positronic-wire 0.12.0__tar.gz

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.
@@ -0,0 +1,167 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ .venv-typecheck
126
+ env/
127
+ venv/
128
+ ENV/
129
+ env.bak/
130
+ venv.bak/
131
+
132
+ # Spyder project settings
133
+ .spyderproject
134
+ .spyproject
135
+
136
+ # Rope project settings
137
+ .ropeproject
138
+
139
+ # mkdocs documentation
140
+ /site
141
+
142
+ # mypy
143
+ .mypy_cache/
144
+ .dmypy.json
145
+ dmypy.json
146
+
147
+ # Pyre type checker
148
+ .pyre/
149
+
150
+ # pytype static type analyzer
151
+ .pytype/
152
+
153
+ # Cython debug symbols
154
+ cython_debug/
155
+
156
+ # PyCharm
157
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
158
+ # be added to the global gitignore or merged into this project gitignore. For a PyCharm
159
+ # project, it is generally recommended to exclude comment-based line additions.
160
+ .idea/
161
+
162
+ # VSCode
163
+ .vscode/
164
+ *.code-workspace
165
+
166
+ # Environment variables
167
+ .env*
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.5
2
+ Name: positronic-wire
3
+ Version: 0.12.0
4
+ Summary: The client side of the transports a Positronic inference session runs over
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Provides-Extra: grpc
8
+ Requires-Dist: grpcio; extra == 'grpc'
9
+ Provides-Extra: websocket
10
+ Requires-Dist: websockets>=15.0.1; extra == 'websocket'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # positronic-wire
14
+
15
+ The client side of the transports a Positronic inference session runs over: websockets, gRPC, and a
16
+ partner's own protocol. It carries the facts both ends of a wire share. It is one distribution,
17
+ installable on its own. The core has no third-party dependencies; the `grpc` and `websocket` extras
18
+ install `grpcio` and `websockets` independently.
19
+
20
+ > **Alpha, under rapid development.** Names and behaviour change without notice, and nothing here is
21
+ > covered by a backwards-compatibility guarantee. Pin the exact version you tested against.
22
+
23
+ ```bash
24
+ uv add "positronic-wire[websocket,grpc]==0.12.0"
25
+ uv add "positronic-wire[websocket,grpc] @ git+https://github.com/Positronic-Robotics/positronic@<tag or commit>#subdirectory=wire"
26
+ ```
27
+
28
+ For an unpublished revision, use the Git installation pinned to a tag or commit.
29
+
30
+ `positronic_wire` never imports `positronic` or `positronic_model_server`. Both packages depend on
31
+ it: the server transports in `positronic_model_server` import the shared wire definitions, and
32
+ `positronic.offboard.client.InferenceClient` dials through the client wires.
33
+
34
+ ## What a wire is
35
+
36
+ A wire is one transport that carries a protocol's frames as opaque bytes. It has two ends. The
37
+ client end dials a session and probes a server; the server end accepts sessions and refuses an
38
+ unauthorized peer. Everything specific to a transport — the library, its exception types, its
39
+ status codes, its metadata keys, the path a probe asks for, the URL scheme it writes — lives inside
40
+ that transport's wire classes. Code outside a wire speaks to every transport through one interface
41
+ and reads one answer. A wire over TLS is a member of its own, so no caller holds a `secure` flag,
42
+ and nothing anywhere reads a transport off a URL scheme: a caller names the wire.
43
+
44
+ WebSocket and gRPC carry the session frames used by `positronic_model_server` and the legacy
45
+ `positronic.offboard` server. Both servers use transports from `positronic_model_server`.
46
+ `roboarena` carries a partner's own protocol. The partner serves it, and this package holds the
47
+ client end alone.
48
+
49
+ ## The package boundary
50
+
51
+ | Module | Holds |
52
+ |---|---|
53
+ | `positronic_wire.wire` | The routes (`API_PATH`, `SESSION_PATH`, `KEEPALIVE_PATH`), `ALIVE_SECONDS`, the key the keepalive answer carries, `MAX_MESSAGE_BYTES`, the addresses `HostPortAddress(host, port, path, query)` and `UnixSocketAddress(uds, path, query)` under the abstract `SessionAddress`, the type variable `AddressT` over them, `netloc`, `bracket_ipv6(host)`, `Refusal`, `ConnectRefused`, `PeerDisconnected`, `KeepaliveUnsupported`, and the abstract `ClientWire` and `ClientConnection` |
54
+ | `positronic_wire.websocket` | `WebsocketClientWire`, `WebsocketTlsClientWire`, `WebsocketUnixClientWire`, `WebsocketClientConnection`, `refusal_of(raised)`, which reads a failed handshake as a `Refusal`, and `connected_socket(host, port, timeout)`, a TCP connect whose timeout covers the name lookup and every address |
55
+ | `positronic_wire.grpc` | `GrpcClientWire`, `GrpcTlsClientWire`, `GrpcClientConnection`, `target(host, port)`, and the calls both ends agree on: `SERVICE`, `METHOD`, `METHOD_PATH`, `KEEPALIVE_METHOD`, `KEEPALIVE_METHOD_PATH`, `PROBE_PATH`, `SESSION_PATH_HEADER`, `SESSION_QUERY_HEADER`, `MESSAGE_SIZE_OPTIONS`, `PING_EVERY_MS` |
56
+ | `positronic_wire.roboarena` | `RoboarenaClientWire`, `RoboarenaClientConnection`, `RoboarenaAddress`, and `TextAnswer`, which a text frame raises. The handshake carries the headers the caller gives, and none where it gives none |
57
+ | `positronic_wire.registry` | `CLIENT_WIRES`, every installed member by its `NAME`, and `client_wire(name)` |
58
+
59
+ [`positronic_model_server`](../model_server/README.md) owns the server transports:
60
+ `server_wire.Wire`, `server_wire.ServerConnection`, `websocket_wire.WebsocketWire` and
61
+ `grpc_wire.GrpcWire`. It also owns `ModelServer`, shared message definitions and value serialization.
62
+ The WebSocket server uses Uvicorn directly; the gRPC server uses `grpcio`'s `grpc.aio` API.
63
+ Each server transport has its own optional installation extra.
64
+
65
+ `positronic.offboard` owns `InferenceClient`, the legacy `PolicyServer`, and v1/v2 robot-command
66
+ encoding. `positronic-wire` does not depend on either server package.
67
+
68
+ ## The client interface
69
+
70
+ A caller selects a wire by `NAME`: `websocket`, `websocket_tls`, `websocket_unix`, `grpc`, `grpc_tls`,
71
+ `roboarena`. A wire dials an address of its `ADDRESS` type, and `DEFAULT_PORT` names the port a URL
72
+ leaves out on the members that carry one.
73
+
74
+ - `session_url(address)` — the session as this wire names it, for a log and for an error. The
75
+ websocket members write `ws://` or `wss://`, `websocket_unix` writes `ws+unix://`, and the gRPC
76
+ members write `host:port`, and `roboarena` writes the root it dials. What a wire dials is its own:
77
+ `websocket`, `websocket_tls` and `roboarena` dial this spelling, and the others dial a socket or a
78
+ target instead.
79
+ - `dial(address, headers, open_timeout)` — a client's end of one session. It raises
80
+ `ConnectRefused` when the session does not open, whatever refused it. The `refusal` on the
81
+ exception says what the caller does next: `COLD` (a backend still starting) and `SILENT`
82
+ (nothing answered) retry, `FORBIDDEN` retries a few times, `FINAL` surfaces at once.
83
+ - `probe(address, headers, open_timeout)` — whether a server answers at the address, without opening
84
+ a session. It carries the same `headers` as `dial`, so an edge that authenticates on them lets the
85
+ probe through to the server behind it: the probe wakes what a session would reach. `None` when a
86
+ server answers; a `Refusal` says why none did, in the terms `dial` uses, `FORBIDDEN` among them for
87
+ a credential the edge refused. The websocket wire asks the host's root for an upgrade, which the
88
+ server refuses with 403 and nothing else answers 403 there. The gRPC wire calls `PROBE_PATH`,
89
+ which a server that is up answers `UNIMPLEMENTED`. The roboarena wire opens the root and reads the
90
+ frame the server announces itself with.
91
+ - `keepalive(address, headers, timeout)` — resets the server's idle timer outside a session, and
92
+ returns the seconds the server stays alive after the call, or `None` for a server with no idle
93
+ timeout. `timeout` bounds the whole call. The websocket members send `POST` to `KEEPALIVE_PATH`, and
94
+ the gRPC members call `KEEPALIVE_METHOD_PATH`. `keepalive` raises
95
+ `KeepaliveUnsupported` where the server serves sessions but not the call, and `ConnectRefused`
96
+ otherwise, in the terms `probe` uses: a 200 that does not carry the keepalive answer is `FINAL`.
97
+ `roboarena` always raises `KeepaliveUnsupported`, and dials nothing.
98
+
99
+ `registry.client_wire(name)` selects an installed transport. `CLIENT_WIRES` includes only families
100
+ whose libraries are installed; requesting an unavailable transport raises with installation guidance.
101
+ Use `[websocket]` for WebSocket and Roboarena, `[grpc]` for gRPC, or `[websocket,grpc]` for all wires.
102
+ Importing the core registry without either extra produces an empty registry. Broken imports from an
103
+ installed transport surface as errors.
104
+
105
+ **Each wire declares the address it dials, and takes no other.** `ClientWire.ADDRESS` names that
106
+ type, and every verb above takes it. `websocket`, `websocket_tls`, `grpc` and `grpc_tls` take a
107
+ `HostPortAddress` — `host`, `port`, `path` (`SESSION_PATH`), `query`, as written.
108
+ `websocket_unix` takes a `UnixSocketAddress` — `uds`, `path`, `query`. `roboarena` takes a
109
+ `RoboarenaAddress` — `host` and `port`. A record that names an endpoint carries the wire's name and
110
+ that wire's fields, never a URL, and no address carries a field a wire ignores.
111
+
112
+ `uds` is an absolute path to a Unix socket a server on the same machine bound, dialled instead of
113
+ the network. The address refuses a relative path when it is built, because a relative one names a
114
+ different socket to each caller. It names no host and no port, because a socket has neither: the
115
+ handshake carries `localhost` as a stand-in the server never resolves. A socket is same-machine by
116
+ construction, so no TLS member sits beside it. An absent path is `SILENT`: the client cannot tell a
117
+ misspelt path from a socket nobody has bound yet, so it retries either to its deadline. A refusal
118
+ from a socket a server is restarting on is `SILENT` too. A path holding something that is not a
119
+ socket, and a refused permission, are `FINAL`, because no retry reaches them. A handshake that timed
120
+ out or was reset reached the socket, so the server rather than the path was not ready, and it reads
121
+ `SILENT` as it does on a port.
122
+
123
+ `roboarena` is a partner's own protocol: msgpack frames on a websocket at the bare root of a port the
124
+ partner publishes. The server closes any other path, and it routes on a key inside each frame, so the
125
+ address names no route and no query. It publishes no default port either, so every address states one.
126
+ The server announces its configuration as the first frame of every connection: `dial` leaves that frame
127
+ for the caller's codec, and `probe` reads it and closes. A server that upgrades the connection and
128
+ announces nothing is a backend still starting, so it reads `COLD`. The server reports a failure in a
129
+ text frame and serves nothing more on that connection, so `recv` and `probe` raise `TextAnswer`, which
130
+ carries the text: a retry does not change it. The protocol names no URL scheme and the port is plain,
131
+ so no TLS member sits beside this wire; a partner who terminates TLS in front of it refuses a handshake
132
+ in the terms the websocket members already read.
133
+
134
+ Typing carries the split: a wire handed the other wire's address is a type error at the call site.
135
+ `registry.client_wire(name)` answers by name and cannot, so `InferenceClient` checks `ADDRESS` once,
136
+ before it dials, and names both in the refusal.
137
+
138
+ ## Whether a policy server is up
139
+
140
+ `keepalive` answers it, and `probe` answers it where a server serves no keepalive call. Each one's
141
+ timeout is one wall-clock deadline over the name lookup, every address the name resolves to, and every
142
+ read, so a server the caller did not write cannot hold the call past it. Both open their own socket, so
143
+ they ignore a proxy the environment names, where `dial` goes through it.
144
+
145
+ | What came back | Means |
146
+ |---|---|
147
+ | `keepalive` returns | a policy server admitted the headers |
148
+ | `KeepaliveUnsupported`, then `probe` returns `None` | a server answers and serves no keepalive call |
149
+ | `FORBIDDEN` | an answer that refused the credential |
150
+ | `COLD` | an answer that asks for a retry: a 5xx, a 429 |
151
+ | `FINAL` | an answer no retry changes: any other status, a 200 without the keepalive answer, a host with no address |
152
+ | `SILENT` | no answer: no connection, or none before the timeout or the close |
153
+
154
+ `COLD` and `SILENT` mean the server is not up yet. `roboarena` raises `KeepaliveUnsupported` without a
155
+ dial, and its `probe` raises `TextAnswer` where the server announces a failure. The gRPC members read
156
+ `SILENT` off the details gRPC writes for a connect nothing answered, and off `DEADLINE_EXCEEDED`.
157
+
158
+ The libraries cap what a call reads: `websockets` reads at most 128 headers of 8 KiB and 1 MiB of a
159
+ refused upgrade's body, and `http.client` at most 100 headers of 64 KiB. The websocket members read at
160
+ most 16 KiB of a keepalive body, and the roboarena member one frame of at most `MAX_MESSAGE_BYTES`.
161
+
162
+ ## What each consumer pays
163
+
164
+ | Consumer | Needs | Installs |
165
+ |---|---|---|
166
+ | A rig client, and `positronic` itself | Every verb, the session protocol, the policy stack | `positronic`, which pins `positronic-wire` exactly |
167
+ | A coordinator that probes an endpoint and warms it | `registry.client_wire`, `probe`, `PROBE_PATH`, the routes | `positronic-wire[websocket,grpc]`, or just the required transport extra |
168
+ | A service that validates an endpoint record | `registry.CLIENT_WIRES` | `positronic-wire` with the transport extras it accepts |
169
+ | A host that runs a policy server and waits for it before it sends sessions | `keepalive`, `probe` | `positronic-wire[websocket,grpc]`, or just the required transport extra |
170
+ | A native model server | The shared server and its transports | `positronic-model-server[websocket,grpc]`, or just the required transport extra |
171
+ | A legacy policy server | `positronic.offboard.server.PolicyServer` | `positronic` |
172
+
173
+ A consumer whose lockfile already carries `grpcio` (through a cloud SDK) and `websockets` (through
174
+ `uvicorn`) adds no third-party package when it adds this one.
175
+
176
+ ## What the wire keeps out of its consumers
177
+
178
+ A transport-specific dial disappears from a consumer once it speaks to the wire. A consumer that
179
+ opens a `websockets.connect` of its own to wake an endpoint has to skip every gRPC endpoint, because
180
+ the same call cannot reach one; `probe` reaches both. A consumer that classifies a failed dial by
181
+ matching library exception names over the raised type's bases has to list `grpc.RpcError`,
182
+ `websockets.*` and `httpx.*`; `dial` raises one `ConnectRefused` for all of them, so `isinstance`
183
+ answers.
184
+
185
+ A copied fact disappears too. The gRPC probe path, the session route and the keepalive route are each
186
+ one symbol here, and the set of wires is `registry.CLIENT_WIRES`. A literal spelled a second time
187
+ in another repository drifts the day either side edits it, and nothing reports the drift; an
188
+ imported symbol cannot.
189
+
190
+ A scheme table disappears with them. A record that names an endpoint carries the wire's name and the
191
+ fields that wire's address declares, so no reader of the record derives a transport, a TLS setting or
192
+ a default port from the spelling of a URL.
193
+
194
+ The port a server serves a wire on, and the server flag that names it, stay literals where they are
195
+ spelled: they are a deployment's configuration, not wire facts. A test in the consumer that installs
196
+ `positronic` pins the flag name against the server's own signature.
197
+
198
+ ## Versioning and skew
199
+
200
+ `positronic` pins `positronic-wire==<version>` exactly, as it pins `positronic-platform-client`.
201
+ `utilities/check_workspace_version_bump.py` refuses a change under `wire/` that reuses the version,
202
+ and one that leaves the root's pin behind. The release workflow publishes `wire/` to the index
203
+ before `positronic`, with `skip-existing`, so an unchanged wire is a no-op and a changed one is on
204
+ the index before the release that depends on it.
205
+
206
+ A consumer outside this repository pins the wire the way it pins the client: by the index once the
207
+ wire is published there, and until then by git subdirectory at a commit
208
+ (`positronic-wire @ git+…positronic@<sha>#subdirectory=wire`). A consumer that also installs
209
+ `positronic` at a commit names the same commit for both, so the wire the git install provides is
210
+ the version `positronic` requires.
211
+
212
+ Two deployments of one consumer can hold different wire versions: a rig runs a pinned console on
213
+ its own schedule while a coordinator runs its checkout. The wire keeps that skew readable instead
214
+ of guarding it with a version floor. Each side reports the names its registry lists, and the side
215
+ that hands an endpoint record across refuses a wire name the other side's registry lacks. A
216
+ transport added to the wire is then one entry in `registry.CLIENT_WIRES`, and no consumer has to
217
+ raise a constant naming the version that first dialled it.
218
+
219
+ ## Adopting the wire in a consumer
220
+
221
+ A consumer moves onto the wire in this order, each step green on its own:
222
+
223
+ 1. Depend on `positronic-wire` with the required transport extras. Import the routes, the probe path and the registry; delete the
224
+ local copies, the tables keyed by scheme, and every read of a URL scheme. **An endpoint record
225
+ names its wire, then that wire's address** — the fields `ClientWire.ADDRESS` declares, which differ
226
+ per wire. Report the registry's names in the deploy handshake and retire any per-transport version
227
+ floor.
228
+ 2. Replace the transport-specific dial with `probe`, and the exception-name match with
229
+ `isinstance(raised, (wire.ConnectRefused, wire.PeerDisconnected, TimeoutError))`.
230
+ 3. Move the consumer's own transports into their own `ClientWire` subclasses, each declaring the
231
+ address it dials, registered by name beside the wire's own.
232
+ 4. Deploy the pinned side at the commit that carries the wire, then the checkout side.
233
+
234
+ ## What is not shared
235
+
236
+ - Server transports live in `positronic-model-server` and use Uvicorn or `grpc.aio`.
237
+ - Model message definitions and value serialization live in `positronic-model-server`. The policy
238
+ stack and robotics codecs live in `positronic`. Neither is a dependency of `positronic-wire`.
239
+ - A transport this package does not implement. A consumer that owns one writes it as a `ClientWire`
240
+ of its own.
@@ -0,0 +1,228 @@
1
+ # positronic-wire
2
+
3
+ The client side of the transports a Positronic inference session runs over: websockets, gRPC, and a
4
+ partner's own protocol. It carries the facts both ends of a wire share. It is one distribution,
5
+ installable on its own. The core has no third-party dependencies; the `grpc` and `websocket` extras
6
+ install `grpcio` and `websockets` independently.
7
+
8
+ > **Alpha, under rapid development.** Names and behaviour change without notice, and nothing here is
9
+ > covered by a backwards-compatibility guarantee. Pin the exact version you tested against.
10
+
11
+ ```bash
12
+ uv add "positronic-wire[websocket,grpc]==0.12.0"
13
+ uv add "positronic-wire[websocket,grpc] @ git+https://github.com/Positronic-Robotics/positronic@<tag or commit>#subdirectory=wire"
14
+ ```
15
+
16
+ For an unpublished revision, use the Git installation pinned to a tag or commit.
17
+
18
+ `positronic_wire` never imports `positronic` or `positronic_model_server`. Both packages depend on
19
+ it: the server transports in `positronic_model_server` import the shared wire definitions, and
20
+ `positronic.offboard.client.InferenceClient` dials through the client wires.
21
+
22
+ ## What a wire is
23
+
24
+ A wire is one transport that carries a protocol's frames as opaque bytes. It has two ends. The
25
+ client end dials a session and probes a server; the server end accepts sessions and refuses an
26
+ unauthorized peer. Everything specific to a transport — the library, its exception types, its
27
+ status codes, its metadata keys, the path a probe asks for, the URL scheme it writes — lives inside
28
+ that transport's wire classes. Code outside a wire speaks to every transport through one interface
29
+ and reads one answer. A wire over TLS is a member of its own, so no caller holds a `secure` flag,
30
+ and nothing anywhere reads a transport off a URL scheme: a caller names the wire.
31
+
32
+ WebSocket and gRPC carry the session frames used by `positronic_model_server` and the legacy
33
+ `positronic.offboard` server. Both servers use transports from `positronic_model_server`.
34
+ `roboarena` carries a partner's own protocol. The partner serves it, and this package holds the
35
+ client end alone.
36
+
37
+ ## The package boundary
38
+
39
+ | Module | Holds |
40
+ |---|---|
41
+ | `positronic_wire.wire` | The routes (`API_PATH`, `SESSION_PATH`, `KEEPALIVE_PATH`), `ALIVE_SECONDS`, the key the keepalive answer carries, `MAX_MESSAGE_BYTES`, the addresses `HostPortAddress(host, port, path, query)` and `UnixSocketAddress(uds, path, query)` under the abstract `SessionAddress`, the type variable `AddressT` over them, `netloc`, `bracket_ipv6(host)`, `Refusal`, `ConnectRefused`, `PeerDisconnected`, `KeepaliveUnsupported`, and the abstract `ClientWire` and `ClientConnection` |
42
+ | `positronic_wire.websocket` | `WebsocketClientWire`, `WebsocketTlsClientWire`, `WebsocketUnixClientWire`, `WebsocketClientConnection`, `refusal_of(raised)`, which reads a failed handshake as a `Refusal`, and `connected_socket(host, port, timeout)`, a TCP connect whose timeout covers the name lookup and every address |
43
+ | `positronic_wire.grpc` | `GrpcClientWire`, `GrpcTlsClientWire`, `GrpcClientConnection`, `target(host, port)`, and the calls both ends agree on: `SERVICE`, `METHOD`, `METHOD_PATH`, `KEEPALIVE_METHOD`, `KEEPALIVE_METHOD_PATH`, `PROBE_PATH`, `SESSION_PATH_HEADER`, `SESSION_QUERY_HEADER`, `MESSAGE_SIZE_OPTIONS`, `PING_EVERY_MS` |
44
+ | `positronic_wire.roboarena` | `RoboarenaClientWire`, `RoboarenaClientConnection`, `RoboarenaAddress`, and `TextAnswer`, which a text frame raises. The handshake carries the headers the caller gives, and none where it gives none |
45
+ | `positronic_wire.registry` | `CLIENT_WIRES`, every installed member by its `NAME`, and `client_wire(name)` |
46
+
47
+ [`positronic_model_server`](../model_server/README.md) owns the server transports:
48
+ `server_wire.Wire`, `server_wire.ServerConnection`, `websocket_wire.WebsocketWire` and
49
+ `grpc_wire.GrpcWire`. It also owns `ModelServer`, shared message definitions and value serialization.
50
+ The WebSocket server uses Uvicorn directly; the gRPC server uses `grpcio`'s `grpc.aio` API.
51
+ Each server transport has its own optional installation extra.
52
+
53
+ `positronic.offboard` owns `InferenceClient`, the legacy `PolicyServer`, and v1/v2 robot-command
54
+ encoding. `positronic-wire` does not depend on either server package.
55
+
56
+ ## The client interface
57
+
58
+ A caller selects a wire by `NAME`: `websocket`, `websocket_tls`, `websocket_unix`, `grpc`, `grpc_tls`,
59
+ `roboarena`. A wire dials an address of its `ADDRESS` type, and `DEFAULT_PORT` names the port a URL
60
+ leaves out on the members that carry one.
61
+
62
+ - `session_url(address)` — the session as this wire names it, for a log and for an error. The
63
+ websocket members write `ws://` or `wss://`, `websocket_unix` writes `ws+unix://`, and the gRPC
64
+ members write `host:port`, and `roboarena` writes the root it dials. What a wire dials is its own:
65
+ `websocket`, `websocket_tls` and `roboarena` dial this spelling, and the others dial a socket or a
66
+ target instead.
67
+ - `dial(address, headers, open_timeout)` — a client's end of one session. It raises
68
+ `ConnectRefused` when the session does not open, whatever refused it. The `refusal` on the
69
+ exception says what the caller does next: `COLD` (a backend still starting) and `SILENT`
70
+ (nothing answered) retry, `FORBIDDEN` retries a few times, `FINAL` surfaces at once.
71
+ - `probe(address, headers, open_timeout)` — whether a server answers at the address, without opening
72
+ a session. It carries the same `headers` as `dial`, so an edge that authenticates on them lets the
73
+ probe through to the server behind it: the probe wakes what a session would reach. `None` when a
74
+ server answers; a `Refusal` says why none did, in the terms `dial` uses, `FORBIDDEN` among them for
75
+ a credential the edge refused. The websocket wire asks the host's root for an upgrade, which the
76
+ server refuses with 403 and nothing else answers 403 there. The gRPC wire calls `PROBE_PATH`,
77
+ which a server that is up answers `UNIMPLEMENTED`. The roboarena wire opens the root and reads the
78
+ frame the server announces itself with.
79
+ - `keepalive(address, headers, timeout)` — resets the server's idle timer outside a session, and
80
+ returns the seconds the server stays alive after the call, or `None` for a server with no idle
81
+ timeout. `timeout` bounds the whole call. The websocket members send `POST` to `KEEPALIVE_PATH`, and
82
+ the gRPC members call `KEEPALIVE_METHOD_PATH`. `keepalive` raises
83
+ `KeepaliveUnsupported` where the server serves sessions but not the call, and `ConnectRefused`
84
+ otherwise, in the terms `probe` uses: a 200 that does not carry the keepalive answer is `FINAL`.
85
+ `roboarena` always raises `KeepaliveUnsupported`, and dials nothing.
86
+
87
+ `registry.client_wire(name)` selects an installed transport. `CLIENT_WIRES` includes only families
88
+ whose libraries are installed; requesting an unavailable transport raises with installation guidance.
89
+ Use `[websocket]` for WebSocket and Roboarena, `[grpc]` for gRPC, or `[websocket,grpc]` for all wires.
90
+ Importing the core registry without either extra produces an empty registry. Broken imports from an
91
+ installed transport surface as errors.
92
+
93
+ **Each wire declares the address it dials, and takes no other.** `ClientWire.ADDRESS` names that
94
+ type, and every verb above takes it. `websocket`, `websocket_tls`, `grpc` and `grpc_tls` take a
95
+ `HostPortAddress` — `host`, `port`, `path` (`SESSION_PATH`), `query`, as written.
96
+ `websocket_unix` takes a `UnixSocketAddress` — `uds`, `path`, `query`. `roboarena` takes a
97
+ `RoboarenaAddress` — `host` and `port`. A record that names an endpoint carries the wire's name and
98
+ that wire's fields, never a URL, and no address carries a field a wire ignores.
99
+
100
+ `uds` is an absolute path to a Unix socket a server on the same machine bound, dialled instead of
101
+ the network. The address refuses a relative path when it is built, because a relative one names a
102
+ different socket to each caller. It names no host and no port, because a socket has neither: the
103
+ handshake carries `localhost` as a stand-in the server never resolves. A socket is same-machine by
104
+ construction, so no TLS member sits beside it. An absent path is `SILENT`: the client cannot tell a
105
+ misspelt path from a socket nobody has bound yet, so it retries either to its deadline. A refusal
106
+ from a socket a server is restarting on is `SILENT` too. A path holding something that is not a
107
+ socket, and a refused permission, are `FINAL`, because no retry reaches them. A handshake that timed
108
+ out or was reset reached the socket, so the server rather than the path was not ready, and it reads
109
+ `SILENT` as it does on a port.
110
+
111
+ `roboarena` is a partner's own protocol: msgpack frames on a websocket at the bare root of a port the
112
+ partner publishes. The server closes any other path, and it routes on a key inside each frame, so the
113
+ address names no route and no query. It publishes no default port either, so every address states one.
114
+ The server announces its configuration as the first frame of every connection: `dial` leaves that frame
115
+ for the caller's codec, and `probe` reads it and closes. A server that upgrades the connection and
116
+ announces nothing is a backend still starting, so it reads `COLD`. The server reports a failure in a
117
+ text frame and serves nothing more on that connection, so `recv` and `probe` raise `TextAnswer`, which
118
+ carries the text: a retry does not change it. The protocol names no URL scheme and the port is plain,
119
+ so no TLS member sits beside this wire; a partner who terminates TLS in front of it refuses a handshake
120
+ in the terms the websocket members already read.
121
+
122
+ Typing carries the split: a wire handed the other wire's address is a type error at the call site.
123
+ `registry.client_wire(name)` answers by name and cannot, so `InferenceClient` checks `ADDRESS` once,
124
+ before it dials, and names both in the refusal.
125
+
126
+ ## Whether a policy server is up
127
+
128
+ `keepalive` answers it, and `probe` answers it where a server serves no keepalive call. Each one's
129
+ timeout is one wall-clock deadline over the name lookup, every address the name resolves to, and every
130
+ read, so a server the caller did not write cannot hold the call past it. Both open their own socket, so
131
+ they ignore a proxy the environment names, where `dial` goes through it.
132
+
133
+ | What came back | Means |
134
+ |---|---|
135
+ | `keepalive` returns | a policy server admitted the headers |
136
+ | `KeepaliveUnsupported`, then `probe` returns `None` | a server answers and serves no keepalive call |
137
+ | `FORBIDDEN` | an answer that refused the credential |
138
+ | `COLD` | an answer that asks for a retry: a 5xx, a 429 |
139
+ | `FINAL` | an answer no retry changes: any other status, a 200 without the keepalive answer, a host with no address |
140
+ | `SILENT` | no answer: no connection, or none before the timeout or the close |
141
+
142
+ `COLD` and `SILENT` mean the server is not up yet. `roboarena` raises `KeepaliveUnsupported` without a
143
+ dial, and its `probe` raises `TextAnswer` where the server announces a failure. The gRPC members read
144
+ `SILENT` off the details gRPC writes for a connect nothing answered, and off `DEADLINE_EXCEEDED`.
145
+
146
+ The libraries cap what a call reads: `websockets` reads at most 128 headers of 8 KiB and 1 MiB of a
147
+ refused upgrade's body, and `http.client` at most 100 headers of 64 KiB. The websocket members read at
148
+ most 16 KiB of a keepalive body, and the roboarena member one frame of at most `MAX_MESSAGE_BYTES`.
149
+
150
+ ## What each consumer pays
151
+
152
+ | Consumer | Needs | Installs |
153
+ |---|---|---|
154
+ | A rig client, and `positronic` itself | Every verb, the session protocol, the policy stack | `positronic`, which pins `positronic-wire` exactly |
155
+ | A coordinator that probes an endpoint and warms it | `registry.client_wire`, `probe`, `PROBE_PATH`, the routes | `positronic-wire[websocket,grpc]`, or just the required transport extra |
156
+ | A service that validates an endpoint record | `registry.CLIENT_WIRES` | `positronic-wire` with the transport extras it accepts |
157
+ | A host that runs a policy server and waits for it before it sends sessions | `keepalive`, `probe` | `positronic-wire[websocket,grpc]`, or just the required transport extra |
158
+ | A native model server | The shared server and its transports | `positronic-model-server[websocket,grpc]`, or just the required transport extra |
159
+ | A legacy policy server | `positronic.offboard.server.PolicyServer` | `positronic` |
160
+
161
+ A consumer whose lockfile already carries `grpcio` (through a cloud SDK) and `websockets` (through
162
+ `uvicorn`) adds no third-party package when it adds this one.
163
+
164
+ ## What the wire keeps out of its consumers
165
+
166
+ A transport-specific dial disappears from a consumer once it speaks to the wire. A consumer that
167
+ opens a `websockets.connect` of its own to wake an endpoint has to skip every gRPC endpoint, because
168
+ the same call cannot reach one; `probe` reaches both. A consumer that classifies a failed dial by
169
+ matching library exception names over the raised type's bases has to list `grpc.RpcError`,
170
+ `websockets.*` and `httpx.*`; `dial` raises one `ConnectRefused` for all of them, so `isinstance`
171
+ answers.
172
+
173
+ A copied fact disappears too. The gRPC probe path, the session route and the keepalive route are each
174
+ one symbol here, and the set of wires is `registry.CLIENT_WIRES`. A literal spelled a second time
175
+ in another repository drifts the day either side edits it, and nothing reports the drift; an
176
+ imported symbol cannot.
177
+
178
+ A scheme table disappears with them. A record that names an endpoint carries the wire's name and the
179
+ fields that wire's address declares, so no reader of the record derives a transport, a TLS setting or
180
+ a default port from the spelling of a URL.
181
+
182
+ The port a server serves a wire on, and the server flag that names it, stay literals where they are
183
+ spelled: they are a deployment's configuration, not wire facts. A test in the consumer that installs
184
+ `positronic` pins the flag name against the server's own signature.
185
+
186
+ ## Versioning and skew
187
+
188
+ `positronic` pins `positronic-wire==<version>` exactly, as it pins `positronic-platform-client`.
189
+ `utilities/check_workspace_version_bump.py` refuses a change under `wire/` that reuses the version,
190
+ and one that leaves the root's pin behind. The release workflow publishes `wire/` to the index
191
+ before `positronic`, with `skip-existing`, so an unchanged wire is a no-op and a changed one is on
192
+ the index before the release that depends on it.
193
+
194
+ A consumer outside this repository pins the wire the way it pins the client: by the index once the
195
+ wire is published there, and until then by git subdirectory at a commit
196
+ (`positronic-wire @ git+…positronic@<sha>#subdirectory=wire`). A consumer that also installs
197
+ `positronic` at a commit names the same commit for both, so the wire the git install provides is
198
+ the version `positronic` requires.
199
+
200
+ Two deployments of one consumer can hold different wire versions: a rig runs a pinned console on
201
+ its own schedule while a coordinator runs its checkout. The wire keeps that skew readable instead
202
+ of guarding it with a version floor. Each side reports the names its registry lists, and the side
203
+ that hands an endpoint record across refuses a wire name the other side's registry lacks. A
204
+ transport added to the wire is then one entry in `registry.CLIENT_WIRES`, and no consumer has to
205
+ raise a constant naming the version that first dialled it.
206
+
207
+ ## Adopting the wire in a consumer
208
+
209
+ A consumer moves onto the wire in this order, each step green on its own:
210
+
211
+ 1. Depend on `positronic-wire` with the required transport extras. Import the routes, the probe path and the registry; delete the
212
+ local copies, the tables keyed by scheme, and every read of a URL scheme. **An endpoint record
213
+ names its wire, then that wire's address** — the fields `ClientWire.ADDRESS` declares, which differ
214
+ per wire. Report the registry's names in the deploy handshake and retire any per-transport version
215
+ floor.
216
+ 2. Replace the transport-specific dial with `probe`, and the exception-name match with
217
+ `isinstance(raised, (wire.ConnectRefused, wire.PeerDisconnected, TimeoutError))`.
218
+ 3. Move the consumer's own transports into their own `ClientWire` subclasses, each declaring the
219
+ address it dials, registered by name beside the wire's own.
220
+ 4. Deploy the pinned side at the commit that carries the wire, then the checkout side.
221
+
222
+ ## What is not shared
223
+
224
+ - Server transports live in `positronic-model-server` and use Uvicorn or `grpc.aio`.
225
+ - Model message definitions and value serialization live in `positronic-model-server`. The policy
226
+ stack and robotics codecs live in `positronic`. Neither is a dependency of `positronic-wire`.
227
+ - A transport this package does not implement. A consumer that owns one writes it as a `ClientWire`
228
+ of its own.