ag2-relay-client 0.1.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.
Files changed (44) hide show
  1. ag2_relay_client-0.1.0/PKG-INFO +402 -0
  2. ag2_relay_client-0.1.0/README.md +393 -0
  3. ag2_relay_client-0.1.0/ag2_relay_client/__init__.py +30 -0
  4. ag2_relay_client-0.1.0/ag2_relay_client/backoff.py +47 -0
  5. ag2_relay_client-0.1.0/ag2_relay_client/client.py +1517 -0
  6. ag2_relay_client-0.1.0/ag2_relay_client/credentials.py +414 -0
  7. ag2_relay_client-0.1.0/ag2_relay_client/egress.py +490 -0
  8. ag2_relay_client-0.1.0/ag2_relay_client/envelope.py +293 -0
  9. ag2_relay_client-0.1.0/ag2_relay_client/events.py +783 -0
  10. ag2_relay_client-0.1.0/ag2_relay_client/journal.py +521 -0
  11. ag2_relay_client-0.1.0/ag2_relay_client/markers.py +380 -0
  12. ag2_relay_client-0.1.0/ag2_relay_client/media.py +667 -0
  13. ag2_relay_client-0.1.0/ag2_relay_client/outbound.py +303 -0
  14. ag2_relay_client-0.1.0/ag2_relay_client/resolver.py +181 -0
  15. ag2_relay_client-0.1.0/ag2_relay_client/roomops.py +510 -0
  16. ag2_relay_client-0.1.0/ag2_relay_client/singleton.py +575 -0
  17. ag2_relay_client-0.1.0/ag2_relay_client/state.py +237 -0
  18. ag2_relay_client-0.1.0/ag2_relay_client/status.py +176 -0
  19. ag2_relay_client-0.1.0/ag2_relay_client/transport.py +498 -0
  20. ag2_relay_client-0.1.0/ag2_relay_client.egg-info/PKG-INFO +402 -0
  21. ag2_relay_client-0.1.0/ag2_relay_client.egg-info/SOURCES.txt +42 -0
  22. ag2_relay_client-0.1.0/ag2_relay_client.egg-info/dependency_links.txt +1 -0
  23. ag2_relay_client-0.1.0/ag2_relay_client.egg-info/top_level.txt +1 -0
  24. ag2_relay_client-0.1.0/pyproject.toml +27 -0
  25. ag2_relay_client-0.1.0/setup.cfg +4 -0
  26. ag2_relay_client-0.1.0/tests/test_auth_recovery.py +181 -0
  27. ag2_relay_client-0.1.0/tests/test_backoff.py +52 -0
  28. ag2_relay_client-0.1.0/tests/test_credentials.py +392 -0
  29. ag2_relay_client-0.1.0/tests/test_egress.py +451 -0
  30. ag2_relay_client-0.1.0/tests/test_envelope.py +218 -0
  31. ag2_relay_client-0.1.0/tests/test_events.py +1152 -0
  32. ag2_relay_client-0.1.0/tests/test_heartbeat.py +261 -0
  33. ag2_relay_client-0.1.0/tests/test_journal.py +251 -0
  34. ag2_relay_client-0.1.0/tests/test_markers.py +260 -0
  35. ag2_relay_client-0.1.0/tests/test_media.py +522 -0
  36. ag2_relay_client-0.1.0/tests/test_outbound.py +324 -0
  37. ag2_relay_client-0.1.0/tests/test_packaging.py +112 -0
  38. ag2_relay_client-0.1.0/tests/test_resolver.py +220 -0
  39. ag2_relay_client-0.1.0/tests/test_roomops.py +435 -0
  40. ag2_relay_client-0.1.0/tests/test_singleton.py +655 -0
  41. ag2_relay_client-0.1.0/tests/test_state.py +179 -0
  42. ag2_relay_client-0.1.0/tests/test_status.py +115 -0
  43. ag2_relay_client-0.1.0/tests/test_transport.py +229 -0
  44. ag2_relay_client-0.1.0/tests/test_wire_loop.py +980 -0
