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,52 @@
1
+ """Access-token use cases - create, list, and revoke credentials."""
2
+
3
+ from datetime import datetime
4
+ from typing import Protocol
5
+ from uuid import UUID
6
+
7
+ from manifold_cli.models.token import AccessToken, CreatedAccessToken, Scope
8
+
9
+
10
+ class TokenApiClient(Protocol):
11
+ """The access-token endpoints TokenService drives on the API client."""
12
+
13
+ def create_access_token(
14
+ self,
15
+ name: str,
16
+ scopes: list[Scope],
17
+ *,
18
+ expires_at: datetime | None,
19
+ ) -> CreatedAccessToken: ...
20
+
21
+ def list_access_tokens(self) -> list[AccessToken]: ...
22
+
23
+ def revoke_access_token(self, token_id: UUID) -> AccessToken:
24
+ """Raise AccessTokenNotFoundError when the token is not the caller's."""
25
+ ...
26
+
27
+
28
+ class TokenService:
29
+ def __init__(self, manifold_client: TokenApiClient):
30
+ self._manifold_client = manifold_client
31
+
32
+ def create(
33
+ self,
34
+ name: str,
35
+ scopes: list[Scope],
36
+ *,
37
+ expires_at: datetime | None,
38
+ ) -> CreatedAccessToken:
39
+ """Create an access token and return the only copy of its plaintext."""
40
+ return self._manifold_client.create_access_token(
41
+ name, scopes, expires_at=expires_at
42
+ )
43
+
44
+ # NOTE: this shadows the builtin `list` for the rest of the class body, so a
45
+ # method defined below cannot annotate anything as `list[...]`.
46
+ def list(self) -> list[AccessToken]:
47
+ """The caller's tokens, oldest first, revoked included."""
48
+ return self._manifold_client.list_access_tokens()
49
+
50
+ def revoke(self, token_id: UUID) -> AccessToken:
51
+ """Revoke an access token and return its stored metadata."""
52
+ return self._manifold_client.revoke_access_token(token_id)
@@ -0,0 +1,30 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ def _default_config_dir() -> Path:
6
+ xdg = os.environ.get("XDG_CONFIG_HOME")
7
+ base = Path(xdg) if xdg else Path.home() / ".config"
8
+ return base / "manifold"
9
+
10
+
11
+ class Settings:
12
+ MANIFOLD_TOKEN: str | None = os.environ.get("MANIFOLD_TOKEN")
13
+ MANIFOLD_API_URL: str = os.environ.get(
14
+ "MANIFOLD_API_URL", "https://api.manifold.bifrost.ai"
15
+ )
16
+ MANIFOLD_WEBAPP_URL: str = os.environ.get(
17
+ "MANIFOLD_WEBAPP_URL",
18
+ MANIFOLD_API_URL.replace("://api.", "://app.", 1),
19
+ )
20
+ MANIFOLD_CONFIG_DIR: Path = Path(
21
+ os.environ.get("MANIFOLD_CONFIG_DIR", str(_default_config_dir()))
22
+ )
23
+ CREDENTIALS_PATH: Path = MANIFOLD_CONFIG_DIR / "credentials.json"
24
+ # The root the runner homes sit under. Overridable on its own, so a box
25
+ # under systemd puts them somewhere a service account reaches
26
+ # (/etc/manifold/runners) while the human's credential stays in the
27
+ # config dir.
28
+ MANIFOLD_RUNNERS_DIR: Path = Path(
29
+ os.environ.get("MANIFOLD_RUNNERS_DIR", str(MANIFOLD_CONFIG_DIR / "runners"))
30
+ )
@@ -0,0 +1,31 @@
1
+ """Transport package: interfaces, adapters, and the registry."""
2
+
3
+ from manifold_cli.transport.interfaces import (
4
+ ByteStream,
5
+ Connector,
6
+ Listener,
7
+ StreamClosedError,
8
+ TransportError,
9
+ )
10
+ from manifold_cli.transport.iroh import IrohConnector, IrohListener, IrohTransport
11
+ from manifold_cli.transport.registry import Transport, TransportRegistry
12
+ from manifold_cli.transport.tcp import TcpConnector, TcpListener, TcpTransport
13
+
14
+ DEFAULT_TRANSPORTS = TransportRegistry([TcpTransport(), IrohTransport()])
15
+
16
+ __all__ = [
17
+ "DEFAULT_TRANSPORTS",
18
+ "ByteStream",
19
+ "Connector",
20
+ "IrohConnector",
21
+ "IrohListener",
22
+ "IrohTransport",
23
+ "Listener",
24
+ "StreamClosedError",
25
+ "TcpConnector",
26
+ "TcpListener",
27
+ "TcpTransport",
28
+ "Transport",
29
+ "TransportError",
30
+ "TransportRegistry",
31
+ ]
@@ -0,0 +1,72 @@
1
+ """Transport diagnostics: a built-in echo policy and a ping round-trip.
2
+
3
+ These smoke-test a transport end to end -- `manifold policy serve --echo`
4
+ serves the echo side and `manifold benchmark run --ping` dials it.
5
+ """
6
+
7
+ import asyncio
8
+ import time
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+
12
+ from manifold_cli.transport.interfaces import ByteStream, Connector, Listener
13
+
14
+ ECHO_CHUNK_SIZE = 64 * 1024
15
+
16
+ OnEvent = Callable[[str], None]
17
+
18
+
19
+ async def echo_once(stream: ByteStream, client: str, on_event: OnEvent) -> None:
20
+ """Echo one client's bytes back, logging each chunk it receives."""
21
+ total = 0
22
+ try:
23
+ while True:
24
+ chunk = await stream.read(ECHO_CHUNK_SIZE)
25
+ if not chunk:
26
+ break
27
+ total += len(chunk)
28
+ on_event(f"{client} received and echoed {len(chunk)} bytes")
29
+ await stream.write(chunk)
30
+ finally:
31
+ await stream.close()
32
+ on_event(f"{client} disconnected ({total} bytes total)")
33
+
34
+
35
+ async def serve_echo(listener: Listener, on_event: OnEvent) -> None:
36
+ tasks: set[asyncio.Task[None]] = set()
37
+ client_count = 0
38
+ try:
39
+ while True:
40
+ stream = await listener.accept()
41
+ client_count += 1
42
+ client = f"client {client_count}"
43
+ on_event(f"{client} connected")
44
+ task = asyncio.create_task(echo_once(stream, client, on_event))
45
+ tasks.add(task)
46
+ task.add_done_callback(tasks.discard)
47
+ finally:
48
+ for task in tasks:
49
+ task.cancel()
50
+ await asyncio.gather(*tasks, return_exceptions=True)
51
+ await listener.close()
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class PingResult:
56
+ matched: bool
57
+ size: int
58
+ elapsed_ms: float
59
+
60
+
61
+ async def ping(connector: Connector, payload: bytes) -> PingResult:
62
+ stream = await connector.connect()
63
+ start = time.perf_counter()
64
+ try:
65
+ await stream.write(payload)
66
+ echoed = await stream.read_exactly(len(payload))
67
+ finally:
68
+ await stream.close()
69
+ elapsed_ms = (time.perf_counter() - start) * 1000
70
+ return PingResult(
71
+ matched=echoed == payload, size=len(payload), elapsed_ms=elapsed_ms
72
+ )
@@ -0,0 +1,69 @@
1
+ """Byte-only transport interfaces: ByteStream, Connector, Listener."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+
6
+ class TransportError(Exception):
7
+ """Transport-level error; adapters translate infrastructure failures into this."""
8
+
9
+
10
+ class StreamClosedError(TransportError):
11
+ """Raised by `read_exactly` when EOF arrives before `n` bytes.
12
+
13
+ `partial` holds the bytes read before EOF.
14
+ """
15
+
16
+ def __init__(self, partial: bytes, expected: int) -> None:
17
+ self.partial = partial
18
+ self.expected = expected
19
+ super().__init__(
20
+ f"stream closed after {len(partial)} of {expected} expected bytes"
21
+ )
22
+
23
+
24
+ @runtime_checkable
25
+ class ByteStream(Protocol):
26
+ """A duplex byte pipe."""
27
+
28
+ async def read(self, n: int) -> bytes:
29
+ """Read up to `n` bytes; returns `b""` at EOF."""
30
+ ...
31
+
32
+ async def read_exactly(self, n: int) -> bytes:
33
+ """Read exactly `n` bytes; raises `StreamClosedError` on EOF."""
34
+ ...
35
+
36
+ async def write(self, data: bytes) -> None:
37
+ """Write all of `data` and flush."""
38
+ ...
39
+
40
+ async def close(self) -> None:
41
+ """Close the stream. Idempotent."""
42
+ ...
43
+
44
+
45
+ @runtime_checkable
46
+ class Connector(Protocol):
47
+ """The dialing side of a transport."""
48
+
49
+ async def connect(self) -> ByteStream:
50
+ """Open a new `ByteStream` to the configured peer."""
51
+ ...
52
+
53
+
54
+ @runtime_checkable
55
+ class Listener(Protocol):
56
+ """The serving side of a transport."""
57
+
58
+ @property
59
+ def target(self) -> str:
60
+ """Dialable target string (e.g. `tcp:HOST:PORT` or `iroh:ID`)."""
61
+ ...
62
+
63
+ async def accept(self) -> ByteStream:
64
+ """Return the next inbound stream; raises `StreamClosedError` if closed."""
65
+ ...
66
+
67
+ async def close(self) -> None:
68
+ """Stop accepting and release the bound address. Idempotent."""
69
+ ...
@@ -0,0 +1,317 @@
1
+ """Iroh (QUIC/NAT-traversal) transport adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import ctypes
8
+ import logging
9
+ import struct
10
+ import weakref
11
+ from collections.abc import Awaitable, Callable
12
+ from importlib.metadata import version
13
+
14
+ import iroh
15
+ import iroh.iroh_ffi
16
+
17
+ from manifold_cli.transport.interfaces import StreamClosedError, TransportError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ ALPN = b"manifold/transport/0"
22
+ # Nebius admits inbound UDP to the runners on exactly these ports, through
23
+ # `runner_iroh_ingress` in manifold's
24
+ # deployment/terraform-runners-nebius/network.tf. Change both together. The
25
+ # port numbers are arbitrary: any free ports below Linux's ephemeral range
26
+ # (32768-60999) work. Keep the range to eight ports, because Nebius accepts at
27
+ # most eight destination ports in one security rule.
28
+ ENDPOINT_PORTS = range(21300, 21308)
29
+ READ_CHUNK_SIZE = 64 * 1024
30
+ ONLINE_TIMEOUT_SECONDS = 20.0
31
+ DRAIN_TIMEOUT_SECONDS = 5.0
32
+ CLOSE_CODE_OK = 0
33
+
34
+ # The replacements below match the `_UniffiRustBufferBuilder` methods
35
+ # generated in these releases only; other releases are not patched.
36
+ IROH_VERSIONS_WITH_BYTE_LOOP = frozenset({"1.0.0", "1.1.0"})
37
+
38
+
39
+ def _pack_into(self, size, format, value) -> None:
40
+ with self._reserve(size):
41
+ packed = struct.pack(format, value)
42
+ if size > 0:
43
+ ctypes.memmove(
44
+ ctypes.addressof(self.rbuf.data.contents) + self.rbuf.len,
45
+ packed,
46
+ size,
47
+ )
48
+
49
+
50
+ def _write(self, value) -> None:
51
+ length = len(value)
52
+ with self._reserve(length):
53
+ if length > 0:
54
+ # memmove refuses a bytearray or memoryview; the binding's
55
+ # check_lower admits any bytes-like value.
56
+ ctypes.memmove(
57
+ ctypes.addressof(self.rbuf.data.contents) + self.rbuf.len,
58
+ bytes(value),
59
+ length,
60
+ )
61
+
62
+
63
+ if version("iroh") in IROH_VERSIONS_WITH_BYTE_LOOP:
64
+ iroh.iroh_ffi._UniffiRustBufferBuilder._pack_into = _pack_into
65
+ iroh.iroh_ffi._UniffiRustBufferBuilder.write = _write
66
+
67
+
68
+ async def wait_online(endpoint: iroh.Endpoint) -> None:
69
+ # online() can hang; wrapped in a timeout (relay needed only for id-only dialing).
70
+ with contextlib.suppress(TimeoutError):
71
+ await asyncio.wait_for(endpoint.online(), timeout=ONLINE_TIMEOUT_SECONDS)
72
+
73
+
74
+ class BiStream:
75
+ """The send + recv halves of one resolved iroh bi-directional stream."""
76
+
77
+ def __init__(self, send: iroh.SendStream, recv: iroh.RecvStream) -> None:
78
+ self.send = send
79
+ self.recv = recv
80
+
81
+
82
+ class IrohByteStream:
83
+ """A `ByteStream` over one iroh bi-directional stream."""
84
+
85
+ def __init__(
86
+ self,
87
+ connection: iroh.Connection,
88
+ open_bi_stream: Callable[[iroh.Connection], Awaitable[BiStream]],
89
+ ) -> None:
90
+ self._connection = connection
91
+ self._open_bi_stream = open_bi_stream
92
+ self._bi: BiStream | None = None
93
+ self._closed = False
94
+ # bi_lock: concurrent first read+write must not open two separate bi-streams.
95
+ self._bi_lock = asyncio.Lock()
96
+
97
+ async def _bi_stream(self) -> BiStream:
98
+ if self._bi is None:
99
+ async with self._bi_lock:
100
+ if self._bi is None:
101
+ self._bi = await self._open_bi_stream(self._connection)
102
+ return self._bi
103
+
104
+ async def read(self, n: int) -> bytes:
105
+ if n == 0:
106
+ return b""
107
+ bi = await self._bi_stream()
108
+ try:
109
+ return await bi.recv.read(n)
110
+ except iroh.IrohError:
111
+ # QUIC raises on peer-close where a socket would return b"" (EOF).
112
+ return b""
113
+
114
+ async def read_exactly(self, n: int) -> bytes:
115
+ if n == 0:
116
+ return b""
117
+ buffer = bytearray()
118
+ while len(buffer) < n:
119
+ chunk = await self.read(min(READ_CHUNK_SIZE, n - len(buffer)))
120
+ if not chunk:
121
+ raise StreamClosedError(bytes(buffer), n)
122
+ buffer.extend(chunk)
123
+ return bytes(buffer)
124
+
125
+ async def write(self, data: bytes) -> None:
126
+ if self._closed:
127
+ raise StreamClosedError(b"", len(data))
128
+ bi = await self._bi_stream()
129
+ await bi.send.write_all(data)
130
+
131
+ async def close(self) -> None:
132
+ if self._closed:
133
+ return
134
+ self._closed = True
135
+ # Must finish() then wait for stopped() before connection.close(): closing
136
+ # immediately drops buffered bytes the peer has not read yet.
137
+ try:
138
+ if self._bi is not None:
139
+ with contextlib.suppress(iroh.IrohError, TimeoutError):
140
+ await self._bi.send.finish()
141
+ await asyncio.wait_for(
142
+ self._bi.send.stopped(), timeout=DRAIN_TIMEOUT_SECONDS
143
+ )
144
+ finally:
145
+ with contextlib.suppress(iroh.IrohError):
146
+ self._connection.close(CLOSE_CODE_OK, b"closed")
147
+
148
+
149
+ async def open_bi_dial(connection: iroh.Connection) -> BiStream:
150
+ bi = await connection.open_bi()
151
+ return BiStream(bi.send(), bi.recv())
152
+
153
+
154
+ async def open_bi_accept(connection: iroh.Connection) -> BiStream:
155
+ bi = await connection.accept_bi()
156
+ return BiStream(bi.send(), bi.recv())
157
+
158
+
159
+ class IrohConnector:
160
+ """Dials an iroh endpoint id from the process's shared `DialEndpoint`."""
161
+
162
+ def __init__(self, endpoint_id: str, dial_endpoint: DialEndpoint) -> None:
163
+ self._endpoint_id = endpoint_id.removeprefix("iroh:")
164
+ self._dial_endpoint = dial_endpoint
165
+
166
+ async def connect(self) -> IrohByteStream:
167
+ try:
168
+ endpoint_id = iroh.EndpointId.from_string(self._endpoint_id)
169
+ except iroh.IrohError as exc:
170
+ raise TransportError(
171
+ f"invalid iroh endpoint id {self._endpoint_id!r}"
172
+ ) from exc
173
+ endpoint = await self._dial_endpoint.endpoint()
174
+ await wait_online(endpoint)
175
+ addr = iroh.EndpointAddr(endpoint_id, None, [])
176
+ try:
177
+ connection = await endpoint.connect(addr, ALPN)
178
+ except iroh.IrohError as exc:
179
+ raise TransportError(
180
+ f"could not connect to iroh endpoint {self._endpoint_id!r}: {exc}"
181
+ ) from exc
182
+ return IrohByteStream(connection, open_bi_dial)
183
+
184
+
185
+ class DialEndpoint:
186
+ """The one iroh endpoint that every `IrohConnector` of a process dials from.
187
+
188
+ The endpoint binds the first free port of `ports`, and a random port once
189
+ every port in the range is in use.
190
+ """
191
+
192
+ def __init__(self, ports: range, preset: Callable[[], iroh.Preset]) -> None:
193
+ self._ports = ports
194
+ self._preset = preset
195
+ self._endpoint: iroh.Endpoint | None = None
196
+ # One bind lock per event loop: an asyncio.Lock binds to the first loop
197
+ # it waits in, and the daemon runs each side in a loop of its own.
198
+ self._bind_locks: weakref.WeakKeyDictionary[
199
+ asyncio.AbstractEventLoop, asyncio.Lock
200
+ ] = weakref.WeakKeyDictionary()
201
+
202
+ async def endpoint(self) -> iroh.Endpoint:
203
+ """The endpoint, bound on first use."""
204
+ lock = self._bind_locks.setdefault(asyncio.get_running_loop(), asyncio.Lock())
205
+ # Concurrent first dials must not bind two endpoints.
206
+ async with lock:
207
+ if self._endpoint is None:
208
+ self._endpoint = await self._bind()
209
+ return self._endpoint
210
+
211
+ async def _bind(self) -> iroh.Endpoint:
212
+ return await bind_endpoint(self._ports, self._preset)
213
+
214
+
215
+ class IrohListener:
216
+ """Accepts inbound iroh connections as `ByteStream`s."""
217
+
218
+ def __init__(self, endpoint: iroh.Endpoint) -> None:
219
+ self._endpoint = endpoint
220
+ self._endpoint_id = str(endpoint.id())
221
+ self._pending: list[IrohByteStream] = []
222
+ self._closed = False
223
+ self._ready = asyncio.Event()
224
+ self._accept_loop = asyncio.create_task(self._run_accept_loop())
225
+
226
+ @classmethod
227
+ async def open(
228
+ cls, ports: range, preset: Callable[[], iroh.Preset]
229
+ ) -> IrohListener:
230
+ endpoint = await bind_endpoint(ports, preset)
231
+ await wait_online(endpoint)
232
+ return cls(endpoint)
233
+
234
+ async def _run_accept_loop(self) -> None:
235
+ while not self._closed:
236
+ try:
237
+ incoming = await self._endpoint.accept_next()
238
+ if incoming is None:
239
+ return
240
+ accepting = await incoming.accept()
241
+ connection = await accepting.connect()
242
+ except iroh.IrohError:
243
+ if self._closed:
244
+ return
245
+ continue
246
+ # Hand off the connection; accept_bi blocks until peer writes (deadlock).
247
+ self._pending.append(IrohByteStream(connection, open_bi_accept))
248
+ self._ready.set()
249
+
250
+ @property
251
+ def target(self) -> str:
252
+ return f"iroh:{self._endpoint_id}"
253
+
254
+ async def accept(self) -> IrohByteStream:
255
+ while not self._pending:
256
+ if self._closed:
257
+ raise StreamClosedError(b"", 0)
258
+ self._ready.clear()
259
+ await self._ready.wait()
260
+ return self._pending.pop(0)
261
+
262
+ async def close(self) -> None:
263
+ if self._closed:
264
+ return
265
+ self._closed = True
266
+ self._ready.set()
267
+ self._accept_loop.cancel()
268
+ with contextlib.suppress(asyncio.CancelledError):
269
+ await self._accept_loop
270
+ try:
271
+ while self._pending:
272
+ with contextlib.suppress(iroh.IrohError):
273
+ await self._pending.pop(0).close()
274
+ finally:
275
+ await self._endpoint.close()
276
+
277
+
278
+ async def bind_endpoint(
279
+ ports: range, preset: Callable[[], iroh.Preset]
280
+ ) -> iroh.Endpoint:
281
+ """Bind an endpoint to the first free port of `ports`, or to a random port
282
+ once every port in the range is in use."""
283
+ error: iroh.IrohError | None = None
284
+ for port in ports:
285
+ try:
286
+ return await iroh.Endpoint.bind(
287
+ iroh.EndpointOptions(
288
+ preset=preset(), alpns=[ALPN], bind_addr=f"0.0.0.0:{port}"
289
+ )
290
+ )
291
+ except iroh.IrohError as exc:
292
+ error = exc
293
+ logger.warning(
294
+ "could not bind iroh ports %d-%d (%s); binding a random port",
295
+ ports.start,
296
+ ports.stop - 1,
297
+ error,
298
+ )
299
+ return await iroh.Endpoint.bind(
300
+ iroh.EndpointOptions(preset=preset(), alpns=[ALPN], bind_addr=None)
301
+ )
302
+
303
+
304
+ class IrohTransport:
305
+ """The `iroh` transport descriptor."""
306
+
307
+ scheme = "iroh"
308
+
309
+ def __init__(self) -> None:
310
+ self._dial_endpoint = DialEndpoint(ENDPOINT_PORTS, iroh.preset_n0)
311
+
312
+ def build_connector(self, rest: str) -> IrohConnector:
313
+ return IrohConnector(rest, self._dial_endpoint)
314
+
315
+ async def open_listener(self, rest: str) -> IrohListener:
316
+ del rest
317
+ return await IrohListener.open(ENDPOINT_PORTS, iroh.preset_n0)
@@ -0,0 +1,34 @@
1
+ """Transport registry: maps `scheme:rest` target strings to adapter descriptors."""
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Protocol
5
+
6
+ from manifold_cli.transport.interfaces import Connector, Listener, TransportError
7
+
8
+
9
+ class Transport(Protocol):
10
+ """A transport adapter descriptor exported by each adapter module."""
11
+
12
+ scheme: str
13
+
14
+ def build_connector(self, rest: str) -> Connector: ...
15
+ async def open_listener(self, rest: str) -> Listener: ...
16
+
17
+
18
+ class TransportRegistry:
19
+ """Maps scheme to adapter; raises `TransportError` on unknown schemes."""
20
+
21
+ def __init__(self, transports: Iterable[Transport]) -> None:
22
+ self.transports = {transport.scheme: transport for transport in transports}
23
+
24
+ def resolve(self, target: str) -> tuple[Transport, str]:
25
+ """Split `target` into its adapter and the trailing `rest` (after `scheme:`)."""
26
+ scheme, _, rest = target.partition(":")
27
+ transport = self.transports.get(scheme)
28
+ if transport is None:
29
+ known = ", ".join(sorted(self.transports)) or "(none)"
30
+ raise TransportError(
31
+ f"unknown transport scheme {scheme!r} in target {target!r}; "
32
+ f"known schemes: {known}"
33
+ )
34
+ return transport, rest