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,19 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Credentials(BaseModel):
5
+ api_url: str
6
+ workos_client_id: str
7
+ access_token: str
8
+ refresh_token: str
9
+
10
+
11
+ class RunnerCredential(BaseModel):
12
+ """A minted runner token, as the runner box stores it.
13
+
14
+ `api_url` is recorded with it so a token pasted against the wrong
15
+ deployment fails on the mismatch rather than as an opaque 401.
16
+ """
17
+
18
+ api_url: str
19
+ token: str
@@ -0,0 +1,121 @@
1
+ import json
2
+
3
+
4
+ class ManifoldApiError(Exception):
5
+ def __init__(self, status_code: int, body: str):
6
+ self.status_code = status_code
7
+ self.body = body
8
+ super().__init__(f"manifold API error {status_code}: {body[:200]}")
9
+
10
+ @property
11
+ def detail(self) -> str | None:
12
+ """The human-readable ``detail`` from the error body."""
13
+ return error_detail(self.body)
14
+
15
+
16
+ class WorkOSAuthError(Exception):
17
+ """Terminal device-flow or refresh failure."""
18
+
19
+ def __init__(self, error: str, description: str | None = None):
20
+ self.error = error
21
+ self.description = description
22
+ super().__init__(description or error)
23
+
24
+
25
+ class NotLoggedInError(Exception):
26
+ """No stored credentials."""
27
+
28
+ def __init__(self, message: str = "Not logged in."):
29
+ super().__init__(message)
30
+
31
+
32
+ class SessionExpiredError(Exception):
33
+ """Stored credentials are invalid and could not be refreshed."""
34
+
35
+ def __init__(self, message: str = "Session expired."):
36
+ super().__init__(message)
37
+
38
+
39
+ class NoRunnerCredentialError(Exception):
40
+ """No stored runner credential."""
41
+
42
+ def __init__(self, message: str = "No runner credential stored."):
43
+ super().__init__(message)
44
+
45
+
46
+ class AccessTokenRejectedError(Exception):
47
+ """The access token exported for registration was refused."""
48
+
49
+ def __init__(self, message: str = "Access token rejected."):
50
+ super().__init__(message)
51
+
52
+
53
+ class InvalidAccessTokenError(Exception):
54
+ """The exported access token cannot be placed in an HTTP header."""
55
+
56
+ def __init__(self):
57
+ super().__init__("MANIFOLD_TOKEN is not a valid credential.")
58
+
59
+
60
+ class NoRunnerHomeError(Exception):
61
+ """No runner home to act on, either named or on the box at all."""
62
+
63
+ def __init__(self, message: str = "This box has no runner home."):
64
+ super().__init__(message)
65
+
66
+
67
+ class AmbiguousRunnerHomeError(Exception):
68
+ """More than one runner home on the box, and the caller named none."""
69
+
70
+ def __init__(self, handles: list[str]):
71
+ self.handles = handles
72
+ super().__init__(
73
+ f"This box has {len(handles)} runner homes ({', '.join(handles)}). "
74
+ f"Name one with --runner."
75
+ )
76
+
77
+
78
+ class RunnerCredentialRejectedError(Exception):
79
+ """The stored runner credential was refused.
80
+
81
+ A runner token is revoked rather than rotated, so a 401 is terminal: it was
82
+ revoked, or it belongs to another deployment.
83
+ """
84
+
85
+ def __init__(self, message: str = "Runner credential rejected."):
86
+ super().__init__(message)
87
+
88
+
89
+ class RunnerDeploymentMismatchError(Exception):
90
+ """The stored runner credential was minted against another deployment."""
91
+
92
+ def __init__(self, stored: str, configured: str):
93
+ self.stored = stored
94
+ self.configured = configured
95
+ super().__init__(
96
+ f"Runner credential is for {stored}, "
97
+ f"but this box is configured for {configured}."
98
+ )
99
+
100
+
101
+ def error_detail(body: str) -> str | None:
102
+ """The human-readable ``detail`` from a JSON error body.
103
+
104
+ Returns None when the body is not JSON or ``detail`` is not a string
105
+ (e.g. FastAPI's request-validation errors, whose detail is a list), so
106
+ callers can fall back to their own message.
107
+ """
108
+ detail = raw_error_detail(body)
109
+ return detail if isinstance(detail, str) else None
110
+
111
+
112
+ def raw_error_detail(body: str) -> object:
113
+ """The ``detail`` of a JSON error body as sent.
114
+
115
+ None where the body is not a JSON object with ``detail``.
116
+ """
117
+ try:
118
+ payload = json.loads(body)
119
+ except ValueError:
120
+ return None
121
+ return payload.get("detail") if isinstance(payload, dict) else None
@@ -0,0 +1,114 @@
1
+ """A run's captured output: what a runner reports, and what a reader reads back."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+ from uuid import UUID
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+ from manifold_cli.models.run import AssignmentReason, AssignmentStatus, RunSide
10
+
11
+
12
+ class LogSource(StrEnum):
13
+ """Which channel of a process's output a line came from."""
14
+
15
+ STDOUT = "stdout"
16
+ STDERR = "stderr"
17
+
18
+
19
+ class LogProducer(StrEnum):
20
+ """Which process produced a captured stream."""
21
+
22
+ RUNNER = "runner"
23
+ WORKER = "worker"
24
+
25
+
26
+ class ReportedStream(BaseModel):
27
+ """One worker's output on one source, as a batch declares it.
28
+
29
+ Declared on every batch rather than once, so a post lost in transit leaves a
30
+ reader's stream list short only until the next one repairs it. `ended_at` and
31
+ `exit_code` are what a close reports, and stay None while the worker runs.
32
+ """
33
+
34
+ producer: LogProducer
35
+ worker_index: int = Field(ge=0)
36
+ source: LogSource
37
+ ended_at: datetime | None = None
38
+ exit_code: int | None = None
39
+
40
+
41
+ class ReportedLine(BaseModel):
42
+ """One captured line, at its position in the stream.
43
+
44
+ `ts` is when the runner read the line rather than when the batch carrying it
45
+ was sent, so the interval a batch is held for does not collapse a worker's
46
+ lines onto one instant.
47
+ """
48
+
49
+ producer: LogProducer
50
+ worker_index: int = Field(ge=0)
51
+ source: LogSource
52
+ seq: int = Field(ge=0)
53
+ ts: datetime
54
+ text: str
55
+
56
+ @field_validator("text")
57
+ @classmethod
58
+ def _without_nul(cls, text: str) -> str:
59
+ """Drop the one character the store behind this cannot hold.
60
+
61
+ A worker writing bytes to a pipe produces a NUL, which is valid UTF-8 and
62
+ so survives the decode intact. The column it lands in refuses one, and
63
+ the refusal costs the whole batch the line rode in rather than the line.
64
+ """
65
+ return text.replace("\x00", "")
66
+
67
+
68
+ class LogBatch(BaseModel):
69
+ """One post: every stream the side has, and the lines since the last one.
70
+
71
+ A batch carrying no lines is not a no-op. It is what puts a worker in a
72
+ reader's list while its container is still pulling an image, and what reports
73
+ a worker gone once it has exited.
74
+ """
75
+
76
+ streams: list[ReportedStream]
77
+ lines: list[ReportedLine] = []
78
+
79
+
80
+ class RunLogStream(BaseModel):
81
+ """One stream of one assignment of a run, with its size and its process's exit.
82
+
83
+ `assignment_id`, `producer`, `worker_index` and `source` identify the
84
+ stream to read.
85
+ """
86
+
87
+ assignment_id: UUID
88
+ side: RunSide
89
+ status: AssignmentStatus
90
+ reason: AssignmentReason | None
91
+ producer: LogProducer
92
+ worker_index: int
93
+ source: LogSource
94
+ line_count: int
95
+ exit_code: int | None
96
+
97
+
98
+ class LogLine(BaseModel):
99
+ """One stored line of a stream, at its `seq`."""
100
+
101
+ seq: int
102
+ ts: datetime
103
+ text: str
104
+
105
+
106
+ class LogMatch(BaseModel):
107
+ """One line that matched a search, with the lines around it in its stream."""
108
+
109
+ assignment_id: UUID
110
+ producer: LogProducer
111
+ worker_index: int
112
+ source: LogSource
113
+ seq: int
114
+ lines: list[LogLine]
@@ -0,0 +1,121 @@
1
+ """What a runner reports about its box's resource usage and the policy's steps.
2
+
3
+ The server exports each report to Honeycomb, and stores only the resource usage
4
+ samples.
5
+ """
6
+
7
+ from uuid import UUID
8
+
9
+ from pydantic import AwareDatetime, BaseModel
10
+
11
+ from manifold_cli.models.run import AssignmentDispatch, RunSide
12
+
13
+
14
+ class GpuUsage(BaseModel):
15
+ index: int
16
+ name: str
17
+ utilization_percent: float
18
+ memory_used_bytes: int
19
+ memory_total_bytes: int
20
+
21
+
22
+ class AssignmentAttributes(BaseModel):
23
+ """The run side assigned to the runner at a sample's `sampled_at` or a policy
24
+ step's `ended_at`."""
25
+
26
+ assignment_id: UUID
27
+ run_id: UUID
28
+ side: RunSide
29
+ policy_slug: str
30
+ policy_version: str
31
+ benchmark_slug: str
32
+ benchmark_version: str
33
+ shards: int
34
+
35
+
36
+ class ResourceUsageSample(BaseModel):
37
+ """The box's resource usage over the interval between two host readings.
38
+
39
+ The disk and network byte counts cover the interval since the previous
40
+ reading. `assignment` is None while the runner does not have an assignment.
41
+ The Docker fields are None when the runner could not read the Docker daemon.
42
+ """
43
+
44
+ gpus: list[GpuUsage]
45
+ assignment: AssignmentAttributes | None
46
+ sampled_at: AwareDatetime
47
+ # cpu
48
+ cpu_utilization_percent: float
49
+ load_average_1m: float
50
+ # memory
51
+ memory_used_bytes: int
52
+ # disk
53
+ io_wait_percent: float
54
+ disk_read_bytes: int
55
+ disk_write_bytes: int
56
+ docker_root_free_bytes: int | None
57
+ tmp_free_bytes: int
58
+ # network
59
+ network_received_bytes: int
60
+ network_sent_bytes: int
61
+ # docker
62
+ docker_image_count: int | None
63
+ docker_image_bytes: int | None
64
+
65
+
66
+ class ReportResourceUsageRequest(BaseModel):
67
+ samples: list[ResourceUsageSample]
68
+ # Sent once per request instead of per sample because they do not change
69
+ # between samples.
70
+ cpu_count: int
71
+ memory_total_bytes: int
72
+
73
+
74
+ class PolicyStep(BaseModel):
75
+ """One policy step that the policy side's bridge timed.
76
+
77
+ A step runs from the runner writing the last byte of an OBSERVATION frame to
78
+ the policy until the runner reads the start of the ACTION frame that answers
79
+ it. The runner times each connection's steps separately, so a step includes
80
+ the time the policy spends serving other connections.
81
+ """
82
+
83
+ ended_at: AwareDatetime
84
+ duration_sec: float
85
+
86
+
87
+ class ReportPolicyMetricsRequest(BaseModel):
88
+ assignment: AssignmentAttributes
89
+ steps: list[PolicyStep]
90
+
91
+
92
+ class BenchmarkStep(BaseModel):
93
+ """One step that the benchmark side's bridge timed.
94
+
95
+ The round trip runs from the runner writing the last byte of an OBSERVATION
96
+ frame to the policy side until the runner reads the start of the ACTION
97
+ frame that answers it. `policy_step_sec` is the duration of the same step
98
+ that the policy side's bridge timed and sent after the ACTION frame.
99
+ """
100
+
101
+ ended_at: AwareDatetime
102
+ round_trip_sec: float
103
+ policy_step_sec: float
104
+
105
+
106
+ class ReportBenchmarkMetricsRequest(BaseModel):
107
+ assignment: AssignmentAttributes
108
+ steps: list[BenchmarkStep]
109
+
110
+
111
+ def create_assignment_attributes(dispatch: AssignmentDispatch) -> AssignmentAttributes:
112
+ return AssignmentAttributes(
113
+ assignment_id=dispatch.assignment.id,
114
+ run_id=dispatch.run.id,
115
+ side=dispatch.assignment.side,
116
+ policy_slug=dispatch.run.policy_slug,
117
+ policy_version=dispatch.run.policy_version,
118
+ benchmark_slug=dispatch.run.benchmark_slug,
119
+ benchmark_version=dispatch.run.benchmark_version,
120
+ shards=dispatch.shards,
121
+ )
@@ -0,0 +1,19 @@
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class Organization(BaseModel):
8
+ id: UUID
9
+ slug: str
10
+ display_name: str
11
+ created_at: datetime
12
+
13
+
14
+ class OrganizationNotFoundError(Exception):
15
+ """The caller named an organization the server will not show them.
16
+
17
+ The server answers alike for one that does not exist and one the caller is
18
+ not a member of, so this covers both. Carries the server's message.
19
+ """
@@ -0,0 +1,59 @@
1
+ from collections.abc import Callable
2
+ from enum import StrEnum
3
+
4
+ from pydantic import BaseModel
5
+
6
+ # The largest page the API will serve; it rejects a request for more. However
7
+ # many rows a caller asks for in total, the collector requests at most this many
8
+ # per request.
9
+ MAX_PAGE_LIMIT = 200
10
+
11
+ # Guards against a server that keeps handing back a cursor: the collector stops
12
+ # after this many pages. At the maximum page size that is still far more rows
13
+ # than any command means to print.
14
+ MAX_PAGES = 100
15
+
16
+
17
+ class SortOrder(StrEnum):
18
+ """The end of a time-ordered list where the first page starts."""
19
+
20
+ ASC = "asc"
21
+ DESC = "desc"
22
+
23
+
24
+ class Page[T](BaseModel):
25
+ """One page of a keyset-paginated list endpoint.
26
+
27
+ `total` counts every row matching the query, ignoring the page limit, so a
28
+ command can report how much it is showing. `next_cursor` is opaque: it is
29
+ echoed back to fetch the following page and never parsed here.
30
+ """
31
+
32
+ items: list[T]
33
+ total: int
34
+ next_cursor: str | None = None
35
+
36
+
37
+ def collect[T](
38
+ fetch: Callable[[int, str | None], Page[T]], *, limit: int | None = None
39
+ ) -> tuple[list[T], int]:
40
+ """Read a paginated list by following its cursor, and report the total.
41
+
42
+ Stops once `limit` rows are in hand, or at the end of the list when no
43
+ limit is given. The total comes back alongside the rows because it counts
44
+ every match, so a truncated read can say what it left out.
45
+ """
46
+ items: list[T] = []
47
+ total = 0
48
+ cursor: str | None = None
49
+ for _ in range(MAX_PAGES):
50
+ remaining = MAX_PAGE_LIMIT if limit is None else limit - len(items)
51
+ page = fetch(min(remaining, MAX_PAGE_LIMIT), cursor)
52
+ items.extend(page.items)
53
+ total = page.total
54
+ if page.next_cursor is None or len(page.items) == 0:
55
+ break
56
+ if limit is not None and len(items) >= limit:
57
+ break
58
+ cursor = page.next_cursor
59
+ return (items if limit is None else items[:limit]), total
@@ -0,0 +1,49 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+ from uuid import UUID
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from manifold_cli.models.catalog import Adapter, Visibility
8
+
9
+
10
+ class Policy(BaseModel):
11
+ """A policy, owned by one organization.
12
+
13
+ `slug` is unique only within `owner_org_slug`, so the two together are what
14
+ names a policy, written `<org>/<slug>` where a command takes one.
15
+ """
16
+
17
+ id: UUID
18
+ owner_user_id: UUID
19
+ owner_org_id: UUID
20
+ owner_org_slug: str
21
+ slug: str
22
+ display_name: str
23
+ visibility: Visibility
24
+ created_at: datetime
25
+ updated_at: datetime
26
+
27
+
28
+ class PolicyVersion(BaseModel):
29
+ """A runnable artifact under a policy — what a run actually executes."""
30
+
31
+ id: UUID
32
+ policy_id: UUID
33
+ version: str
34
+ adapter: Adapter
35
+ config: dict[str, Any]
36
+ minimum_gpu_memory_gb: int
37
+ minimum_driver_major: int | None
38
+ maximum_driver_major: int | None
39
+ minimum_gpu_count: int | None = None
40
+ created_at: datetime
41
+ updated_at: datetime
42
+
43
+
44
+ class PolicyNotFoundError(Exception):
45
+ """No policy visible to the caller matches this reference.
46
+
47
+ Carries the server's message, which contains the reference that failed to
48
+ resolve.
49
+ """