@@ -0,0 +1,402 @@
1
+ Metadata-Version: 2.4
2
+ Name: ag2-relay-client
3
+ Version: 0.1.0
4
+ Summary: The AG2 Space relay wire, as a library: poll, lease, ack, results, heartbeat, media, Room Ops.
5
+ License: MIT
6
+ Keywords: ag2space,relay,broker,agent,matrix
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # ag2-relay-client
11
+
12
+ The AG2 Space relay wire, as a library: one bearer's whole conversation with the
13
+ broker — poll, lease, ack, results, heartbeat, media in both directions, Room
14
+ Ops — behind one seam. A consumer takes Tasks out of an in-memory queue and
15
+ hands answers back through `complete` / `reject`; everything that touches the
16
+ wire, including resolving media markers to local files, stays on this side.
17
+
18
+ It exists because that conversation had been implemented twice, in two
19
+ processes, from two repos — and the operational lessons (a User-Agent the edge
20
+ accepts, a DNS call that cannot hang forever, a token parse that survives a
21
+ URL-encoded separator) had to be learned twice too. They live here once now.
22
+
23
+ ## Promises
24
+
25
+ - **stdlib-only.** No dependency arrives with a transport, ever.
26
+ - **Python >= 3.9**, sync and threaded, **no asyncio**. An asyncio consumer
27
+ wraps the calls in an executor; a sync one calls them directly.
28
+ - **No compiled-in gateway.** The base URL is discovered at provisioning time —
29
+ it travels inside the onboarding token or alongside it.
30
+
31
+ ## Status
32
+
33
+ Under construction. What ships today is the whole task path — poll, lease,
34
+ journal, ack, results, heartbeat, auth recovery, status — media in both
35
+ directions, with inbound markers resolved to local files before delivery — the
36
+ whole outbound half — Room Ops, the egress allowlist and the result-marker
37
+ grammar — the singleton-per-bearer guard, and the optional events channel. All
38
+ of it behind the seam below; nothing from the ticket list is outstanding here.
39
+
40
+ ## The seam
41
+
42
+ ```python
43
+ from ag2_relay_client import RelayClient, TokenSource
44
+
45
+ client = RelayClient(TokenSource(token_file="~/.ag2/relay.env"),
46
+ state_dir="~/.ag2/state", instance="prod",
47
+ egress_roots=[workspace]) # the only files it may send
48
+ client.start()
49
+
50
+ task = client.next_task(timeout=30) # or read client.tasks yourself
51
+ if task:
52
+ client.complete(task.id, my_agent.handle(task.body))
53
+ ```
54
+
55
+ Two things wide, and that is the whole consumer surface: Tasks come out of an
56
+ in-memory queue, answers go back through `complete` — or `reject(id, reason)`,
57
+ which dead-letters a permanently malformed task instead of letting it re-serve
58
+ five times.
59
+
60
+ `complete` is not a POST with extra steps. It reads the result-marker grammar
61
+ (below), uploads any file the body names **from an allowlisted path**, appends a
62
+ sentence about any it refused, re-stitches a `[channel:]` redirect for the
63
+ broker's deliverer, and only then journals and POSTs. So a consumer hands over
64
+ the text its agent wrote and never touches a marker, a path this library has not
65
+ judged, or the wire. `base_dir=` is what a relative path in a marker is read
66
+ against; it widens nothing.
67
+
68
+ The queue is a **handoff, not durability**. What survives a restart is the
69
+ journal under the state dir, and the promise attached to it is worth stating
70
+ plainly:
71
+
72
+ - A task the client has already answered is **re-completed upstream, never
73
+ handed to the consumer twice** — a reconnect replays the broker's unacked
74
+ pool, and the replays that prompted this were 500 tasks each.
75
+ - An answer is **retained until the broker takes it**. A failed POST, a killed
76
+ process: the answer is on disk and goes out on a later pass, under the same
77
+ id, which the broker dedups.
78
+ - A blank answer is refused. "Nothing to say" is `[no-send]`, which completes
79
+ the lease and posts nothing; a blank body means "not ready", and delivering it
80
+ would archive the task and strand the real answer.
81
+
82
+ Everything the loop knows about itself is readable while it runs:
83
+ `client.snapshot()` for a thread-safe copy, `client.on_status(hook)` for a
84
+ callback on every change, and a connection-only `connection-status.json` under
85
+ the state dir so a supervisor can read it whether or not the consumer bothered
86
+ to. A hook that raises is the hook's problem: nothing beside the wire is allowed
87
+ to block delivery.
88
+
89
+ ## What the loop guarantees, and why it is shaped like this
90
+
91
+ Polling cadence is a correctness property. The broker extends a lease only while
92
+ the worker keeps polling; a worker that stops has its in-flight tasks re-served.
93
+ So nothing in the loop blocks unboundedly — the long poll is `wait=25` with a
94
+ socket timeout of `wait+10`, every side call is bounded, the Task queue is
95
+ unbounded so a slow consumer cannot stall the poll thread, and status writes,
96
+ hooks and logs all fail silently by contract. Neither of the two unbounded
97
+ *lists* the loop meets can stretch a turn either: the owed-result queue and a
98
+ poll answer (the broker drains its whole queue into one) each get a wall-clock
99
+ slice of the turn, and what does not fit waits for the next one. Health is asked
100
+ of `client.healthz()`; `GET /v1/tasks` is never a probe, because it leases tasks.
101
+
102
+ The ordering inside one accepted task is journal, then delivery, then ack. The
103
+ ack does not gate the handoff — but it is **not** informational, whatever
104
+ `WORKER-PROTOCOL.md` says. Against this broker, liveness alone extends an
105
+ un-acked lease twice and then requeues it with `attempt` bumped, and a re-serve
106
+ starts un-acked again, so the ack is what buys a long Turn its lease. A re-served
107
+ id the consumer still holds is therefore re-acked — never re-delivered — and a
108
+ pause on acking is written into the status file as the delivery outage it is.
109
+ Its 404 is content-sniffed, because `not leased to you` is one task's expired
110
+ lease and a bare 404 is a broker without the route, and treating the first as the
111
+ second once blinded a whole host's delivery state.
112
+
113
+ A result that the broker will not take costs its own id a backoff and costs the
114
+ answers behind it nothing: a `break` there put the poisoned answer permanently at
115
+ the head of an oldest-first queue, with everything behind it durable, owed and
116
+ never sent.
117
+
118
+ A rejected bearer is a wait, not a death: the durable token source is re-read at
119
+ once (a rotation may already have happened), and otherwise the loop holds at a
120
+ slow cadence, saying so every pass, until one lands — then resumes live, no
121
+ restart. A token file that starts naming a *different* gateway is a
122
+ reconfiguration and is refused.
123
+
124
+ ## Receiving a file
125
+
126
+ The broker sends **no attachments field**. Media rides inside the task text as a
127
+ marker — `[ag2space-media: <url> mime= name= size= kind=]` — and a client that
128
+ waits for a header waits for ever. The library resolves it before the Task is
129
+ delivered, so a consumer reads paths:
130
+
131
+ ```python
132
+ client = RelayClient(creds, state_dir="~/.ag2/state",
133
+ media_dir="~/.ag2/media") # optional; state dir by default
134
+
135
+ task = client.next_task(timeout=30)
136
+ for got in task.attachments: # 0..N, already resolved
137
+ if got.ok:
138
+ my_agent.read(got.path, got.mime) # a local file, on disk
139
+ else:
140
+ say(f"I couldn't read {got.name or 'the attachment'}: {got.reason}")
141
+ ```
142
+
143
+ **The consumer never sees a marker or a URL** — not on a success, not on a
144
+ failure, not when the URL was nonsense. What crosses the seam is a path or a
145
+ reason.
146
+
147
+ - **The mime comes from the fetch's `Content-Type`.** The marker's is a hint,
148
+ and so are `name` and `kind`: nothing in the marker is escaped, so a filename
149
+ with a space truncates `name=` at the space and one with a `]` truncates the
150
+ whole marker. `name` is never used to name the file on disk.
151
+ - **A failed fetch never blocks the Task.** One budgeted retry, then the Task is
152
+ delivered with that attachment marked failed and the reason carried. It is not
153
+ held — the gateway's media route answers `502` for *every* cause, membership
154
+ refusals included, so waiting for a good answer can wait for ever — and it is
155
+ not auto-rejected, because an agent that can say "I couldn't read that" is
156
+ more use than a dead-lettered task.
157
+ - **The fetch runs off the poll thread.** Cadence is correctness, and a 25 MiB
158
+ download on the poll thread is a stalled loop. A task with nothing to fetch is
159
+ never queued behind one that has; a task with media is delivered when its
160
+ bytes are.
161
+ - **The bearer goes only to the gateway's own URLs**: exact parsed origin, at or
162
+ under the base path with a real `/` boundary — `relay.example.evil` and
163
+ `/relay-evil/` are both a no. No redirect is followed, so a gateway-written URL
164
+ cannot bounce this bearer to a third-party host; `cap + 1` bytes are read
165
+ against a 25 MiB ceiling, so a lying `Content-Length` cannot OOM anything; and
166
+ a URL that will not parse is a failed attachment, never an exception out of
167
+ task intake.
168
+
169
+ Fetched files are **deleted when the task is answered**, and anything an earlier
170
+ run left behind is swept when the next one starts — those Tasks were sitting in
171
+ an in-memory queue, so no live task can claim their bytes. A consumer whose own
172
+ archives reference the paths opts out at construction with
173
+ `media_retention_s=<seconds>`, which keeps the files and sweeps only what has
174
+ aged out.
175
+
176
+ **The media directory is not auto-allowlisted for egress.** A consumer that
177
+ wants to send back what arrived adds it as an explicit root, so all egress
178
+ policy stays in one place a reviewer can read: the `egress_roots` list on the
179
+ `RelayClient` constructor, and nothing else. There is no environment variable
180
+ below this line, no default root, and no way to add one after construction.
181
+
182
+ ## One poller per bearer
183
+
184
+ A bearer's queue tolerates exactly one concurrent poller. The broker does not
185
+ detect a second one and does not reject it — two clients simply split the lease
186
+ stream and **deliver every task twice**. The guarantee therefore cannot come
187
+ from the wire, and it is not left to the consumer either: every client arbitrates
188
+ for the bearer through a lock file in its own state dir, and nobody has to
189
+ remember to switch it on.
190
+
191
+ ```python
192
+ client.snapshot()["singleton"] # "held" | "lost" | "degraded" | "off"
193
+ ```
194
+
195
+ Four properties, and each one is an incident:
196
+
197
+ - **Atomic acquire.** Two clients starting in the same millisecond produce one
198
+ winner, because the whole decision — read, judge, write — happens under an
199
+ exclusive lock on the guard file.
200
+ - **Liveness is heartbeat freshness, never pid-alive.** The holder re-stamps the
201
+ record wherever its poll loop demonstrably got to — at every phase boundary of
202
+ a turn, and through the wait between turns — so the freshness window bounds the
203
+ longest single call the loop can be *inside*, not the sum of the calls it
204
+ makes. A holder that stops turning loses the guard after 150 s, however alive
205
+ its pid may be; a holder that is merely slow never does. The ghost that prompted this
206
+ was alive-but-stale for *days*, and pid recycling makes "is that pid running?"
207
+ a question about somebody else's process. There is no `os.kill` in the guard,
208
+ and a test asserts there never will be.
209
+ - **A definitive loser stops immediately.** A client that never held the guard
210
+ stands by and keeps asking — so a holder that dies without releasing is taken
211
+ over from with nobody in the loop — while a client whose claim is *taken* stops
212
+ polling on that same turn and stays stopped, saying `displaced` in its status.
213
+ Coming back is the consumer's decision, never the loop's: `start()` re-enters
214
+ the arbitration as a standby, so a client that was displaced comes back behind
215
+ whoever took the bearer rather than alongside them.
216
+ - **It fails open.** An I/O error, a filesystem without locking, a bug in the
217
+ guard: all of them mean *poll anyway*, loudly logged and visible as
218
+ `"degraded"` in the status. A lock that fails closed silences delivery
219
+ entirely, and the worst case on the other side is the doubled message the
220
+ guard was there to prevent in the first place.
221
+
222
+ A clean `stop()` releases the guard, so a restart takes it back at once rather
223
+ than waiting out a freshness window — but only when its join actually joined. A
224
+ `stop()` whose join times out leaves the guard held and says so: releasing a
225
+ bearer this client may still be inside a poll for is the dual poller the
226
+ ordering exists to prevent. `RelayClient(..., singleton=False)` turns
227
+ the whole thing off for a consumer that has its own singleton mechanism; it logs
228
+ a warning, because nothing else here prevents a second poller.
229
+
230
+ ## Credentials
231
+
232
+ The onboarding string is the combined `<url>|<secret>` form — the gateway
233
+ travels inside the credential. The separator may arrive URL-encoded (`%7C`),
234
+ which is what a desktop connect flow writes:
235
+
236
+ ```python
237
+ from ag2_relay_client.credentials import TokenSource
238
+
239
+ creds = TokenSource(token_file="~/.ag2/relay.env") # or token="https://gw/relay|SECRET"
240
+ creds.base_url # "https://gw/relay"
241
+ creds.source # names the file, never the value
242
+ creds.reload() # picks up a rotated secret; refuses a changed gateway
243
+ ```
244
+
245
+ A rotation changes the secret and nothing else. A token source that starts
246
+ naming a *different* gateway is a reconfiguration, not a rotation: the swap is
247
+ refused and logged, and the client keeps running where it is. Restart to move
248
+ gateways.
249
+
250
+ Nothing is guessed. There is no bare-home fallback path, because that is the one
251
+ lookup that can silently bind the wrong identity after a reinstall or an account
252
+ switch.
253
+
254
+ ## Requests
255
+
256
+ ```python
257
+ from ag2_relay_client.transport import RelayHTTP
258
+
259
+ http = RelayHTTP(creds)
260
+ http.get("/v1/tasks", params={"wait": 25}, timeout=35)
261
+ http.post("/v1/results", {"id": "task-1", "body": "done"})
262
+ ```
263
+
264
+ Every request carries an explicit `User-Agent` (the edge rejects urllib's
265
+ default), reads the bearer live so a rotation needs no restart, refuses
266
+ redirects while credentialed, and opens its socket through a resolver whose DNS
267
+ is wall-clock-bounded, single-flight, and IPv4-first — per client, with nothing
268
+ patched process-wide. `401`/`403` raise `AuthRejected`, which best-effort
269
+ callers must not swallow; other unusable answers raise `RelayHTTPError`,
270
+ carrying the status and the body for the call sites that have to read it.
271
+
272
+ ## State
273
+
274
+ ```python
275
+ from ag2_relay_client.state import StateLayout
276
+
277
+ layout = StateLayout("~/.ag2/state", instance="prod")
278
+ layout.ensure() # 0700; holds the journal, the status file, the lock
279
+ ```
280
+
281
+ Per-instance by construction: one host may run clients against several gateways,
282
+ and broker task ids are unique only *within* a gateway.
283
+
284
+ ## Room Ops
285
+
286
+ Speaking in a room *as* the agent identity — post, edit, react, upload:
287
+
288
+ ```python
289
+ rooms = client.room_ops # built from `egress_roots`
290
+ event = rooms.message(room_id, "⏳ On it...") # keep the id; the ladder needs it
291
+ rooms.edit(room_id, event, answer) # ...then complete with [REPLIED]
292
+ ```
293
+
294
+ `client.room_ops` is read-only and holds no reachable bearer: four ops, and the
295
+ same allowlist `complete` uploads through — not a second door for a file.
296
+
297
+ **Nothing here raises into your loop.** A room that cannot be spoken to is a
298
+ room whose answer arrives the plain way, through `POST /v1/results`; losing an
299
+ answer to a decoration is never acceptable. A failure answers `None` / `False`
300
+ and marks room ops unavailable for a **time-gated** cooldown (~300 s) — long
301
+ enough that a broken broker is not retried per task, short enough that it heals
302
+ itself after a broker deploy without a restart. A `401`/`403` still reaches the
303
+ `on_auth_rejected` hook, because a revoked bearer is not a cosmetic failure.
304
+
305
+ An `op:edit` body over 4000 characters is refused *locally*, without spending
306
+ the cooldown: the reply goes through `/v1/results`, whose render path chunks it.
307
+ And this client never reacts to a message it was served — the broker places the
308
+ intake reaction, and a second one is the room seeing double.
309
+
310
+ ## Sending a file
311
+
312
+ Egress is **paths only**, and the roots are fixed when the client is built:
313
+
314
+ ```python
315
+ client = RelayClient(creds, state_dir=..., egress_roots=[workspace],
316
+ egress_max_bytes=24 * 1024 * 1024)
317
+ ```
318
+
319
+ A client built with no roots sends no files, and says so in the answer rather
320
+ than in a log — the fail-closed reading of "nobody configured this", and the
321
+ only safe one.
322
+
323
+ **There is no bytes-upload API, public or private.** One exists nowhere in this
324
+ package on purpose: a surface that took bytes would make the allowlist
325
+ decorative, since a caller could read anything and hand the contents over. The
326
+ only door is `EgressAllowlist.open(path)`, which returns a descriptor it has
327
+ already judged — resolved, inside a root by a real path-separator boundary,
328
+ opened one component at a time with `O_NOFOLLOW` so a symlink swapped in after
329
+ the check cannot be followed, and `fstat`'d for regular-file-ness, size, and a
330
+ single hard link. There is no method that widens an allowlist at runtime, and
331
+ the object refuses attribute writes after construction.
332
+
333
+ The check now runs in the same process that holds the bearer, where it used to
334
+ run in a separate one. `egress.py`'s docstring records that regression honestly;
335
+ read it before changing anything there, and read `tests/test_egress.py` as the
336
+ threat model it mitigates.
337
+
338
+ ## Result markers
339
+
340
+ One parser, because every copy of it drifted — and a marker one consumer
341
+ stripped reached users through another as literal text:
342
+
343
+ `client.complete` runs it; there is nothing to wire up and no second parser to
344
+ write:
345
+
346
+ ```python
347
+ prepared = client.complete(task.id, agent_output, base_dir=where_it_ran)
348
+ prepared.uploaded # ("chart.png",)
349
+ prepared.refused # ("[attachment not sent: … (…)]",)
350
+ ```
351
+
352
+ `prepare` reads the grammar (skip is terminal; `[dm-only]` is detected anywhere
353
+ and suppresses a `[channel:]` redirect; stripping is narrower than detection so
354
+ prose *discussing* a marker is not rewritten; a marker inside markdown code is
355
+ being shown, not issued), uploads whatever the body named, and hands back the
356
+ body to POST — with `[channel:]` re-stitched for the broker's deliverer and any
357
+ refused file explained in-band as `[attachment not sent: …]`.
358
+
359
+ Two properties are worth stating out loud:
360
+
361
+ - `[no-send]` / `[REPLIED]` / `[deduped: <id>]` **still POST**. They complete
362
+ the lease with no user-visible message; skipping the POST as well leaves the
363
+ lease to expire and the task to be re-served for ever.
364
+ - A **retried result POST uploads nothing more**. The body is re-derived from
365
+ the same markers and the `(task, path)` ledger answers for the second pass;
366
+ the ledger is retired by the POST that finally succeeds, because only success
367
+ may retire one (F5).
368
+
369
+ ## Events (optional, off unless asked for)
370
+
371
+ A Server-Sent Events channel on `/v1/events/stream`, for a consumer that wants
372
+ workspace events durably. Nothing in this library starts it and no module here
373
+ imports it — the only way one exists is this call:
374
+
375
+ ```python
376
+ from ag2_relay_client.events import events
377
+
378
+ channel = events(http, sink) # starts its own thread
379
+ channel.health() # {"status": "connected", "last_cursor": 41, ...}
380
+ channel.stop()
381
+ ```
382
+
383
+ `sink` is the consumer's durable store, duck-typed on two methods:
384
+ `durable_cursor()` returns the cursor of the last event that is durable, and
385
+ `commit(event)` **returning** is what makes one durable. Resume uses the durable
386
+ cursor, so a crash between "received" and "durable" replays the event rather
387
+ than losing it — the sink must be idempotent.
388
+
389
+ The channel is **isolated by construction**: its own thread, connection, backoff
390
+ and cursor, and a loop that swallows everything. A dead network, a revoked
391
+ bearer, a garbled frame, a sink that raises — none of them can reach task
392
+ delivery, which is the whole reason the module is shaped this way. Beyond that:
393
+ a 120 s read timeout, because a black-holed TCP path never says anything;
394
+ unusable frames skipped and not resumed from, because handing one to a sink that
395
+ raises means replaying it forever; `404` fatal and `401`/`403` retryable only
396
+ while a durable token source could still deliver a rotation. The bearer is read
397
+ through the same credential the poll loop rotates, so a rotation reaches the
398
+ stream on its next connect without a restart.
399
+
400
+ ## License
401
+
402
+ MIT.