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,749 @@
|
|
|
1
|
+
"""The `manifold policy` command group."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import functools
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from manifold_cli.bridge import run_bridge
|
|
22
|
+
from manifold_cli.clients.manifold import ManifoldApiClient
|
|
23
|
+
from manifold_cli.commands.organization import (
|
|
24
|
+
ORGANIZATION_CALL_ERRORS,
|
|
25
|
+
exit_for_call_error,
|
|
26
|
+
resolve_owner,
|
|
27
|
+
)
|
|
28
|
+
from manifold_cli.commands.table import catalog_table, print_table, terminal_width
|
|
29
|
+
from manifold_cli.dependencies import Dependencies, make_in_memory_runner_client
|
|
30
|
+
from manifold_cli.models.catalog import (
|
|
31
|
+
CatalogRegisterError,
|
|
32
|
+
InvalidImageError,
|
|
33
|
+
InvalidReferenceError,
|
|
34
|
+
Visibility,
|
|
35
|
+
join_versioned_ref,
|
|
36
|
+
split_ref,
|
|
37
|
+
split_versioned_ref,
|
|
38
|
+
)
|
|
39
|
+
from manifold_cli.models.errors import RunnerCredentialRejectedError
|
|
40
|
+
from manifold_cli.models.policy import PolicyNotFoundError
|
|
41
|
+
from manifold_cli.models.run import AssignmentDispatch
|
|
42
|
+
from manifold_cli.models.runner import (
|
|
43
|
+
MintedRunner,
|
|
44
|
+
RunnerNameTakenError,
|
|
45
|
+
RunnerSource,
|
|
46
|
+
)
|
|
47
|
+
from manifold_cli.models.runner_home import BoxFacts, PolicyTransport
|
|
48
|
+
from manifold_cli.runner.current_dispatch import CurrentDispatch
|
|
49
|
+
from manifold_cli.runner.daemon import (
|
|
50
|
+
BRIDGE_POLL_SECONDS,
|
|
51
|
+
POLL_TIMEOUT_SECONDS,
|
|
52
|
+
run_daemon,
|
|
53
|
+
)
|
|
54
|
+
from manifold_cli.runner.gpu import gpu_facts
|
|
55
|
+
from manifold_cli.runner.policy import run_served_policy_side
|
|
56
|
+
from manifold_cli.runner.process import AsyncioSpawner
|
|
57
|
+
from manifold_cli.runner.resource_usage import reporting_resource_usage
|
|
58
|
+
from manifold_cli.runner.served import ServedPolicy
|
|
59
|
+
from manifold_cli.runner.signature import read_signature
|
|
60
|
+
from manifold_cli.runner.source import serve_source
|
|
61
|
+
from manifold_cli.runner.stages import (
|
|
62
|
+
callout,
|
|
63
|
+
failed,
|
|
64
|
+
finished,
|
|
65
|
+
started,
|
|
66
|
+
stopped,
|
|
67
|
+
)
|
|
68
|
+
from manifold_cli.transport import DEFAULT_TRANSPORTS, TcpConnector, TransportError
|
|
69
|
+
from manifold_cli.transport.diagnostics import serve_echo
|
|
70
|
+
from manifold_cli.transport.tcp import split_host_port
|
|
71
|
+
|
|
72
|
+
DEFAULT_SERVE_TARGET = "tcp:127.0.0.1:0"
|
|
73
|
+
|
|
74
|
+
# Length of the random suffix added when the hostname is already taken.
|
|
75
|
+
SERVE_NAME_SUFFIX_LENGTH = 6
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def register(app: typer.Typer, deps: Dependencies) -> None:
|
|
79
|
+
console = Console()
|
|
80
|
+
policy_app = typer.Typer(help="Serve and manage policies.", no_args_is_help=True)
|
|
81
|
+
|
|
82
|
+
@policy_app.command("list")
|
|
83
|
+
def list_policies() -> None:
|
|
84
|
+
"""List the policies you can see."""
|
|
85
|
+
try:
|
|
86
|
+
policies = deps.policy_service.list_policies()
|
|
87
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
88
|
+
raise exit_for_call_error(console, exc) from exc
|
|
89
|
+
if len(policies) == 0:
|
|
90
|
+
console.print("There are no policies yet.")
|
|
91
|
+
return
|
|
92
|
+
print_table(console, catalog_table(policies, terminal_width(console)))
|
|
93
|
+
|
|
94
|
+
@policy_app.command()
|
|
95
|
+
def versions(
|
|
96
|
+
policy: Annotated[
|
|
97
|
+
str,
|
|
98
|
+
typer.Argument(
|
|
99
|
+
help="Policy identifier, or <org>/<identifier> to specify its owner."
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
) -> None:
|
|
103
|
+
"""List the versions of a policy, newest first."""
|
|
104
|
+
try:
|
|
105
|
+
owner, slug = split_ref(policy)
|
|
106
|
+
except InvalidReferenceError as exc:
|
|
107
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
108
|
+
raise typer.Exit(1) from exc
|
|
109
|
+
# A qualified reference names its owner outright. A bare slug resolves
|
|
110
|
+
# the way `run submit` resolves one: the caller's own policy first, then
|
|
111
|
+
# a public policy of that slug.
|
|
112
|
+
list_versions = (
|
|
113
|
+
deps.policy_service.list_versions
|
|
114
|
+
if owner is not None
|
|
115
|
+
else deps.policy_service.list_own_or_public_versions
|
|
116
|
+
)
|
|
117
|
+
if owner is None:
|
|
118
|
+
try:
|
|
119
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
120
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
121
|
+
raise exit_for_call_error(console, exc) from exc
|
|
122
|
+
try:
|
|
123
|
+
found = list_versions(slug, owner)
|
|
124
|
+
except PolicyNotFoundError as exc:
|
|
125
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
126
|
+
raise typer.Exit(1) from exc
|
|
127
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
128
|
+
raise exit_for_call_error(console, exc) from exc
|
|
129
|
+
if len(found) == 0:
|
|
130
|
+
console.print(f"'{escape(policy)}' has no versions yet.")
|
|
131
|
+
return
|
|
132
|
+
table = Table(box=None, pad_edge=False)
|
|
133
|
+
table.add_column("Version")
|
|
134
|
+
table.add_column("Adapter", style="dim")
|
|
135
|
+
for version in found:
|
|
136
|
+
table.add_row(Text(version.version, style="bold"), version.adapter.value)
|
|
137
|
+
print_table(console, table)
|
|
138
|
+
|
|
139
|
+
@policy_app.command()
|
|
140
|
+
def init(
|
|
141
|
+
policy: Annotated[
|
|
142
|
+
str,
|
|
143
|
+
typer.Argument(
|
|
144
|
+
help="Policy identifier to register the image under, "
|
|
145
|
+
"or <org>/<identifier> to specify the organization that owns it."
|
|
146
|
+
),
|
|
147
|
+
],
|
|
148
|
+
image: Annotated[
|
|
149
|
+
str,
|
|
150
|
+
typer.Option(
|
|
151
|
+
"--image",
|
|
152
|
+
help="Container image, e.g. ghcr.io/bifrost/smolvla:0.1.0. "
|
|
153
|
+
"Its tag is the version this registers.",
|
|
154
|
+
),
|
|
155
|
+
],
|
|
156
|
+
minimum_gpu_memory_gb: Annotated[
|
|
157
|
+
int,
|
|
158
|
+
typer.Option(
|
|
159
|
+
"--minimum-gpu-memory-gb",
|
|
160
|
+
min=0,
|
|
161
|
+
help="Minimum memory required on one GPU. Set to 0 to allow "
|
|
162
|
+
"placement on runners without a GPU.",
|
|
163
|
+
),
|
|
164
|
+
],
|
|
165
|
+
env: Annotated[
|
|
166
|
+
list[str] | None,
|
|
167
|
+
typer.Option(
|
|
168
|
+
"--env",
|
|
169
|
+
help="Environment variable as KEY=VALUE. Repeat for each variable.",
|
|
170
|
+
),
|
|
171
|
+
] = None,
|
|
172
|
+
minimum_driver_major: Annotated[
|
|
173
|
+
int | None,
|
|
174
|
+
typer.Option(
|
|
175
|
+
"--minimum-driver-major",
|
|
176
|
+
min=1,
|
|
177
|
+
help="Minimum NVIDIA driver major version required on a "
|
|
178
|
+
"runner. Omit to leave the lower bound open.",
|
|
179
|
+
),
|
|
180
|
+
] = None,
|
|
181
|
+
maximum_driver_major: Annotated[
|
|
182
|
+
int | None,
|
|
183
|
+
typer.Option(
|
|
184
|
+
"--maximum-driver-major",
|
|
185
|
+
min=1,
|
|
186
|
+
help="Maximum NVIDIA driver major version allowed on a "
|
|
187
|
+
"runner. Omit to leave the upper bound open.",
|
|
188
|
+
),
|
|
189
|
+
] = None,
|
|
190
|
+
minimum_gpu_count: Annotated[
|
|
191
|
+
int | None,
|
|
192
|
+
typer.Option(
|
|
193
|
+
"--minimum-gpu-count",
|
|
194
|
+
min=0,
|
|
195
|
+
help="Minimum number of GPUs required on one runner. If omitted, "
|
|
196
|
+
"the default is 1, or 0 when --minimum-gpu-memory-gb is 0.",
|
|
197
|
+
),
|
|
198
|
+
] = None,
|
|
199
|
+
display_name: Annotated[
|
|
200
|
+
str | None,
|
|
201
|
+
typer.Option(
|
|
202
|
+
"--display-name",
|
|
203
|
+
help="Name shown in `policy list`. Left off, an existing "
|
|
204
|
+
"policy keeps the name it has and a new one uses its identifier.",
|
|
205
|
+
),
|
|
206
|
+
] = None,
|
|
207
|
+
visibility: Annotated[
|
|
208
|
+
Visibility | None,
|
|
209
|
+
typer.Option(
|
|
210
|
+
"--visibility",
|
|
211
|
+
help="Who can read the policy. Left off, an existing policy "
|
|
212
|
+
"keeps the visibility it has and a new one is visible to its "
|
|
213
|
+
"organization.",
|
|
214
|
+
),
|
|
215
|
+
] = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Register a container image as a version of a policy."""
|
|
218
|
+
environment = _parse_environment(env)
|
|
219
|
+
try:
|
|
220
|
+
owner, slug = split_ref(policy)
|
|
221
|
+
except InvalidReferenceError as exc:
|
|
222
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
223
|
+
raise typer.Exit(1) from exc
|
|
224
|
+
try:
|
|
225
|
+
if owner is None:
|
|
226
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
227
|
+
version = deps.policy_service.register_container_version(
|
|
228
|
+
slug,
|
|
229
|
+
owner,
|
|
230
|
+
image=image,
|
|
231
|
+
environment=environment,
|
|
232
|
+
minimum_gpu_memory_gb=minimum_gpu_memory_gb,
|
|
233
|
+
minimum_driver_major=minimum_driver_major,
|
|
234
|
+
maximum_driver_major=maximum_driver_major,
|
|
235
|
+
minimum_gpu_count=minimum_gpu_count,
|
|
236
|
+
display_name=display_name,
|
|
237
|
+
visibility=visibility,
|
|
238
|
+
)
|
|
239
|
+
except (
|
|
240
|
+
InvalidImageError,
|
|
241
|
+
CatalogRegisterError,
|
|
242
|
+
PolicyNotFoundError,
|
|
243
|
+
) as exc:
|
|
244
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
245
|
+
raise typer.Exit(1) from exc
|
|
246
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
247
|
+
raise exit_for_call_error(console, exc) from exc
|
|
248
|
+
console.print(
|
|
249
|
+
f"[green]✓ Registered[/green] [bold]{escape(policy)}[/bold] "
|
|
250
|
+
f"[bold]{escape(version.version)}[/bold] "
|
|
251
|
+
f"[dim]{version.adapter.value}[/dim]"
|
|
252
|
+
)
|
|
253
|
+
console.print(f" {escape(image)}")
|
|
254
|
+
console.print(
|
|
255
|
+
f"Run it with: manifold run submit {escape(policy)} <benchmark> --name NAME"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@policy_app.command()
|
|
259
|
+
def delete(
|
|
260
|
+
policy: Annotated[
|
|
261
|
+
str,
|
|
262
|
+
typer.Argument(
|
|
263
|
+
help="Policy to delete, as [<org>/]<identifier>[:<version>], for "
|
|
264
|
+
"example my-policy:0.0.1. With a version, only that "
|
|
265
|
+
"version is deleted."
|
|
266
|
+
),
|
|
267
|
+
],
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Delete a policy, or one version of it.
|
|
270
|
+
|
|
271
|
+
A deleted policy or version is left out of listings and cannot be used
|
|
272
|
+
in new runs. `run get` still shows it on the runs that used it.
|
|
273
|
+
Registering it again restores it. A restored policy takes the display
|
|
274
|
+
name and visibility of that registration, not its earlier ones.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
owner, slug, version = split_versioned_ref(policy)
|
|
278
|
+
except InvalidReferenceError as exc:
|
|
279
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
280
|
+
raise typer.Exit(1) from exc
|
|
281
|
+
try:
|
|
282
|
+
if owner is None:
|
|
283
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
284
|
+
deps.policy_service.delete(slug, owner, version)
|
|
285
|
+
except PolicyNotFoundError as exc:
|
|
286
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
287
|
+
raise typer.Exit(1) from exc
|
|
288
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
289
|
+
raise exit_for_call_error(console, exc) from exc
|
|
290
|
+
deleted = "Policy deleted" if version is None else "Policy version deleted"
|
|
291
|
+
console.print(
|
|
292
|
+
f"[green]✓ {deleted}[/green] "
|
|
293
|
+
f"[bold]{escape(join_versioned_ref(owner, slug, version))}[/bold]"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@policy_app.command(
|
|
297
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
|
|
298
|
+
)
|
|
299
|
+
def serve(
|
|
300
|
+
context: typer.Context,
|
|
301
|
+
policy: Annotated[
|
|
302
|
+
str | None,
|
|
303
|
+
typer.Argument(
|
|
304
|
+
help="Policy to serve, as [<org>/]<identifier>:<version>, for "
|
|
305
|
+
"example my-policy:0.0.1."
|
|
306
|
+
),
|
|
307
|
+
] = None,
|
|
308
|
+
echo: Annotated[
|
|
309
|
+
bool,
|
|
310
|
+
typer.Option("--echo", help="Serve a built-in echo policy (echoes bytes)."),
|
|
311
|
+
] = False,
|
|
312
|
+
target: Annotated[
|
|
313
|
+
str,
|
|
314
|
+
typer.Option(
|
|
315
|
+
"--target",
|
|
316
|
+
help="Address for --echo to listen on, e.g. tcp:0.0.0.0:9000 or iroh.",
|
|
317
|
+
),
|
|
318
|
+
] = DEFAULT_SERVE_TARGET,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Serve a policy from this machine.
|
|
321
|
+
|
|
322
|
+
Put the command that starts your policy after `--`:
|
|
323
|
+
|
|
324
|
+
manifold policy serve pi05-libero:0.0.1 -- uv run serve.py
|
|
325
|
+
|
|
326
|
+
This starts the command and waits until your policy accepts
|
|
327
|
+
connections. Then it registers the policy version and this machine
|
|
328
|
+
with Manifold. The runs of this version that you submit go to this
|
|
329
|
+
machine, and the policy loads only once.
|
|
330
|
+
|
|
331
|
+
Press Ctrl-C to stop. This unregisters the machine and stops the
|
|
332
|
+
command.
|
|
333
|
+
"""
|
|
334
|
+
if echo:
|
|
335
|
+
_serve_echo(console, target)
|
|
336
|
+
return
|
|
337
|
+
if policy is None:
|
|
338
|
+
console.print("[red]Name the policy to serve.[/red] For example:")
|
|
339
|
+
console.print(" manifold policy serve my-policy:0.0.1 -- COMMAND")
|
|
340
|
+
raise typer.Exit(1)
|
|
341
|
+
command = list(context.args)
|
|
342
|
+
if len(command) == 0:
|
|
343
|
+
console.print(
|
|
344
|
+
"[red]No command given.[/red] Put the command that starts your "
|
|
345
|
+
"policy after `--`, for example `-- uv run scripts/eval.py`."
|
|
346
|
+
)
|
|
347
|
+
raise typer.Exit(1)
|
|
348
|
+
_serve_policy(console, deps, policy, command=command)
|
|
349
|
+
|
|
350
|
+
@policy_app.command(hidden=True)
|
|
351
|
+
def bridge(
|
|
352
|
+
transport: Annotated[
|
|
353
|
+
str,
|
|
354
|
+
typer.Argument(
|
|
355
|
+
help="Listen target for our transport, e.g. tcp:0.0.0.0:9500 or iroh."
|
|
356
|
+
),
|
|
357
|
+
],
|
|
358
|
+
upstream: Annotated[
|
|
359
|
+
str,
|
|
360
|
+
typer.Option(
|
|
361
|
+
"--upstream", help="Local policy server to dial, as HOST:PORT."
|
|
362
|
+
),
|
|
363
|
+
],
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Bridge a local policy server so it is reachable over our transport."""
|
|
366
|
+
|
|
367
|
+
async def run() -> None:
|
|
368
|
+
host, port = split_host_port(upstream)
|
|
369
|
+
listen_transport, rest = DEFAULT_TRANSPORTS.resolve(transport)
|
|
370
|
+
listener = await listen_transport.open_listener(rest)
|
|
371
|
+
console.print(
|
|
372
|
+
f"[green]Policy bridge up.[/green] "
|
|
373
|
+
f"Dial it at: [bold]{escape(listener.target)}[/bold]"
|
|
374
|
+
)
|
|
375
|
+
console.print(
|
|
376
|
+
f"Forwarding to policy server at [bold]{escape(host)}:{port}[/bold]."
|
|
377
|
+
)
|
|
378
|
+
console.print("Bridging... (press Ctrl-C to stop)")
|
|
379
|
+
await run_bridge(listener, TcpConnector(host, port))
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
asyncio.run(run())
|
|
383
|
+
except KeyboardInterrupt:
|
|
384
|
+
console.print("\n[yellow]Policy bridge stopped.[/yellow]")
|
|
385
|
+
except (TransportError, OSError) as exc:
|
|
386
|
+
console.print(f"[red]Bridge failed:[/red] {escape(str(exc))}")
|
|
387
|
+
raise typer.Exit(1) from exc
|
|
388
|
+
|
|
389
|
+
app.add_typer(policy_app, name="policy")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _serve_echo(console: Console, target: str) -> None:
|
|
393
|
+
"""Serve a policy that echoes bytes back, to test the transport."""
|
|
394
|
+
|
|
395
|
+
async def run() -> None:
|
|
396
|
+
transport, rest = DEFAULT_TRANSPORTS.resolve(target)
|
|
397
|
+
listener = await transport.open_listener(rest)
|
|
398
|
+
console.print(
|
|
399
|
+
"[green]Echo policy serving.[/green] In another shell, reach it with:"
|
|
400
|
+
)
|
|
401
|
+
console.print(
|
|
402
|
+
f" [bold]manifold benchmark run {escape(listener.target)} --ping[/bold]"
|
|
403
|
+
)
|
|
404
|
+
console.print("Waiting for connections... (press Ctrl-C to stop)")
|
|
405
|
+
await serve_echo(listener, on_event=console.print)
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
asyncio.run(run())
|
|
409
|
+
except KeyboardInterrupt:
|
|
410
|
+
console.print("\n[yellow]Policy server stopped.[/yellow]")
|
|
411
|
+
except (TransportError, OSError) as exc:
|
|
412
|
+
console.print(f"[red]Serve failed:[/red] {escape(str(exc))}")
|
|
413
|
+
raise typer.Exit(1) from exc
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _serve_policy(
|
|
417
|
+
console: Console,
|
|
418
|
+
deps: Dependencies,
|
|
419
|
+
policy: str,
|
|
420
|
+
*,
|
|
421
|
+
command: list[str],
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Register the version, start the policy, then take runs once it is ready.
|
|
424
|
+
|
|
425
|
+
The version is registered first, so that a conflict fails before the
|
|
426
|
+
model loads, and so that a submit during the load says that the version
|
|
427
|
+
is not ready yet. Serve stops the policy process whenever it exits.
|
|
428
|
+
"""
|
|
429
|
+
try:
|
|
430
|
+
owner, slug, version = split_versioned_ref(policy)
|
|
431
|
+
except InvalidReferenceError as exc:
|
|
432
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
433
|
+
raise typer.Exit(1) from exc
|
|
434
|
+
if version is None:
|
|
435
|
+
console.print("[red]Name the version to serve.[/red] For example:")
|
|
436
|
+
console.print(f" manifold policy serve {escape(policy)}:0.0.1 -- COMMAND")
|
|
437
|
+
raise typer.Exit(1)
|
|
438
|
+
try:
|
|
439
|
+
if owner is None:
|
|
440
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
441
|
+
deps.policy_service.register_served_version(slug, owner, version=version)
|
|
442
|
+
except (CatalogRegisterError, PolicyNotFoundError) as exc:
|
|
443
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
444
|
+
raise typer.Exit(1) from exc
|
|
445
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
446
|
+
raise exit_for_call_error(console, exc) from exc
|
|
447
|
+
name = f"[bold]{escape(slug)}:{escape(version)}[/bold]"
|
|
448
|
+
console.print(finished(f"Registered {name}"))
|
|
449
|
+
# A shell without a terminal starts a background command with SIGINT
|
|
450
|
+
# ignored, and Python keeps that setting. Serve stops on SIGINT: on
|
|
451
|
+
# Ctrl-C, on `kill -INT`, and when the policy process exits. So it puts
|
|
452
|
+
# the default handler back.
|
|
453
|
+
if signal.getsignal(signal.SIGINT) == signal.SIG_IGN:
|
|
454
|
+
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
455
|
+
console.print(started(f"Starting {name}"))
|
|
456
|
+
try:
|
|
457
|
+
served = ServedPolicy.start(command)
|
|
458
|
+
except OSError as exc:
|
|
459
|
+
console.print(failed("Could not start your command", detail=escape(str(exc))))
|
|
460
|
+
raise typer.Exit(1) from exc
|
|
461
|
+
try:
|
|
462
|
+
_serve_started_policy(
|
|
463
|
+
console,
|
|
464
|
+
deps,
|
|
465
|
+
served,
|
|
466
|
+
policy,
|
|
467
|
+
owner=owner,
|
|
468
|
+
slug=slug,
|
|
469
|
+
version=version,
|
|
470
|
+
source=serve_source(command, Path.cwd()),
|
|
471
|
+
)
|
|
472
|
+
finally:
|
|
473
|
+
served.stop()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _serve_started_policy(
|
|
477
|
+
console: Console,
|
|
478
|
+
deps: Dependencies,
|
|
479
|
+
served: ServedPolicy,
|
|
480
|
+
policy: str,
|
|
481
|
+
*,
|
|
482
|
+
owner: str,
|
|
483
|
+
slug: str,
|
|
484
|
+
version: str,
|
|
485
|
+
source: RunnerSource,
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Wait until the policy is ready, register this machine, then take runs.
|
|
488
|
+
|
|
489
|
+
The runner is registered only after the policy accepts connections. So
|
|
490
|
+
when a submit finds a registered runner, the policy behind it is ready.
|
|
491
|
+
"""
|
|
492
|
+
began = time.monotonic()
|
|
493
|
+
try:
|
|
494
|
+
ready = asyncio.run(served.wait_until_ready())
|
|
495
|
+
except TimeoutError as exc:
|
|
496
|
+
console.print(failed(escape(str(exc))))
|
|
497
|
+
raise typer.Exit(1) from exc
|
|
498
|
+
except KeyboardInterrupt:
|
|
499
|
+
console.print("\n" + stopped("Policy server stopped"))
|
|
500
|
+
return
|
|
501
|
+
loading_s = time.monotonic() - began
|
|
502
|
+
if not ready:
|
|
503
|
+
console.print(
|
|
504
|
+
failed(
|
|
505
|
+
f"Your policy process exited with code {served.exit_code} "
|
|
506
|
+
"before it was ready",
|
|
507
|
+
elapsed_s=loading_s,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
raise typer.Exit(1)
|
|
511
|
+
console.print(
|
|
512
|
+
finished(
|
|
513
|
+
"Policy loaded",
|
|
514
|
+
elapsed_s=loading_s,
|
|
515
|
+
detail=f"Listening on {served.wire.address}",
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
try:
|
|
519
|
+
served_signature = asyncio.run(read_signature(served.wire))
|
|
520
|
+
except ValidationError as exc:
|
|
521
|
+
console.print(
|
|
522
|
+
failed(
|
|
523
|
+
"Your policy sent its serving script in the wrong format",
|
|
524
|
+
detail=escape(str(exc)),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
raise typer.Exit(1) from exc
|
|
528
|
+
signature = None
|
|
529
|
+
if served_signature is not None:
|
|
530
|
+
signature = served_signature.signature
|
|
531
|
+
source = source.model_copy(
|
|
532
|
+
update={
|
|
533
|
+
"sdk_version": served_signature.sdk_version,
|
|
534
|
+
"script": served_signature.script,
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
try:
|
|
538
|
+
minted = _register_serving_runner(
|
|
539
|
+
console,
|
|
540
|
+
deps,
|
|
541
|
+
owner,
|
|
542
|
+
policy=slug,
|
|
543
|
+
version=version,
|
|
544
|
+
source=source,
|
|
545
|
+
signature=signature,
|
|
546
|
+
)
|
|
547
|
+
except (CatalogRegisterError, PolicyNotFoundError) as exc:
|
|
548
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
549
|
+
raise typer.Exit(1) from exc
|
|
550
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
551
|
+
raise exit_for_call_error(console, exc) from exc
|
|
552
|
+
console.print(
|
|
553
|
+
finished(
|
|
554
|
+
"Registered this machine as runner "
|
|
555
|
+
f"[bold]{escape(minted.runner.name)}[/bold]"
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
console.print(
|
|
559
|
+
finished(f"Ready. Serving [bold]{escape(slug)}:{escape(version)}[/bold]")
|
|
560
|
+
)
|
|
561
|
+
console.print(
|
|
562
|
+
callout(
|
|
563
|
+
"To submit a run, in another shell:",
|
|
564
|
+
f" [bold]manifold run submit {escape(policy)} <benchmark> "
|
|
565
|
+
"--name NAME[/bold]",
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
console.print("[dim]Waiting for work... (press Ctrl-C to stop)[/dim]")
|
|
569
|
+
_run_until_stopped(console, deps, minted, served)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _register_serving_runner(
|
|
573
|
+
console: Console,
|
|
574
|
+
deps: Dependencies,
|
|
575
|
+
owner: str,
|
|
576
|
+
*,
|
|
577
|
+
policy: str,
|
|
578
|
+
version: str,
|
|
579
|
+
source: RunnerSource,
|
|
580
|
+
signature: dict[str, Any] | None,
|
|
581
|
+
) -> MintedRunner:
|
|
582
|
+
"""Register this machine as a runner named after its hostname.
|
|
583
|
+
|
|
584
|
+
If the name is taken, retry once with a random suffix. The name is taken
|
|
585
|
+
when another `manifold policy serve` runs on this machine, when an earlier
|
|
586
|
+
one was killed before it unregistered, or when a runner in another
|
|
587
|
+
organization uses the name.
|
|
588
|
+
"""
|
|
589
|
+
name = socket.gethostname() or "workstation"
|
|
590
|
+
gpu_devices = gpu_facts().devices
|
|
591
|
+
try:
|
|
592
|
+
return deps.runner_service.register_serving(
|
|
593
|
+
name,
|
|
594
|
+
owner,
|
|
595
|
+
policy=policy,
|
|
596
|
+
version=version,
|
|
597
|
+
gpu_devices=gpu_devices,
|
|
598
|
+
source=source,
|
|
599
|
+
signature=signature,
|
|
600
|
+
)
|
|
601
|
+
except RunnerNameTakenError:
|
|
602
|
+
unique = f"{name}-{uuid4().hex[:SERVE_NAME_SUFFIX_LENGTH]}"
|
|
603
|
+
return deps.runner_service.register_serving(
|
|
604
|
+
unique,
|
|
605
|
+
owner,
|
|
606
|
+
policy=policy,
|
|
607
|
+
version=version,
|
|
608
|
+
gpu_devices=gpu_devices,
|
|
609
|
+
source=source,
|
|
610
|
+
signature=signature,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _run_until_stopped(
|
|
615
|
+
console: Console, deps: Dependencies, minted: MintedRunner, served: ServedPolicy
|
|
616
|
+
) -> None:
|
|
617
|
+
"""Run the runner loop until Ctrl-C or until the policy exits, then unregister.
|
|
618
|
+
|
|
619
|
+
When the policy process exits, serve sends itself SIGINT. The runner loop
|
|
620
|
+
may be waiting on a long poll, and SIGINT is what interrupts that wait.
|
|
621
|
+
|
|
622
|
+
Ctrl-C during a run cancels the run. When the policy process exited, the
|
|
623
|
+
policy side reports the exit instead.
|
|
624
|
+
"""
|
|
625
|
+
client = make_in_memory_runner_client(minted.token, deps.manifold_api_url)
|
|
626
|
+
current_dispatch = CurrentDispatch()
|
|
627
|
+
try:
|
|
628
|
+
# A SIGINT outside the loop's waits for work and its sides raises
|
|
629
|
+
# KeyboardInterrupt. The policy may already have exited during
|
|
630
|
+
# registration, and then on_exit sends SIGINT immediately, so call
|
|
631
|
+
# `served.on_exit` inside the `suppress`.
|
|
632
|
+
with (
|
|
633
|
+
contextlib.suppress(KeyboardInterrupt),
|
|
634
|
+
reporting_resource_usage(
|
|
635
|
+
client=client,
|
|
636
|
+
current_dispatch=current_dispatch,
|
|
637
|
+
docker=None,
|
|
638
|
+
log=console.print,
|
|
639
|
+
),
|
|
640
|
+
):
|
|
641
|
+
served.on_exit(lambda _code: os.kill(os.getpid(), signal.SIGINT))
|
|
642
|
+
run_daemon(
|
|
643
|
+
client=client,
|
|
644
|
+
spawner=AsyncioSpawner(),
|
|
645
|
+
box=BoxFacts(
|
|
646
|
+
gpu_devices=[],
|
|
647
|
+
cache_dir=None,
|
|
648
|
+
advertise_host=None,
|
|
649
|
+
# IROH, because the benchmark runner may not be able to reach
|
|
650
|
+
# this machine.
|
|
651
|
+
# TODO: This fixes the transport when the runner is created.
|
|
652
|
+
# That only suits one-time runners like this one, which last
|
|
653
|
+
# for one CLI process. Do not do this for registered runners.
|
|
654
|
+
# Pick their transport when a run starts, so that a box can
|
|
655
|
+
# move to TCP once the fleet can reach it, for example over a
|
|
656
|
+
# VPN.
|
|
657
|
+
policy_transport=PolicyTransport.IROH,
|
|
658
|
+
),
|
|
659
|
+
poll_timeout_s=POLL_TIMEOUT_SECONDS,
|
|
660
|
+
bridge_poll_s=BRIDGE_POLL_SECONDS,
|
|
661
|
+
current_dispatch=current_dispatch,
|
|
662
|
+
stop_signal=signal.SIGINT,
|
|
663
|
+
start_runner=_send_nothing,
|
|
664
|
+
stop_runner=_send_nothing,
|
|
665
|
+
stop_side=functools.partial(
|
|
666
|
+
_cancel_served_run, console=console, client=client, served=served
|
|
667
|
+
),
|
|
668
|
+
# The runner copies its lines into the run's log, and the app shows
|
|
669
|
+
# when a run starts and ends. So the terminal skips them.
|
|
670
|
+
log=_discard,
|
|
671
|
+
policy_side=functools.partial(run_served_policy_side, served=served),
|
|
672
|
+
)
|
|
673
|
+
exit_code = served.exit_code
|
|
674
|
+
if exit_code is None:
|
|
675
|
+
console.print("\n" + stopped("Policy server stopped"))
|
|
676
|
+
except RunnerCredentialRejectedError as exc:
|
|
677
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
678
|
+
console.print("This runner was revoked while serving.")
|
|
679
|
+
raise typer.Exit(1) from exc
|
|
680
|
+
finally:
|
|
681
|
+
# Serve is stopping, so a policy that exits from here on is expected.
|
|
682
|
+
served.ignore_exit()
|
|
683
|
+
_release(console, client)
|
|
684
|
+
client.close()
|
|
685
|
+
if exit_code is not None:
|
|
686
|
+
console.print(failed(f"Your policy process exited with code {exit_code}"))
|
|
687
|
+
raise typer.Exit(1)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _send_nothing() -> None:
|
|
691
|
+
"""Stand in for the start and stop requests. A serving runner does not
|
|
692
|
+
send them: placement chooses it by the version it serves."""
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _cancel_served_run(
|
|
696
|
+
dispatch: AssignmentDispatch,
|
|
697
|
+
*,
|
|
698
|
+
console: Console,
|
|
699
|
+
client: ManifoldApiClient,
|
|
700
|
+
served: ServedPolicy,
|
|
701
|
+
) -> None:
|
|
702
|
+
"""Cancel the dispatch's run, unless the policy process exited.
|
|
703
|
+
|
|
704
|
+
Print the error from a failed cancel instead of raising it: the policy
|
|
705
|
+
side's own failed report then ends the side.
|
|
706
|
+
"""
|
|
707
|
+
if served.exit_code is not None:
|
|
708
|
+
return
|
|
709
|
+
try:
|
|
710
|
+
client.cancel_run(dispatch.run.id)
|
|
711
|
+
except Exception as exc:
|
|
712
|
+
console.print(
|
|
713
|
+
"[yellow]Could not cancel the run:[/yellow] "
|
|
714
|
+
f"{escape(str(exc) or type(exc).__name__)}"
|
|
715
|
+
)
|
|
716
|
+
else:
|
|
717
|
+
console.print(
|
|
718
|
+
"\n" + stopped(f"Canceled run [bold]{escape(dispatch.run.name)}[/bold]")
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _discard(message: str) -> None:
|
|
723
|
+
"""Drop a line from the runner loop."""
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _release(console: Console, client: ManifoldApiClient) -> None:
|
|
727
|
+
"""Unregister this runner, printing any error instead of raising it.
|
|
728
|
+
|
|
729
|
+
This runs in a `finally`, where raising would hide the original exception.
|
|
730
|
+
"""
|
|
731
|
+
try:
|
|
732
|
+
client.release_runner()
|
|
733
|
+
except Exception as exc:
|
|
734
|
+
console.print(
|
|
735
|
+
"[yellow]Could not unregister this runner:[/yellow] "
|
|
736
|
+
f"{escape(str(exc) or type(exc).__name__)}. Manifold stops sending "
|
|
737
|
+
"it runs once it stops polling. An operator can remove it with "
|
|
738
|
+
"`manifold runner revoke`."
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _parse_environment(values: list[str] | None) -> dict[str, str]:
|
|
743
|
+
environment: dict[str, str] = {}
|
|
744
|
+
for value in values or []:
|
|
745
|
+
key, separator, setting = value.partition("=")
|
|
746
|
+
if not separator or not key:
|
|
747
|
+
raise typer.BadParameter("must be KEY=VALUE", param_hint="--env")
|
|
748
|
+
environment[key] = setting
|
|
749
|
+
return environment
|