muse-code-sdk 1.3.0__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.
@@ -0,0 +1,440 @@
1
+ """``TurnHandle`` — one turn's view of the fold (spec 638 FR-638-019a,
2
+ INV-638-04 carrying spec 14990 INV-014; tdd SS3.1.4). Port of
3
+ ``clients/sdk-ts/src/facade/turn-handle.ts``.
4
+
5
+ A handle owns two things: the SS3.1.4 turn-wait, and the async iterators that
6
+ ride the fold for this turn. It authors no wire traffic and holds no durable
7
+ state — :class:`~muse_code.facade.session.Session` feeds it folded events and
8
+ it fans them out.
9
+
10
+ INV-006 governs the wait: the SDK never locally times out, fails, or completes
11
+ a turn. Every settlement below traces to a server-authored fact, with one
12
+ sanctioned exception that is not a terminal at all — terminal-unknown after an
13
+ ephemeral host death, which SS2.13.3b makes a client MUST ("stop waiting for a
14
+ terminal") and which stays an annotation rather than a synthesized event.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from dataclasses import dataclass
21
+ from typing import (
22
+ AsyncIterator,
23
+ Awaitable,
24
+ Callable,
25
+ Generic,
26
+ List,
27
+ Literal,
28
+ Protocol,
29
+ Set,
30
+ TypeVar,
31
+ Union,
32
+ runtime_checkable,
33
+ )
34
+
35
+ from muse_code_msp import (
36
+ TURN_ERROR_KIND_KNOWN_VALUES,
37
+ TURN_TERMINAL_KNOWN_VALUES,
38
+ Item,
39
+ ItemDeltaParams,
40
+ TurnCompletedParams,
41
+ TurnUnqueuedParams,
42
+ )
43
+
44
+ _T = TypeVar("_T")
45
+
46
+ FoldedItem = Item
47
+ """What the iterators yield: the fold's stored item.
48
+
49
+ The TS twin seals this with ``DeepReadonly<Item>`` so a consumer cannot write
50
+ ``item["revision"] = 0`` through the read surface. Python has no structural
51
+ readonly; the fold's own read views already refuse mutation, and the items
52
+ yielded here are the same generated dicts the fold holds (INV-638-02).
53
+ """
54
+
55
+ # Read from the generated KNOWN_VALUES so a typo is caught by the codegen
56
+ # contract rather than compiling green against an open ``str`` enum — the
57
+ # runtime twin of the TS closed-vocabulary ``Extract<…>`` bind.
58
+ _TERMINAL_FAILED = "failed"
59
+ _LAUNCH_ERROR = "launchError"
60
+ assert _TERMINAL_FAILED in TURN_TERMINAL_KNOWN_VALUES
61
+ assert _LAUNCH_ERROR in TURN_ERROR_KIND_KNOWN_VALUES
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class _Completed:
66
+ """The ordinary terminal, AND the launch-failure exit.
67
+
68
+ ``deferred_start_failed`` is terminal ``failed``, ``error.kind:
69
+ launchError``, no preceding ``turn/started`` — :func:`is_launch_failure`
70
+ reads that WIRE marker; ``observed_start`` does not discriminate them,
71
+ because a terminal-first fold is legitimate for turns that really ran.
72
+ """
73
+
74
+ params: TurnCompletedParams
75
+ observed_start: bool
76
+ kind: Literal["completed"] = "completed"
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class _Unqueued:
81
+ """The reclaim (SS3.6). No ``turn/completed`` will ever carry this
82
+ ``turnId``, so a wait folding only ``turn/completed`` hangs forever."""
83
+
84
+ params: TurnUnqueuedParams
85
+ kind: Literal["unqueued"] = "unqueued"
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class _TerminalUnknown:
90
+ """SS2.13.3b: an ephemeral host died, so the client stops waiting.
91
+
92
+ Carries no ``reason``: the arm has exactly one cause, so a single-valued
93
+ field would add nothing ``kind`` does not already say.
94
+ """
95
+
96
+ kind: Literal["terminalUnknown"] = "terminalUnknown"
97
+
98
+
99
+ TurnOutcome = Union[_Completed, _Unqueued, _TerminalUnknown]
100
+ """How a turn ended, from a waiter's point of view (SS3.1.4's two resolutions,
101
+ plus the ephemeral-death annotation). ``turn/retracted`` and
102
+ ``turn/retryScheduled`` are deliberately absent: a retracted turn still
103
+ reaches its own terminal, and a scheduled retry is explicitly non-terminal
104
+ (SS4.5.1)."""
105
+
106
+
107
+ def is_launch_failure(outcome: TurnOutcome) -> bool:
108
+ """The ``deferred_start_failed`` exit (SS3.1.4/SS3.2).
109
+
110
+ A pre-minted turn whose launch errored at the terminal boundary, so the
111
+ runtime wrote its terminal directly and no ``turn/started`` was ever
112
+ emitted BY THE HOST. Read off the wire, never off local observation: a
113
+ client that had not been listening when the turn started is not looking at
114
+ a launch failure.
115
+
116
+ Args:
117
+ outcome: The settled turn outcome.
118
+
119
+ Returns:
120
+ ``True`` iff the terminal is ``failed`` with ``error.kind ==
121
+ launchError``.
122
+ """
123
+ if not isinstance(outcome, _Completed):
124
+ return False
125
+ if outcome.params.get("terminal") != _TERMINAL_FAILED:
126
+ return False
127
+ error = outcome.params.get("error")
128
+ return isinstance(error, dict) and error.get("kind") == _LAUNCH_ERROR
129
+
130
+
131
+ class _PushStream(Generic[_T]):
132
+ """A single-consumer push stream (async iterator).
133
+
134
+ Buffers when nobody is awaiting and hands the buffer over before reporting
135
+ an end or a failure, so a consumer never loses events it was already owed.
136
+ """
137
+
138
+ def __init__(self, on_finished: Callable[[], None]) -> None:
139
+ self._buffer: List[_T] = []
140
+ # FIFO, not a single slot: ``items()``/``deltas()`` hand out a plain
141
+ # async iterator, so a consumer prefetching with two concurrent
142
+ # ``__anext__`` calls is reasonable, and a single slot would leave the
143
+ # first waiter never settled. A silently never-settling future is the
144
+ # hang class this file exists to prevent.
145
+ self._waiters: List[asyncio.Future[_T]] = []
146
+ self._on_finished = on_finished
147
+ self._ended = False
148
+ self._failure: BaseException | None = None
149
+
150
+ def push(self, value: _T) -> None:
151
+ """Delivers one value (to the oldest waiter, else the buffer)."""
152
+ if self._ended:
153
+ return
154
+ while self._waiters:
155
+ waiter = self._waiters.pop(0)
156
+ if not waiter.done():
157
+ waiter.set_result(value)
158
+ return
159
+ self._buffer.append(value)
160
+
161
+ def end(self) -> None:
162
+ """Reports end-of-stream to every queued waiter, not just the oldest."""
163
+ if self._ended:
164
+ return
165
+ self._ended = True
166
+ for waiter in self._waiters:
167
+ if not waiter.done():
168
+ waiter.set_exception(StopAsyncIteration())
169
+ self._waiters.clear()
170
+
171
+ def fail(self, error: BaseException) -> None:
172
+ """Reports a failure to every queued waiter."""
173
+ if self._ended:
174
+ return
175
+ self._ended = True
176
+ self._failure = error
177
+ for waiter in self._waiters:
178
+ if not waiter.done():
179
+ waiter.set_exception(error)
180
+ self._waiters.clear()
181
+
182
+ def __aiter__(self) -> AsyncIterator[_T]:
183
+ return self
184
+
185
+ async def __anext__(self) -> _T:
186
+ # Buffered values come out first even after an end or a failure: the
187
+ # consumer was already owed them, and swallowing them here would be
188
+ # the silent hole the iterators exist to prevent.
189
+ if self._buffer:
190
+ return self._buffer.pop(0)
191
+ if self._failure is not None:
192
+ raise self._failure
193
+ if self._ended:
194
+ raise StopAsyncIteration()
195
+ waiter: asyncio.Future[_T] = asyncio.get_running_loop().create_future()
196
+ self._waiters.append(waiter)
197
+ return await waiter
198
+
199
+ async def aclose(self) -> None:
200
+ """Drop the backlog and deregister this stream.
201
+
202
+ The TS twin deregisters on ``for await ... break`` via the iterator's
203
+ ``return()``. Python's ``async for ... break`` does NOT call ``aclose``
204
+ on a manual async iterator, so a consumer that wants deterministic
205
+ deregistration wraps the iterator in ``contextlib.aclosing(...)`` (the
206
+ Python idiom) — this is the method it drives. Without it a dropped
207
+ iterator is reclaimed only when the turn settles (``end``/``fail``
208
+ clears every stream) or on GC; either way the fan-out never yields to a
209
+ stream nobody reads, because ``end`` and ``fail`` clear the sets.
210
+ """
211
+ self._buffer.clear()
212
+ self.end()
213
+ self._on_finished()
214
+
215
+
216
+ ItemReplay = Callable[[], "tuple[FoldedItem, ...]"]
217
+ """The turn's replayable backlog, supplied by ``Session`` from the fold."""
218
+
219
+
220
+ class TurnHandle:
221
+ """One turn's SS7.1 surface, and the Session-fed mutators behind it.
222
+
223
+ ``Session.turn()`` returns this typed as :class:`Turn` (the consumer view)
224
+ so the ``settle_*``/``push_*``/``fail`` mutators are not public: an
225
+ embedder calling ``settle_completed`` would fabricate a terminal the
226
+ server never authored, which is exactly what INV-006 forbids. They stay
227
+ reachable only through ``Session.apply``, i.e. only from a server-authored
228
+ event.
229
+
230
+ Attributes:
231
+ turn_id: The wire turn id.
232
+ """
233
+
234
+ def __init__(self, turn_id: str, replay_items: ItemReplay) -> None:
235
+ """Builds the handle; ``replay_items`` reads this turn's folded items."""
236
+ self.turn_id = turn_id
237
+ self._replay_items = replay_items
238
+ self._item_streams: Set[_PushStream[FoldedItem]] = set()
239
+ self._delta_streams: Set[_PushStream[ItemDeltaParams]] = set()
240
+ self._observed_start = False
241
+ self._settled = False
242
+ self._outcome: TurnOutcome | None = None
243
+ # Retained so an iterator opened AFTER the failure reports it too:
244
+ # without it a late ``items()`` would end cleanly, handing a partial
245
+ # replay plus a tidy done — an unfinished turn rendered as finished,
246
+ # the invented completion INV-006 forbids.
247
+ self._failure: BaseException | None = None
248
+ self._waiters: List[asyncio.Future[TurnOutcome]] = []
249
+
250
+ @property
251
+ def completed(self) -> Awaitable[TurnOutcome]:
252
+ """Settles on ``turn/completed``, ``turn/unqueued``, or the ephemeral
253
+ discard.
254
+
255
+ Each access returns a fresh awaitable resolved from the handle's
256
+ settled state (or a pending one that the next settlement resolves), so
257
+ any number of callers may await it and a late awaiter still learns the
258
+ outcome. It never settles on a local timeout (INV-006).
259
+ """
260
+ future: asyncio.Future[TurnOutcome] = asyncio.get_running_loop().create_future()
261
+ if self._failure is not None:
262
+ future.set_exception(self._failure)
263
+ elif self._settled and self._outcome is not None:
264
+ future.set_result(self._outcome)
265
+ else:
266
+ self._waiters.append(future)
267
+ return future
268
+
269
+ @property
270
+ def live_stream_count(self) -> int:
271
+ """How many iterators this handle is still fanning out to.
272
+
273
+ Exposed so the deregistration contract is assertable: an iterator a
274
+ consumer broke out of, or dropped, must leave this set rather than
275
+ accumulate for the rest of the turn.
276
+ """
277
+ return len(self._item_streams) + len(self._delta_streams)
278
+
279
+ @property
280
+ def observed_start(self) -> bool:
281
+ """Did a ``turn/started`` ever fold for this turn?
282
+
283
+ A session-local observation, NOT a launch-failure marker — the fold
284
+ accepts a terminal-first ``turn/completed`` for turns that really ran
285
+ (single-shot turns, gap fills, mid-stream attach). Branch on
286
+ :func:`is_launch_failure` for the launch failure.
287
+ """
288
+ return self._observed_start
289
+
290
+ def items(self) -> AsyncIterator[FoldedItem]:
291
+ """This turn's items: everything the fold already holds, at its
292
+ current revision and in first-opened order, then the live tail — one
293
+ yield per fold-observed revision CHANGE. A stale re-emission mutates
294
+ nothing (INV-003) and yields nothing.
295
+
296
+ The iterator ends when the turn settles; it never ends on a local
297
+ timeout.
298
+ """
299
+ stream: _PushStream[FoldedItem] = _PushStream(
300
+ lambda: self._item_streams.discard(stream)
301
+ )
302
+ for item in self._replay_items():
303
+ stream.push(item)
304
+ self._admit(stream, self._item_streams)
305
+ return stream
306
+
307
+ def deltas(self) -> AsyncIterator[ItemDeltaParams]:
308
+ """This turn's ``item/delta`` frames, live only.
309
+
310
+ Deliberately NOT replayed, and the asymmetry with :meth:`items` is a
311
+ fact about the fold: the store holds each item at its latest revision
312
+ plus the ACCUMULATED delta text per field path, never the delta event
313
+ sequence. A consumer attaching mid-turn reads the accumulated value
314
+ from the fold; there is no retained event list to replay, and
315
+ inventing one would be a second copy of state INV-002 must defend.
316
+ """
317
+ stream: _PushStream[ItemDeltaParams] = _PushStream(
318
+ lambda: self._delta_streams.discard(stream)
319
+ )
320
+ self._admit(stream, self._delta_streams)
321
+ return stream
322
+
323
+ def _admit(
324
+ self, stream: _PushStream[_T], into: Set[_PushStream[_T]]
325
+ ) -> None:
326
+ # The failure branch comes FIRST: a turn that failed is not a turn that
327
+ # ended. ``_PushStream`` drains the replay before reporting either, so
328
+ # a late consumer still receives everything it was owed.
329
+ if self._failure is not None:
330
+ stream.fail(self._failure)
331
+ elif self._settled:
332
+ stream.end()
333
+ else:
334
+ into.add(stream)
335
+
336
+ # ---- fed by Session -----------------------------------------------------
337
+
338
+ def mark_started(self) -> None:
339
+ """A ``turn/started`` folded for this turn."""
340
+ self._observed_start = True
341
+
342
+ def push_item(self, item: FoldedItem) -> None:
343
+ """Fan one item out to every live ``items()`` iterator."""
344
+ for stream in self._item_streams:
345
+ stream.push(item)
346
+
347
+ def push_delta(self, params: ItemDeltaParams) -> None:
348
+ """Fan one delta out to every live ``deltas()`` iterator."""
349
+ for stream in self._delta_streams:
350
+ stream.push(params)
351
+
352
+ def settle_completed(self, params: TurnCompletedParams) -> None:
353
+ """Settle on a ``turn/completed`` terminal."""
354
+ self._finish(_Completed(params=params, observed_start=self._observed_start))
355
+
356
+ def settle_unqueued(self, params: TurnUnqueuedParams) -> None:
357
+ """Settle on a ``turn/unqueued`` reclaim."""
358
+ self._finish(_Unqueued(params=params))
359
+
360
+ def settle_terminal_unknown(self) -> None:
361
+ """SS2.13.3b: an ephemeral host died; stop waiting for a terminal."""
362
+ self._finish(_TerminalUnknown())
363
+
364
+ def fail(self, error: BaseException) -> None:
365
+ """A DURABLE host died abnormally.
366
+
367
+ The waiter gets no answer here — FM-001 puts the terminals on resume —
368
+ so it is rejected rather than resolved: a resolution would be the
369
+ invented terminal INV-006 forbids, and silence would be the SS3.1.4
370
+ hang.
371
+ """
372
+ if self._settled:
373
+ return
374
+ self._settled = True
375
+ self._failure = error
376
+ for waiter in self._waiters:
377
+ if not waiter.done():
378
+ waiter.set_exception(error)
379
+ self._waiters.clear()
380
+ for item_stream in list(self._item_streams):
381
+ item_stream.fail(error)
382
+ for delta_stream in list(self._delta_streams):
383
+ delta_stream.fail(error)
384
+ self._item_streams.clear()
385
+ self._delta_streams.clear()
386
+
387
+ def _finish(self, outcome: TurnOutcome) -> None:
388
+ # First settlement wins: a duplicate terminal is a losing racer's echo.
389
+ if self._settled:
390
+ return
391
+ self._settled = True
392
+ self._outcome = outcome
393
+ for waiter in self._waiters:
394
+ if not waiter.done():
395
+ waiter.set_result(outcome)
396
+ self._waiters.clear()
397
+ for item_stream in list(self._item_streams):
398
+ item_stream.end()
399
+ for delta_stream in list(self._delta_streams):
400
+ delta_stream.end()
401
+ self._item_streams.clear()
402
+ self._delta_streams.clear()
403
+
404
+
405
+ @runtime_checkable
406
+ class Turn(Protocol):
407
+ """A turn, as a CONSUMER sees it — the SS7.1 turn surface and nothing else.
408
+
409
+ ``Session.turn()`` and ``send_user_turn()`` advertise THIS rather than the
410
+ concrete :class:`TurnHandle` so the "fed by Session" mutators
411
+ (``settle_completed``, ``push_item``, ``fail``, …) are not part of the
412
+ consumer contract: an embedder calling one would fabricate a terminal the
413
+ server never authored (INV-006). Python cannot hide them at runtime the
414
+ way the TS ``Turn`` interface does, so the seal is the return annotation
415
+ plus this documented surface — the same convention the fold's read views
416
+ use.
417
+ """
418
+
419
+ @property
420
+ def turn_id(self) -> str:
421
+ """The wire turn id."""
422
+ ...
423
+
424
+ @property
425
+ def observed_start(self) -> bool:
426
+ """Did THIS session observe a ``turn/started``? Not a launch marker."""
427
+ ...
428
+
429
+ @property
430
+ def completed(self) -> Awaitable[TurnOutcome]:
431
+ """The SS3.1.4 turn-wait; settles on a server-authored fact."""
432
+ ...
433
+
434
+ def items(self) -> AsyncIterator[FoldedItem]:
435
+ """This turn's items: folded backlog, then the live tail."""
436
+ ...
437
+
438
+ def deltas(self) -> AsyncIterator[ItemDeltaParams]:
439
+ """This turn's ``item/delta`` frames, live only."""
440
+ ...