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,1288 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from collections.abc import AsyncIterator
5
+ from contextlib import asynccontextmanager
6
+ from datetime import datetime
7
+ from http import HTTPStatus
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+ from uuid import UUID
12
+
13
+ import httpx
14
+ from pydantic import TypeAdapter
15
+
16
+ from manifold_cli.models.auth import CliConfig, User
17
+ from manifold_cli.models.benchmark import (
18
+ Benchmark,
19
+ BenchmarkNotFoundError,
20
+ BenchmarkVersion,
21
+ RubricContent,
22
+ )
23
+ from manifold_cli.models.catalog import Adapter, CatalogRegisterError, Visibility
24
+ from manifold_cli.models.errors import (
25
+ ManifoldApiError,
26
+ error_detail,
27
+ raw_error_detail,
28
+ )
29
+ from manifold_cli.models.log import (
30
+ LogBatch,
31
+ LogLine,
32
+ LogMatch,
33
+ LogProducer,
34
+ LogSource,
35
+ RunLogStream,
36
+ )
37
+ from manifold_cli.models.observability import (
38
+ ReportBenchmarkMetricsRequest,
39
+ ReportPolicyMetricsRequest,
40
+ ReportResourceUsageRequest,
41
+ )
42
+ from manifold_cli.models.organization import Organization, OrganizationNotFoundError
43
+ from manifold_cli.models.pagination import Page, SortOrder
44
+ from manifold_cli.models.policy import Policy, PolicyNotFoundError, PolicyVersion
45
+ from manifold_cli.models.run import (
46
+ Assignment,
47
+ AssignmentDispatch,
48
+ AssignmentNotAssignedToRunnerError,
49
+ Episode,
50
+ EpisodeReport,
51
+ EpisodesRejectedError,
52
+ PublishedLiveView,
53
+ ReplayRejectedError,
54
+ ReplayUploadSlot,
55
+ Run,
56
+ RunBridge,
57
+ RunCancelRejectedError,
58
+ RunDeleteRejectedError,
59
+ RunNotFoundError,
60
+ RunSideRefusedError,
61
+ RunStatus,
62
+ RunSubmitError,
63
+ SchedulingType,
64
+ SideHeartbeat,
65
+ SideOutcome,
66
+ )
67
+ from manifold_cli.models.runner import (
68
+ GpuModel,
69
+ InstanceOverrideType,
70
+ ListedRunner,
71
+ MintedRunner,
72
+ Runner,
73
+ RunnerGpuDevice,
74
+ RunnerNameTakenError,
75
+ RunnerNotFoundError,
76
+ RunnerProvider,
77
+ RunnerSource,
78
+ )
79
+ from manifold_cli.models.task_queue import (
80
+ TaskClaim,
81
+ TaskQueueLeaseLostError,
82
+ TaskResult,
83
+ )
84
+ from manifold_cli.models.token import (
85
+ AccessToken,
86
+ AccessTokenNotFoundError,
87
+ CreatedAccessToken,
88
+ Scope,
89
+ )
90
+
91
+ logger = logging.getLogger(__name__)
92
+
93
+ # The recording's own media type, signed into the slot, so the PUT has to send it.
94
+ RRD_MIME = "application/vnd.rerun.rrd"
95
+
96
+ # A recording runs to megabytes and the upload is a single request, so it gets
97
+ # its own generous timeout rather than the client's default.
98
+ REPLAY_PUT_TIMEOUT = 120.0
99
+ HEARTBEAT_TIMEOUT_SECONDS = 5.0
100
+ # An episode report is a write the server keeps whether or not the reply is
101
+ # read, so it waits well past httpx's five-second default before giving up.
102
+ EPISODE_REPORT_TIMEOUT_SECONDS = 30.0
103
+
104
+ _ACCESS_TOKENS_ADAPTER = TypeAdapter(list[AccessToken])
105
+
106
+
107
+ class AssignmentHeartbeatClient:
108
+ def __init__(self, base_url: str, client: httpx.AsyncClient):
109
+ self._base_url = base_url
110
+ self._client = client
111
+
112
+ async def send(self, assignment: Assignment, *, serving: bool) -> SideHeartbeat:
113
+ """Report this assignment's side alive, and serving when `serving` is set.
114
+
115
+ `assignment` is the attempt returned by the dispatch. Including it in
116
+ the request prevents the server from updating a later attempt if the
117
+ request completes after the first attempt ends.
118
+
119
+ A heartbeat that is not serving is the daemon's liveness until the
120
+ policy has accepted the benchmark's signature: the server marks the
121
+ runner seen without changing its preparation or startup phase.
122
+ The first serving heartbeat sets the side to `running`.
123
+
124
+ The answer carries how the peer finished, which is the other instruction
125
+ a side gets: a policy container serves until it is stopped, so its own
126
+ worker ending does not mean the run is over.
127
+
128
+ Raises `AssignmentNotAssignedToRunnerError` when the attempt is no longer this
129
+ runner's. That refusal is the instruction to tear the worker down.
130
+ """
131
+ async with asyncio.timeout(HEARTBEAT_TIMEOUT_SECONDS):
132
+ response = await self._client.post(
133
+ f"{self._base_url}/v0/runs/{assignment.run_id}"
134
+ f"/attempts/{assignment.id}/heartbeat",
135
+ params={"serving": serving},
136
+ )
137
+ if response.status_code == HTTPStatus.CONFLICT:
138
+ raise _heartbeat_refusal(response, assignment.id)
139
+ if response.is_error:
140
+ raise ManifoldApiError(response.status_code, response.text)
141
+ return SideHeartbeat.model_validate(response.json())
142
+
143
+
144
+ class ManifoldApiClient:
145
+ def __init__(self, base_url: str, auth: httpx.Auth | None = None):
146
+ self._base_url = base_url.rstrip("/")
147
+ self._auth = auth
148
+ self._client = httpx.Client(auth=auth)
149
+
150
+ @property
151
+ def base_url(self) -> str:
152
+ """The deployment this client sends its requests to.
153
+
154
+ The policy side reads it to work out which of its own addresses a peer
155
+ dials: the interface that routes to this URL is the one a peer reaches.
156
+ """
157
+ return self._base_url
158
+
159
+ def close(self) -> None:
160
+ self._client.close()
161
+
162
+ def get_cli_config(self) -> CliConfig:
163
+ # Unauthenticated bootstrap: this endpoint needs no token, and it is
164
+ # also what the auth flow fetches to refresh one — routing it through
165
+ # auth would recurse. auth=None opts this request out of the client's
166
+ # auth flow entirely.
167
+ response = self._client.get(f"{self._base_url}/auth/cli/config", auth=None)
168
+ if response.is_error:
169
+ raise ManifoldApiError(response.status_code, response.text)
170
+ return CliConfig.model_validate(response.json())
171
+
172
+ def get_me(self) -> User:
173
+ response = self._client.get(f"{self._base_url}/auth/me")
174
+ if response.is_error:
175
+ raise ManifoldApiError(response.status_code, response.text)
176
+ return User.model_validate(response.json())
177
+
178
+ def list_organizations(self) -> list[Organization]:
179
+ response = self._client.get(f"{self._base_url}/organization")
180
+ if response.is_error:
181
+ raise ManifoldApiError(response.status_code, response.text)
182
+ return [Organization.model_validate(item) for item in response.json()]
183
+
184
+ def list_policies(
185
+ self, *, limit: int | None = None, cursor: str | None = None
186
+ ) -> Page[Policy]:
187
+ response = self._client.get(
188
+ f"{self._base_url}/v0/policies", params=_page_params(limit, cursor)
189
+ )
190
+ if response.is_error:
191
+ raise ManifoldApiError(response.status_code, response.text)
192
+ return Page[Policy].model_validate(response.json())
193
+
194
+ def list_benchmarks(
195
+ self, *, limit: int | None = None, cursor: str | None = None
196
+ ) -> Page[Benchmark]:
197
+ response = self._client.get(
198
+ f"{self._base_url}/v0/benchmarks", params=_page_params(limit, cursor)
199
+ )
200
+ if response.is_error:
201
+ raise ManifoldApiError(response.status_code, response.text)
202
+ return Page[Benchmark].model_validate(response.json())
203
+
204
+ def list_policy_versions(self, slug: str, organization: str) -> list[PolicyVersion]:
205
+ response = self._client.get(
206
+ f"{self._base_url}/v0/policies/{slug}/versions",
207
+ params={"organization": organization},
208
+ )
209
+ if response.status_code == HTTPStatus.NOT_FOUND:
210
+ raise PolicyNotFoundError(
211
+ error_detail(response.text) or f"'{slug}' was not found"
212
+ )
213
+ if response.is_error:
214
+ raise ManifoldApiError(response.status_code, response.text)
215
+ return [PolicyVersion.model_validate(item) for item in response.json()]
216
+
217
+ def list_benchmark_versions(
218
+ self, slug: str, organization: str
219
+ ) -> list[BenchmarkVersion]:
220
+ response = self._client.get(
221
+ f"{self._base_url}/v0/benchmarks/{slug}/versions",
222
+ params={"organization": organization},
223
+ )
224
+ if response.status_code == HTTPStatus.NOT_FOUND:
225
+ raise BenchmarkNotFoundError(
226
+ error_detail(response.text) or f"'{slug}' was not found"
227
+ )
228
+ if response.is_error:
229
+ raise ManifoldApiError(response.status_code, response.text)
230
+ return [BenchmarkVersion.model_validate(item) for item in response.json()]
231
+
232
+ def upsert_policy(
233
+ self,
234
+ slug: str,
235
+ organization: str,
236
+ *,
237
+ display_name: str,
238
+ visibility: Visibility,
239
+ ) -> None:
240
+ response = self._client.put(
241
+ f"{self._base_url}/v0/policies/{slug}",
242
+ json={
243
+ "organization": organization,
244
+ "display_name": display_name,
245
+ "visibility": visibility.value,
246
+ },
247
+ )
248
+ _raise_for_register_rejection(response, slug)
249
+ if response.is_error:
250
+ raise ManifoldApiError(response.status_code, response.text)
251
+
252
+ def register_policy_version(
253
+ self,
254
+ slug: str,
255
+ version: str,
256
+ organization: str,
257
+ *,
258
+ adapter: Adapter,
259
+ config: dict[str, Any],
260
+ minimum_gpu_memory_gb: int,
261
+ minimum_driver_major: int | None,
262
+ maximum_driver_major: int | None,
263
+ minimum_gpu_count: int | None = None,
264
+ ) -> PolicyVersion:
265
+ body = {
266
+ "organization": organization,
267
+ "adapter": adapter.value,
268
+ "config": config,
269
+ "minimum_gpu_memory_gb": minimum_gpu_memory_gb,
270
+ }
271
+ if minimum_driver_major is not None:
272
+ body["minimum_driver_major"] = minimum_driver_major
273
+ if maximum_driver_major is not None:
274
+ body["maximum_driver_major"] = maximum_driver_major
275
+ if minimum_gpu_count is not None:
276
+ body["minimum_gpu_count"] = minimum_gpu_count
277
+ response = self._client.put(
278
+ f"{self._base_url}/v0/policies/{slug}/versions/{version}",
279
+ json=body,
280
+ )
281
+ if response.status_code == HTTPStatus.NOT_FOUND:
282
+ raise PolicyNotFoundError(
283
+ error_detail(response.text) or f"'{slug}' was not found"
284
+ )
285
+ _raise_for_register_rejection(response, slug)
286
+ if response.is_error:
287
+ raise ManifoldApiError(response.status_code, response.text)
288
+ return PolicyVersion.model_validate(response.json())
289
+
290
+ def delete_policy(self, slug: str, organization: str) -> None:
291
+ response = self._client.delete(
292
+ f"{self._base_url}/v0/policies/{quote(slug, safe='')}",
293
+ params={"organization": organization},
294
+ )
295
+ if response.status_code == HTTPStatus.NOT_FOUND:
296
+ raise PolicyNotFoundError(
297
+ error_detail(response.text) or f"'{slug}' was not found"
298
+ )
299
+ if response.is_error:
300
+ raise ManifoldApiError(response.status_code, response.text)
301
+
302
+ def delete_policy_version(self, slug: str, version: str, organization: str) -> None:
303
+ response = self._client.delete(
304
+ f"{self._base_url}/v0/policies/{quote(slug, safe='')}"
305
+ f"/versions/{quote(version, safe='')}",
306
+ params={"organization": organization},
307
+ )
308
+ if response.status_code == HTTPStatus.NOT_FOUND:
309
+ raise PolicyNotFoundError(
310
+ error_detail(response.text) or f"'{slug}:{version}' was not found"
311
+ )
312
+ if response.is_error:
313
+ raise ManifoldApiError(response.status_code, response.text)
314
+
315
+ def upsert_benchmark(
316
+ self,
317
+ slug: str,
318
+ organization: str,
319
+ *,
320
+ display_name: str,
321
+ visibility: Visibility,
322
+ ) -> None:
323
+ response = self._client.put(
324
+ f"{self._base_url}/v0/benchmarks/{slug}",
325
+ json={
326
+ "organization": organization,
327
+ "display_name": display_name,
328
+ "visibility": visibility.value,
329
+ },
330
+ )
331
+ _raise_for_register_rejection(response, slug)
332
+ if response.is_error:
333
+ raise ManifoldApiError(response.status_code, response.text)
334
+
335
+ def register_benchmark_version(
336
+ self,
337
+ slug: str,
338
+ version: str,
339
+ organization: str,
340
+ *,
341
+ adapter: Adapter,
342
+ config: dict[str, Any],
343
+ episodes: int,
344
+ shards: int,
345
+ shards_per_gpu: int | None,
346
+ minimum_gpu_memory_gb: int,
347
+ minimum_driver_major: int | None,
348
+ maximum_driver_major: int | None,
349
+ minimum_gpu_count: int | None = None,
350
+ scheduling_type: SchedulingType,
351
+ allow_preemptible: bool,
352
+ ) -> BenchmarkVersion:
353
+ body = {
354
+ "organization": organization,
355
+ "adapter": adapter.value,
356
+ "config": config,
357
+ "episodes": episodes,
358
+ "shards": shards,
359
+ "minimum_gpu_memory_gb": minimum_gpu_memory_gb,
360
+ "scheduling_type": scheduling_type.value,
361
+ "allow_preemptible": allow_preemptible,
362
+ "shards_per_gpu": shards_per_gpu,
363
+ }
364
+ if minimum_driver_major is not None:
365
+ body["minimum_driver_major"] = minimum_driver_major
366
+ if maximum_driver_major is not None:
367
+ body["maximum_driver_major"] = maximum_driver_major
368
+ if minimum_gpu_count is not None:
369
+ body["minimum_gpu_count"] = minimum_gpu_count
370
+ response = self._client.put(
371
+ f"{self._base_url}/v0/benchmarks/{slug}/versions/{version}",
372
+ json=body,
373
+ )
374
+ if response.status_code == HTTPStatus.NOT_FOUND:
375
+ raise BenchmarkNotFoundError(
376
+ error_detail(response.text) or f"'{slug}' was not found"
377
+ )
378
+ _raise_for_register_rejection(response, slug)
379
+ if response.is_error:
380
+ raise ManifoldApiError(response.status_code, response.text)
381
+ return BenchmarkVersion.model_validate(response.json())
382
+
383
+ def publish_benchmark_rubric(
384
+ self,
385
+ slug: str,
386
+ version: str,
387
+ organization: str,
388
+ *,
389
+ rubric: RubricContent,
390
+ ) -> None:
391
+ """Publish `rubric` as the benchmark version's rubric.
392
+
393
+ The client sends the groups and tasks exactly as it read them from the
394
+ image. The server replaces any stored rubric and checks each group and
395
+ task.
396
+ """
397
+ response = self._client.put(
398
+ f"{self._base_url}/v0/benchmarks/{slug}/versions/{version}/rubric",
399
+ json={
400
+ "organization": organization,
401
+ "groups": rubric.groups,
402
+ "tasks": rubric.tasks,
403
+ "grading": rubric.grading,
404
+ },
405
+ )
406
+ if response.status_code == HTTPStatus.NOT_FOUND:
407
+ raise BenchmarkNotFoundError(
408
+ error_detail(response.text) or f"'{slug}' was not found"
409
+ )
410
+ _raise_for_register_rejection(response, slug)
411
+ if response.is_error:
412
+ raise ManifoldApiError(response.status_code, response.text)
413
+
414
+ def delete_benchmark(self, slug: str, organization: str) -> None:
415
+ response = self._client.delete(
416
+ f"{self._base_url}/v0/benchmarks/{quote(slug, safe='')}",
417
+ params={"organization": organization},
418
+ )
419
+ if response.status_code == HTTPStatus.NOT_FOUND:
420
+ raise BenchmarkNotFoundError(
421
+ error_detail(response.text) or f"'{slug}' was not found"
422
+ )
423
+ if response.is_error:
424
+ raise ManifoldApiError(response.status_code, response.text)
425
+
426
+ def delete_benchmark_version(
427
+ self, slug: str, version: str, organization: str
428
+ ) -> None:
429
+ response = self._client.delete(
430
+ f"{self._base_url}/v0/benchmarks/{quote(slug, safe='')}"
431
+ f"/versions/{quote(version, safe='')}",
432
+ params={"organization": organization},
433
+ )
434
+ if response.status_code == HTTPStatus.NOT_FOUND:
435
+ raise BenchmarkNotFoundError(
436
+ error_detail(response.text) or f"'{slug}:{version}' was not found"
437
+ )
438
+ if response.is_error:
439
+ raise ManifoldApiError(response.status_code, response.text)
440
+
441
+ def submit_run(
442
+ self,
443
+ organization: str,
444
+ policy: str,
445
+ benchmark: str,
446
+ *,
447
+ name: str,
448
+ shards: int | None,
449
+ shards_per_gpu: int | None,
450
+ policy_version: str | None = None,
451
+ benchmark_version: str | None = None,
452
+ ) -> Run:
453
+ body: dict[str, Any] = {
454
+ "organization": organization,
455
+ "policy": policy,
456
+ "benchmark": benchmark,
457
+ "name": name,
458
+ "shards_per_gpu": shards_per_gpu,
459
+ }
460
+ # Left out rather than sent as null: an absent key leaves the default
461
+ # to the server, which takes the newest version and the benchmark
462
+ # version's own shard count.
463
+ if policy_version is not None:
464
+ body["policy_version"] = policy_version
465
+ if benchmark_version is not None:
466
+ body["benchmark_version"] = benchmark_version
467
+ if shards is not None:
468
+ body["shards"] = shards
469
+ response = self._client.post(f"{self._base_url}/v0/runs", json=body)
470
+ # A 403 here is the server refusing the pairing itself: a private policy
471
+ # or benchmark owned by an organization other than the run's. A 409
472
+ # means that the submitter does not serve the self-hosted version. Both
473
+ # are the caller's to fix, not an API fault.
474
+ if response.status_code in (
475
+ HTTPStatus.NOT_FOUND,
476
+ HTTPStatus.FORBIDDEN,
477
+ HTTPStatus.CONFLICT,
478
+ ):
479
+ raise RunSubmitError(
480
+ error_detail(response.text) or "The run could not be submitted."
481
+ )
482
+ if response.is_error:
483
+ raise ManifoldApiError(response.status_code, response.text)
484
+ return Run.model_validate(response.json())
485
+
486
+ def list_runs(
487
+ self, organization: str, *, limit: int | None = None, cursor: str | None = None
488
+ ) -> Page[Run]:
489
+ response = self._client.get(
490
+ f"{self._base_url}/v0/runs",
491
+ params={"organization": organization, **_page_params(limit, cursor)},
492
+ )
493
+ # NOTE: the runs themselves are filtered, not looked up, so the only
494
+ # thing this route can fail to find is the organization.
495
+ if response.status_code == HTTPStatus.NOT_FOUND:
496
+ # The fallback message deliberately does not distinguish a slug
497
+ # owned by someone else from one that does not exist.
498
+ raise OrganizationNotFoundError(
499
+ error_detail(response.text) or f"'{organization}' was not found"
500
+ )
501
+ if response.is_error:
502
+ raise ManifoldApiError(response.status_code, response.text)
503
+ return Page[Run].model_validate(response.json())
504
+
505
+ def get_run(self, run_id: UUID) -> Run:
506
+ response = self._client.get(f"{self._base_url}/v0/runs/{run_id}")
507
+ if response.status_code == HTTPStatus.NOT_FOUND:
508
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
509
+ if response.is_error:
510
+ raise ManifoldApiError(response.status_code, response.text)
511
+ return Run.model_validate(response.json())
512
+
513
+ def cancel_run(self, run_id: UUID) -> Run:
514
+ response = self._client.post(
515
+ f"{self._base_url}/v0/runs/{run_id}/status",
516
+ json={"status": "canceled"},
517
+ )
518
+ if response.status_code == HTTPStatus.NOT_FOUND:
519
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
520
+ # A 409 means the run is already in a terminal state, possibly since
521
+ # the caller last read it.
522
+ if response.status_code == HTTPStatus.CONFLICT:
523
+ raise RunCancelRejectedError(
524
+ error_detail(response.text) or "the run cannot be canceled"
525
+ )
526
+ if response.is_error:
527
+ raise ManifoldApiError(response.status_code, response.text)
528
+ return Run.model_validate(response.json())
529
+
530
+ def delete_run(self, run_id: UUID) -> None:
531
+ response = self._client.delete(f"{self._base_url}/v0/runs/{run_id}")
532
+ if response.status_code == HTTPStatus.NOT_FOUND:
533
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
534
+ if response.status_code == HTTPStatus.CONFLICT:
535
+ raise RunDeleteRejectedError(
536
+ error_detail(response.text) or "the run cannot be deleted"
537
+ )
538
+ if response.is_error:
539
+ raise ManifoldApiError(response.status_code, response.text)
540
+
541
+ def poll_for_assignment(self, *, timeout_s: float) -> AssignmentDispatch | None:
542
+ """Wait for the assignment naming this runner, and collect it.
543
+
544
+ The runner does not choose: a placement pass writes the row before this
545
+ is called, and the poll hands over one side or nothing. None means the
546
+ request was held open and nothing arrived, which is the ordinary answer
547
+ for an idle runner -- ask again.
548
+
549
+ `timeout_s` has to exceed the server's own poll window, or every poll
550
+ looks like a transport failure from here.
551
+ """
552
+ response = self._client.get(
553
+ f"{self._base_url}/v0/runs/assignment",
554
+ timeout=timeout_s,
555
+ )
556
+ if response.is_error:
557
+ raise ManifoldApiError(response.status_code, response.text)
558
+ body = response.json()
559
+ return AssignmentDispatch.model_validate(body) if body is not None else None
560
+
561
+ def claim_task(
562
+ self, run_id: UUID, assignment_id: UUID, claim_request_id: UUID
563
+ ) -> TaskClaim:
564
+ response = self._task_queue_request(
565
+ run_id,
566
+ "claim",
567
+ {
568
+ "assignment_id": str(assignment_id),
569
+ "claim_request_id": str(claim_request_id),
570
+ },
571
+ )
572
+ return TaskClaim.model_validate(response.json())
573
+
574
+ def mint_task_upload(
575
+ self,
576
+ run_id: UUID,
577
+ assignment_id: UUID,
578
+ attempt_id: UUID,
579
+ episode_idx: int,
580
+ size_bytes: int,
581
+ ) -> ReplayUploadSlot:
582
+ response = self._task_queue_request(
583
+ run_id,
584
+ "mint-upload",
585
+ {
586
+ "assignment_id": str(assignment_id),
587
+ "attempt_id": str(attempt_id),
588
+ "episode_idx": episode_idx,
589
+ "size_bytes": size_bytes,
590
+ },
591
+ )
592
+ return ReplayUploadSlot.model_validate(response.json())
593
+
594
+ def complete_task(
595
+ self,
596
+ run_id: UUID,
597
+ assignment_id: UUID,
598
+ attempt_id: UUID,
599
+ results: list[TaskResult],
600
+ ) -> None:
601
+ self._task_queue_request(
602
+ run_id,
603
+ "complete",
604
+ {
605
+ "assignment_id": str(assignment_id),
606
+ "attempt_id": str(attempt_id),
607
+ "results": [result.model_dump(mode="json") for result in results],
608
+ },
609
+ )
610
+
611
+ def _task_queue_request(
612
+ self, run_id: UUID, operation: str, body: dict[str, Any]
613
+ ) -> httpx.Response:
614
+ response = self._client.post(
615
+ f"{self._base_url}/v0/runs/{run_id}/task-queue/{operation}",
616
+ json=body,
617
+ timeout=EPISODE_REPORT_TIMEOUT_SECONDS,
618
+ )
619
+ if (
620
+ response.status_code == HTTPStatus.CONFLICT
621
+ and _refusal_code(response.text) == "lease_lost"
622
+ ):
623
+ raise TaskQueueLeaseLostError(response.status_code, response.text)
624
+ if response.is_error:
625
+ raise ManifoldApiError(response.status_code, response.text)
626
+ return response
627
+
628
+ @asynccontextmanager
629
+ async def assignment_heartbeats(
630
+ self,
631
+ ) -> AsyncIterator[AssignmentHeartbeatClient]:
632
+ async with httpx.AsyncClient(auth=self._auth, timeout=None) as client:
633
+ yield AssignmentHeartbeatClient(self._base_url, client)
634
+
635
+ def begin_assignment_preparation(self) -> Assignment:
636
+ """Move this runner's collected side into preparation."""
637
+ response = self._client.post(
638
+ f"{self._base_url}/v0/runs/assignment/preparation/start"
639
+ )
640
+ return Assignment.model_validate(_assignment_body(response))
641
+
642
+ def complete_assignment_preparation(self) -> Assignment:
643
+ """Move this runner's prepared side into worker startup."""
644
+ response = self._client.post(
645
+ f"{self._base_url}/v0/runs/assignment/preparation/complete"
646
+ )
647
+ return Assignment.model_validate(_assignment_body(response))
648
+
649
+ def publish_run_bridge(
650
+ self, run_id: UUID, address: str, *, can_send_step_times: bool
651
+ ) -> RunBridge:
652
+ """Record where the benchmark side dials to reach the policy side, and
653
+ whether the policy side's bridge can send step times."""
654
+ response = self._client.post(
655
+ f"{self._base_url}/v0/runs/{run_id}/bridge",
656
+ json={"address": address, "can_send_step_times": can_send_step_times},
657
+ )
658
+ return RunBridge.model_validate(_assigned_side_body(response, run_id))
659
+
660
+ def get_run_bridge(self, run_id: UUID) -> RunBridge:
661
+ """Read the policy side's address. None while it is still loading."""
662
+ response = self._client.get(f"{self._base_url}/v0/runs/{run_id}/bridge")
663
+ return RunBridge.model_validate(_assigned_side_body(response, run_id))
664
+
665
+ def report_run_side(
666
+ self,
667
+ run_id: UUID,
668
+ assignment_id: UUID,
669
+ outcome: SideOutcome,
670
+ *,
671
+ detail: str,
672
+ failed_attempt_id: UUID | None,
673
+ ) -> Run:
674
+ """Report how this runner's attempt at a side of the run finished.
675
+
676
+ `failed_attempt_id` is the task-queue attempt whose worker failed.
677
+ """
678
+ body: dict[str, Any] = {"outcome": outcome.value, "detail": detail}
679
+ if failed_attempt_id is not None:
680
+ body["failed_attempt_id"] = str(failed_attempt_id)
681
+ response = self._client.post(
682
+ f"{self._base_url}/v0/runs/{run_id}/attempts/{assignment_id}/report",
683
+ json=body,
684
+ )
685
+ return Run.model_validate(_assigned_side_body(response, run_id))
686
+
687
+ def list_run_episodes(self, run_id: UUID) -> list[Episode]:
688
+ response = self._client.get(f"{self._base_url}/v0/runs/{run_id}/episodes")
689
+ if response.status_code == HTTPStatus.NOT_FOUND:
690
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
691
+ if response.is_error:
692
+ raise ManifoldApiError(response.status_code, response.text)
693
+ return [Episode.model_validate(item) for item in response.json()]
694
+
695
+ def list_run_log_streams(self, run_id: UUID) -> list[RunLogStream]:
696
+ response = self._client.get(f"{self._base_url}/v0/runs/{run_id}/logs/streams")
697
+ if response.status_code == HTTPStatus.NOT_FOUND:
698
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
699
+ if response.is_error:
700
+ raise ManifoldApiError(response.status_code, response.text)
701
+ return [RunLogStream.model_validate(item) for item in response.json()]
702
+
703
+ def search_run_log(
704
+ self,
705
+ run_id: UUID,
706
+ *,
707
+ pattern: str,
708
+ assignment_id: UUID | None,
709
+ context_lines: int | None,
710
+ since: datetime | None,
711
+ until: datetime | None,
712
+ order: SortOrder,
713
+ cursor: str | None,
714
+ ) -> Page[LogMatch]:
715
+ params: dict[str, str | int] = {"pattern": pattern, "order": order.value}
716
+ if assignment_id is not None:
717
+ params["assignment_id"] = str(assignment_id)
718
+ if context_lines is not None:
719
+ params["context_lines"] = context_lines
720
+ if since is not None:
721
+ params["since"] = since.isoformat()
722
+ if until is not None:
723
+ params["until"] = until.isoformat()
724
+ if cursor is not None:
725
+ params["cursor"] = cursor
726
+ response = self._client.get(
727
+ f"{self._base_url}/v0/runs/{run_id}/logs/search", params=params
728
+ )
729
+ if response.status_code == HTTPStatus.NOT_FOUND:
730
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
731
+ if response.is_error:
732
+ raise ManifoldApiError(response.status_code, response.text)
733
+ return Page[LogMatch].model_validate(response.json())
734
+
735
+ def read_run_log(
736
+ self,
737
+ run_id: UUID,
738
+ *,
739
+ assignment_id: UUID,
740
+ producer: LogProducer,
741
+ worker_index: int,
742
+ source: LogSource,
743
+ around_seq: int | None,
744
+ limit: int | None,
745
+ ) -> list[LogLine]:
746
+ params: dict[str, int] = {}
747
+ if around_seq is not None:
748
+ params["around_seq"] = around_seq
749
+ if limit is not None:
750
+ params["limit"] = limit
751
+ response = self._client.get(
752
+ f"{self._base_url}/v0/runs/{run_id}/logs/streams/{assignment_id}"
753
+ f"/{producer.value}/{worker_index}/{source.value}",
754
+ params=params,
755
+ )
756
+ if response.status_code == HTTPStatus.NOT_FOUND:
757
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
758
+ if response.is_error:
759
+ raise ManifoldApiError(response.status_code, response.text)
760
+ return [LogLine.model_validate(item) for item in response.json()]
761
+
762
+ def record_run_episodes(self, run_id: UUID, episodes: list[EpisodeReport]) -> Run:
763
+ response = self._client.post(
764
+ f"{self._base_url}/v0/runs/{run_id}/episodes",
765
+ json={"episodes": [report.model_dump(mode="json") for report in episodes]},
766
+ timeout=EPISODE_REPORT_TIMEOUT_SECONDS,
767
+ )
768
+ if response.status_code == HTTPStatus.NOT_FOUND:
769
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
770
+ if response.status_code == HTTPStatus.CONFLICT:
771
+ raise EpisodesRejectedError(
772
+ error_detail(response.text) or "the run cannot record these episodes"
773
+ )
774
+ if response.is_error:
775
+ raise ManifoldApiError(response.status_code, response.text)
776
+ return Run.model_validate(response.json())
777
+
778
+ def publish_live_view(
779
+ self,
780
+ run_id: UUID,
781
+ worker_index: int,
782
+ *,
783
+ episode_idx: int,
784
+ step: int,
785
+ task: str,
786
+ captured_at: datetime,
787
+ jpeg: bytes | None,
788
+ task_id: str | None = None,
789
+ ) -> PublishedLiveView:
790
+ response = self._client.post(
791
+ f"{self._base_url}/v0/runs/{run_id}/live-views/{worker_index}",
792
+ params={
793
+ "episode_idx": episode_idx,
794
+ "step": step,
795
+ "task": task,
796
+ "captured_at": captured_at.isoformat(),
797
+ **({} if task_id is None else {"task_id": task_id}),
798
+ },
799
+ content=jpeg or b"",
800
+ headers={"content-type": "image/jpeg"} if jpeg is not None else {},
801
+ )
802
+ if response.status_code == HTTPStatus.NOT_FOUND:
803
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
804
+ if response.is_error:
805
+ raise ManifoldApiError(response.status_code, response.text)
806
+ return PublishedLiveView.model_validate(response.json())
807
+
808
+ def delete_live_view(
809
+ self, run_id: UUID, worker_index: int, *, assignment_id: UUID
810
+ ) -> None:
811
+ """Delete the benchmark worker's live view for `assignment_id`.
812
+
813
+ The server accepts the request only while `assignment_id` is assigned
814
+ to this runner and no later benchmark attempt has replaced it.
815
+ """
816
+ response = self._client.delete(
817
+ f"{self._base_url}/v0/runs/{run_id}/live-views/{worker_index}",
818
+ params={"assignment_id": str(assignment_id)},
819
+ )
820
+ if response.status_code == HTTPStatus.NOT_FOUND:
821
+ raise RunNotFoundError(
822
+ error_detail(response.text)
823
+ or "latest benchmark assignment does not match the request"
824
+ )
825
+ if response.is_error:
826
+ raise ManifoldApiError(response.status_code, response.text)
827
+
828
+ def post_captured_output(self, run_id: UUID, batch: LogBatch) -> None:
829
+ """Send one batch of the output this runner's side has captured.
830
+
831
+ Nothing comes back. The server resolves which side's output this is from
832
+ the credential, so a runner with one side of a run assigned to it cannot post
833
+ for the other.
834
+ """
835
+ response = self._client.post(
836
+ f"{self._base_url}/v0/runs/{run_id}/logs",
837
+ json=batch.model_dump(mode="json"),
838
+ )
839
+ if response.status_code == HTTPStatus.NOT_FOUND:
840
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
841
+ if response.is_error:
842
+ raise ManifoldApiError(response.status_code, response.text)
843
+
844
+ def mint_replay_upload(
845
+ self, run_id: UUID, episode_idx: int, size_bytes: int
846
+ ) -> ReplayUploadSlot:
847
+ """Ask where one episode's replay should be PUT, declaring its size.
848
+
849
+ The size is signed into the URL, so the PUT has to send exactly that many
850
+ bytes: the recording is written before the slot is asked for.
851
+ """
852
+ response = self._client.post(
853
+ f"{self._base_url}/v0/runs/{run_id}/replays/{episode_idx}/slot",
854
+ json={"size_bytes": size_bytes},
855
+ )
856
+ if response.status_code == HTTPStatus.NOT_FOUND:
857
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
858
+ if response.status_code == HTTPStatus.CONFLICT:
859
+ raise ReplayRejectedError(
860
+ error_detail(response.text) or "the run cannot take this replay"
861
+ )
862
+ if response.is_error:
863
+ raise ManifoldApiError(response.status_code, response.text)
864
+ return ReplayUploadSlot.model_validate(response.json())
865
+
866
+ def put_replay(self, slot: ReplayUploadSlot, path: Path) -> int:
867
+ """PUT the recording to the slot's signed URL, returning the bytes sent.
868
+
869
+ Straight to object storage rather than through the API, which is what the
870
+ signed URL is for. A fresh client: the signature carries its own
871
+ authorization, and the session's bearer token has no business leaving
872
+ Manifold's own host.
873
+ """
874
+ size = path.stat().st_size
875
+ with (
876
+ path.open("rb") as body,
877
+ httpx.Client(timeout=REPLAY_PUT_TIMEOUT) as client,
878
+ ):
879
+ response = client.put(
880
+ slot.url, content=body, headers={"Content-Type": RRD_MIME}
881
+ )
882
+ if response.is_error:
883
+ raise ManifoldApiError(response.status_code, response.text)
884
+ return size
885
+
886
+ def confirm_replay_upload(self, run_id: UUID, episode_idx: int) -> None:
887
+ """Tell the server the PUT is done, which is what records the row.
888
+
889
+ Nothing to report: the server heads the object and reads the size off it,
890
+ so a confirm carries no claim the bucket cannot settle.
891
+ """
892
+ response = self._client.post(
893
+ f"{self._base_url}/v0/runs/{run_id}/replays/{episode_idx}"
894
+ )
895
+ if response.status_code == HTTPStatus.NOT_FOUND:
896
+ raise RunNotFoundError(error_detail(response.text) or "run not found")
897
+ if response.status_code == HTTPStatus.CONFLICT:
898
+ raise ReplayRejectedError(
899
+ error_detail(response.text) or "the run cannot take this replay"
900
+ )
901
+ if response.is_error:
902
+ raise ManifoldApiError(response.status_code, response.text)
903
+
904
+ def mint_runner(
905
+ self,
906
+ name: str,
907
+ *,
908
+ ram_gb: int | None,
909
+ gpu_memory_gb: int,
910
+ gpu_devices: list[RunnerGpuDevice],
911
+ gpu_model: GpuModel,
912
+ provider: RunnerProvider | None,
913
+ is_preemptible: bool,
914
+ organization: str | None,
915
+ ) -> MintedRunner:
916
+ # Without an organization, the server mints the runner in the operator
917
+ # organization.
918
+ body: dict[str, object] = {
919
+ "name": name,
920
+ "ram_gb": ram_gb,
921
+ "gpu_memory_gb": gpu_memory_gb,
922
+ "gpu_devices": [device.model_dump() for device in gpu_devices],
923
+ "gpu_model": gpu_model,
924
+ "is_preemptible": is_preemptible,
925
+ }
926
+ if provider is not None:
927
+ body["provider"] = provider
928
+ if organization is not None:
929
+ body["organization"] = organization
930
+ response = self._client.post(f"{self._base_url}/v0/runners", json=body)
931
+ if response.status_code == HTTPStatus.CONFLICT:
932
+ raise RunnerNameTakenError(
933
+ error_detail(response.text) or f"a runner named '{name}' already exists"
934
+ )
935
+ if organization is not None and response.status_code == HTTPStatus.NOT_FOUND:
936
+ raise OrganizationNotFoundError(
937
+ error_detail(response.text) or f"'{organization}' was not found"
938
+ )
939
+ if response.is_error:
940
+ raise ManifoldApiError(response.status_code, response.text)
941
+ return MintedRunner.model_validate(response.json())
942
+
943
+ def register_serving_runner(
944
+ self,
945
+ name: str,
946
+ organization: str,
947
+ *,
948
+ policy: str,
949
+ version: str,
950
+ gpu_devices: list[RunnerGpuDevice],
951
+ source: RunnerSource,
952
+ signature: dict[str, Any] | None,
953
+ ) -> MintedRunner:
954
+ """Register this machine as a runner for one policy version."""
955
+ response = self._client.post(
956
+ f"{self._base_url}/v0/runners/serving",
957
+ json={
958
+ "organization": organization,
959
+ "name": name,
960
+ "policy": policy,
961
+ "version": version,
962
+ "gpu_devices": [device.model_dump() for device in gpu_devices],
963
+ "source": source.model_dump(),
964
+ "signature": signature,
965
+ },
966
+ )
967
+ if response.status_code == HTTPStatus.CONFLICT:
968
+ raise RunnerNameTakenError(
969
+ error_detail(response.text) or f"a runner named '{name}' already exists"
970
+ )
971
+ if response.status_code == HTTPStatus.NOT_FOUND:
972
+ raise PolicyNotFoundError(
973
+ error_detail(response.text) or f"policy version '{version}' not found"
974
+ )
975
+ if response.is_error:
976
+ raise ManifoldApiError(response.status_code, response.text)
977
+ return MintedRunner.model_validate(response.json())
978
+
979
+ def release_runner(self) -> None:
980
+ """Revoke this runner's own credential.
981
+
982
+ The server identifies the runner from the token.
983
+ """
984
+ response = self._client.post(f"{self._base_url}/v0/runners/release")
985
+ if response.is_error:
986
+ raise ManifoldApiError(response.status_code, response.text)
987
+
988
+ def start_runner(self) -> None:
989
+ """Mark this runner started, so that placement may assign a side to it.
990
+
991
+ The server identifies the runner from the token.
992
+ """
993
+ response = self._client.post(f"{self._base_url}/v0/runners/start")
994
+ if response.is_error:
995
+ raise ManifoldApiError(response.status_code, response.text)
996
+
997
+ def stop_runner(self) -> None:
998
+ """Mark this runner stopping, so that placement skips it.
999
+
1000
+ The server identifies the runner from the token.
1001
+ """
1002
+ response = self._client.post(f"{self._base_url}/v0/runners/stop")
1003
+ if response.is_error:
1004
+ raise ManifoldApiError(response.status_code, response.text)
1005
+
1006
+ def report_driver(self, driver_version: str) -> None:
1007
+ """Record the driver this box reports.
1008
+
1009
+ The server looks the runner up from the API credential, which is
1010
+ minted per runner.
1011
+ """
1012
+ response = self._client.post(
1013
+ f"{self._base_url}/v0/runners/driver",
1014
+ json={"driver_version": driver_version},
1015
+ )
1016
+ if response.is_error:
1017
+ raise ManifoldApiError(response.status_code, response.text)
1018
+
1019
+ def report_resource_usage(self, report: ReportResourceUsageRequest) -> None:
1020
+ """Send samples of this box's resource usage for export to Honeycomb.
1021
+
1022
+ The server looks the runner up from the API credential.
1023
+ """
1024
+ response = self._client.post(
1025
+ f"{self._base_url}/v0/observability/resource-usage",
1026
+ json=report.model_dump(mode="json"),
1027
+ )
1028
+ if response.is_error:
1029
+ raise ManifoldApiError(response.status_code, response.text)
1030
+
1031
+ def report_policy_metrics(self, report: ReportPolicyMetricsRequest) -> None:
1032
+ """Send the policy steps that this box's bridge timed for export to
1033
+ Honeycomb.
1034
+
1035
+ The server looks the runner up from the API credential.
1036
+ """
1037
+ response = self._client.post(
1038
+ f"{self._base_url}/v0/observability/policy-metrics",
1039
+ json=report.model_dump(mode="json"),
1040
+ )
1041
+ if response.is_error:
1042
+ raise ManifoldApiError(response.status_code, response.text)
1043
+
1044
+ def report_benchmark_metrics(self, report: ReportBenchmarkMetricsRequest) -> None:
1045
+ """Send the steps that this box's benchmark bridge timed for export to
1046
+ Honeycomb.
1047
+
1048
+ The server looks the runner up from the API credential.
1049
+ """
1050
+ response = self._client.post(
1051
+ f"{self._base_url}/v0/observability/benchmark-metrics",
1052
+ json=report.model_dump(mode="json"),
1053
+ )
1054
+ if response.is_error:
1055
+ raise ManifoldApiError(response.status_code, response.text)
1056
+
1057
+ def list_runners(self, *, organization: str | None) -> list[ListedRunner]:
1058
+ response = self._client.get(
1059
+ f"{self._base_url}/v0/runners", params=_organization_params(organization)
1060
+ )
1061
+ if organization is not None and response.status_code == HTTPStatus.NOT_FOUND:
1062
+ raise OrganizationNotFoundError(
1063
+ error_detail(response.text) or f"'{organization}' was not found"
1064
+ )
1065
+ if response.is_error:
1066
+ raise ManifoldApiError(response.status_code, response.text)
1067
+ return [ListedRunner.model_validate(item) for item in response.json()]
1068
+
1069
+ def revoke_runner(self, runner_id: UUID, *, organization: str | None) -> Runner:
1070
+ response = self._client.post(
1071
+ f"{self._base_url}/v0/runners/{runner_id}/revoke",
1072
+ params=_organization_params(organization),
1073
+ )
1074
+ # NOTE: the server also returns 404 for an unknown organization, and
1075
+ # its detail says whether the organization or the runner was not found.
1076
+ if response.status_code == HTTPStatus.NOT_FOUND:
1077
+ raise RunnerNotFoundError(error_detail(response.text) or "runner not found")
1078
+ if response.is_error:
1079
+ raise ManifoldApiError(response.status_code, response.text)
1080
+ return Runner.model_validate(response.json())
1081
+
1082
+ def set_instance_override(
1083
+ self,
1084
+ runner_id: UUID,
1085
+ override_type: InstanceOverrideType,
1086
+ *,
1087
+ until: datetime | None,
1088
+ organization: str | None,
1089
+ ) -> Runner:
1090
+ response = self._client.put(
1091
+ f"{self._base_url}/v0/runners/{runner_id}/instance-override",
1092
+ params=_organization_params(organization),
1093
+ json={
1094
+ "override_type": override_type.value,
1095
+ "until": None if until is None else until.isoformat(),
1096
+ },
1097
+ )
1098
+ return _parse_runner(response)
1099
+
1100
+ def clear_instance_override(
1101
+ self, runner_id: UUID, *, organization: str | None
1102
+ ) -> Runner:
1103
+ response = self._client.delete(
1104
+ f"{self._base_url}/v0/runners/{runner_id}/instance-override",
1105
+ params=_organization_params(organization),
1106
+ )
1107
+ return _parse_runner(response)
1108
+
1109
+ def pause_placement(
1110
+ self, runner_id: UUID, *, until: datetime, organization: str | None
1111
+ ) -> Runner:
1112
+ response = self._client.put(
1113
+ f"{self._base_url}/v0/runners/{runner_id}/placement-pause",
1114
+ params=_organization_params(organization),
1115
+ json={"until": until.isoformat()},
1116
+ )
1117
+ return _parse_runner(response)
1118
+
1119
+ def resume_placement(self, runner_id: UUID, *, organization: str | None) -> Runner:
1120
+ response = self._client.delete(
1121
+ f"{self._base_url}/v0/runners/{runner_id}/placement-pause",
1122
+ params=_organization_params(organization),
1123
+ )
1124
+ return _parse_runner(response)
1125
+
1126
+ def create_access_token(
1127
+ self,
1128
+ name: str,
1129
+ scopes: list[Scope],
1130
+ *,
1131
+ expires_at: datetime | None,
1132
+ ) -> CreatedAccessToken:
1133
+ response = self._client.post(
1134
+ f"{self._base_url}/v0/tokens",
1135
+ json={
1136
+ "name": name,
1137
+ "scopes": [scope.value for scope in scopes],
1138
+ "expires_at": (
1139
+ expires_at.isoformat() if expires_at is not None else None
1140
+ ),
1141
+ },
1142
+ )
1143
+ if response.is_error:
1144
+ raise ManifoldApiError(response.status_code, response.text)
1145
+ return CreatedAccessToken.model_validate(response.json())
1146
+
1147
+ def list_access_tokens(self) -> list[AccessToken]:
1148
+ response = self._client.get(f"{self._base_url}/v0/tokens")
1149
+ if response.is_error:
1150
+ raise ManifoldApiError(response.status_code, response.text)
1151
+ return _ACCESS_TOKENS_ADAPTER.validate_python(response.json())
1152
+
1153
+ def revoke_access_token(self, token_id: UUID) -> AccessToken:
1154
+ response = self._client.post(f"{self._base_url}/v0/tokens/{token_id}/revoke")
1155
+ if response.status_code == HTTPStatus.NOT_FOUND:
1156
+ raise AccessTokenNotFoundError(
1157
+ error_detail(response.text) or "access token not found"
1158
+ )
1159
+ if response.is_error:
1160
+ raise ManifoldApiError(response.status_code, response.text)
1161
+ return AccessToken.model_validate(response.json())
1162
+
1163
+
1164
+ def _assigned_side_body(response: httpx.Response, run_id: UUID) -> object:
1165
+ """The body of a call on a side assigned to this runner, or why it was refused.
1166
+
1167
+ A 409 is a call the run's state or this runner's standing does not allow,
1168
+ not an API fault. Only the server's `detail` distinguishes them, and the reactions
1169
+ differ: a side that has already reported is finished with, and a side no
1170
+ longer assigned to this runner has its worker torn down.
1171
+ """
1172
+ if response.status_code == HTTPStatus.NOT_FOUND:
1173
+ raise RunNotFoundError(error_detail(response.text) or f"run {run_id} not found")
1174
+ if response.status_code == HTTPStatus.CONFLICT:
1175
+ raise RunSideRefusedError(
1176
+ error_detail(response.text) or f"run {run_id} refused the call"
1177
+ )
1178
+ if response.is_error:
1179
+ raise ManifoldApiError(response.status_code, response.text)
1180
+ return response.json()
1181
+
1182
+
1183
+ def _assignment_body(response: httpx.Response) -> object:
1184
+ """The assignment body, or why it is no longer assigned to this runner."""
1185
+ if response.status_code == HTTPStatus.CONFLICT:
1186
+ raise AssignmentNotAssignedToRunnerError(
1187
+ _refusal_message(raw_error_detail(response.text))
1188
+ or "an assignment is not assigned to this runner"
1189
+ )
1190
+ if response.is_error:
1191
+ raise ManifoldApiError(response.status_code, response.text)
1192
+ return response.json()
1193
+
1194
+
1195
+ def _heartbeat_refusal(
1196
+ response: httpx.Response, assignment_id: UUID
1197
+ ) -> AssignmentNotAssignedToRunnerError:
1198
+ """The error for a refused heartbeat, with the run's status where one was sent.
1199
+
1200
+ A server that predates `run_status` sends `detail` as a string. A
1201
+ `run_status` outside `RunStatus` is treated as absent, so the side is
1202
+ handled as reclaimed.
1203
+ """
1204
+ detail = raw_error_detail(response.text)
1205
+ message = (
1206
+ _refusal_message(detail)
1207
+ or f"assignment {assignment_id} is not assigned to this runner"
1208
+ )
1209
+ if not isinstance(detail, dict):
1210
+ return AssignmentNotAssignedToRunnerError(message)
1211
+ try:
1212
+ run_status = RunStatus(detail.get("run_status"))
1213
+ except ValueError:
1214
+ run_status = None
1215
+ return AssignmentNotAssignedToRunnerError(message, run_status=run_status)
1216
+
1217
+
1218
+ def _refusal_message(detail: object) -> str | None:
1219
+ """The message of an assignment refusal's `detail`.
1220
+
1221
+ A server that predates the `detail` object sends the message as a string.
1222
+ """
1223
+ if isinstance(detail, dict):
1224
+ detail = detail.get("message")
1225
+ return detail if isinstance(detail, str) else None
1226
+
1227
+
1228
+ def _refusal_code(body: str) -> str | None:
1229
+ """The `code` of a task-queue refusal, whose `detail` is an object."""
1230
+ try:
1231
+ payload = json.loads(body)
1232
+ except ValueError:
1233
+ return None
1234
+ detail = payload.get("detail") if isinstance(payload, dict) else None
1235
+ code = detail.get("code") if isinstance(detail, dict) else None
1236
+ return code if isinstance(code, str) else None
1237
+
1238
+
1239
+ def _raise_for_register_rejection(response: httpx.Response, slug: str) -> None:
1240
+ """Raise CatalogRegisterError for a refused catalog write.
1241
+
1242
+ The server refuses one of two ways: 409 when another member of the
1243
+ organization owns the slug, 403 when the visibility asked for is the
1244
+ operator organization's to set. Both are the caller's to fix, so neither
1245
+ is an API failure.
1246
+ """
1247
+ if response.status_code not in _REGISTER_REJECTIONS:
1248
+ return
1249
+ raise CatalogRegisterError(
1250
+ error_detail(response.text) or f"'{slug}' could not be registered"
1251
+ )
1252
+
1253
+
1254
+ _REGISTER_REJECTIONS = frozenset({HTTPStatus.FORBIDDEN, HTTPStatus.CONFLICT})
1255
+
1256
+
1257
+ def _page_params(limit: int | None, cursor: str | None) -> dict[str, str | int]:
1258
+ """Query params for a paged list call.
1259
+
1260
+ Unset params are omitted so the server applies its own defaults.
1261
+ """
1262
+ params: dict[str, str | int] = {}
1263
+ if limit is not None:
1264
+ params["limit"] = limit
1265
+ if cursor is not None:
1266
+ params["cursor"] = cursor
1267
+ return params
1268
+
1269
+
1270
+ def _parse_runner(response: httpx.Response) -> Runner:
1271
+ """The runner from an override route's response."""
1272
+ if response.is_error:
1273
+ raise ManifoldApiError(response.status_code, response.text)
1274
+ return Runner.model_validate(response.json())
1275
+
1276
+
1277
+ def _organization_params(organization: str | None) -> dict[str, str]:
1278
+ """Query params for an optional organization, empty when the caller gave none."""
1279
+ if organization is None:
1280
+ return {}
1281
+ return {"organization": organization}
1282
+
1283
+
1284
+ def is_transient_api_failure(exc: BaseException) -> bool:
1285
+ """Whether a call failed in transport or with a 5xx response."""
1286
+ return isinstance(exc, httpx.TransportError) or (
1287
+ isinstance(exc, ManifoldApiError) and exc.status_code >= 500
1288
+ )