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,279 @@
1
+ """The FR-638-019b approval round trip: inbound ``approval/requested`` → the
2
+ consumer's handler → outbound ``approval/decide`` (spec 638 T031, carrying
3
+ spec 14990 FR-019; tdd SS5.4). Port of ``clients/sdk-ts/src/facade/approval.ts``.
4
+
5
+ Split out of ``session.py`` rather than added to it: the router owns a
6
+ handler, a per-stage latch, and a decision-shaping guard chain that have
7
+ nothing to do with folding a view, and ``Session`` was already the package's
8
+ largest module.
9
+
10
+ SHAPE NOTE, established from the bundle rather than assumed. ``approval/request``
11
+ as a SERVER REQUEST is not enrolled for the view stream this router rides —
12
+ the schema carries ``approval/requested`` as a NOTIFICATION — so the round
13
+ trip is notification-in / command-out, which is exactly the pair FR-019
14
+ names. The server-request form would be a #206 enrollment request
15
+ (INV-638-01), never a local interface.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import inspect
21
+ from dataclasses import dataclass
22
+ from typing import Any, Awaitable, Callable, Literal, Union
23
+
24
+ from muse_code_msp import ApprovalDecideParams, ApprovalRequestParams
25
+
26
+ from ..connection.connection import Connection
27
+
28
+ # Every ``approval/decide`` member the guard chain below forwards. Composition
29
+ # alone does not stop drift — the chain is hand-listed — so a regenerated
30
+ # member reds THIS import (and thus every test) until it is forwarded, exactly
31
+ # as the ``MuseClient`` option pins do. ``commandId`` is excluded because the
32
+ # connection is the single minter (INV-638-04 carrying INV-013).
33
+ _DECIDE_FORWARDED = frozenset(
34
+ {"approvalId", "choiceId", "requirementId", "sessionId", "feedback"}
35
+ )
36
+ assert _DECIDE_FORWARDED | {"commandId"} == (
37
+ ApprovalDecideParams.__required_keys__ | ApprovalDecideParams.__optional_keys__
38
+ ), "approval/decide grew a member — forward it through ApprovalRouter"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ApprovalDecisionInput:
43
+ """What an approval handler answers with: a choice the SERVER offered, and
44
+ optional feedback (C-638-1 idiomatic surface over the generated params).
45
+
46
+ D-006 is select-never-create, and this TYPE cannot enforce it —
47
+ ``choiceId`` is a string on the wire — so the router checks the answer
48
+ against the request's own ``availableChoices`` before the frame is built.
49
+
50
+ Attributes:
51
+ choice_id: The chosen ``availableChoices`` entry's ``choiceId``.
52
+ feedback: Optional feedback text; omitted from the frame when ``None``
53
+ (SS1.2 — valid only on choices with ``acceptsFeedback``).
54
+ """
55
+
56
+ choice_id: str
57
+ feedback: str | None = None
58
+
59
+
60
+ ApprovalHandler = Callable[
61
+ [ApprovalRequestParams],
62
+ "ApprovalDecisionInput | Awaitable[ApprovalDecisionInput]",
63
+ ]
64
+ """FR-638-019b: the consumer's decision callback. May be sync or async."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class UnofferedChoice:
69
+ """The handler picked a ``choiceId`` the request never offered (D-006)."""
70
+
71
+ approval_id: str
72
+ choice_id: str
73
+ available_choice_ids: tuple[str, ...]
74
+ kind: Literal["unofferedChoice"] = "unofferedChoice"
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class HandlerRaised:
79
+ """The consumer's decision handler raised; nothing reached the wire."""
80
+
81
+ approval_id: str
82
+ error: BaseException
83
+ kind: Literal["handlerThrew"] = "handlerThrew"
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class SubmitFailed:
88
+ """The ``approval/decide`` round trip itself failed (or there was no
89
+ connection to author it on)."""
90
+
91
+ approval_id: str
92
+ error: BaseException
93
+ kind: Literal["submitFailed"] = "submitFailed"
94
+
95
+
96
+ ApprovalFailure = Union[UnofferedChoice, HandlerRaised, SubmitFailed]
97
+ """Why one approval round trip did not complete.
98
+
99
+ REPORTED, not raised: the trip is driven from a notification, so an exception
100
+ would escape into the consumer's pump with nothing to catch it — the same
101
+ hazard ``Session.apply`` already refuses a malformed frame for. Each arm names
102
+ a DIFFERENT owner (the consumer's handler, the consumer's choice, the host),
103
+ because the repair differs.
104
+ """
105
+
106
+ ApprovalFailureHandler = Callable[[ApprovalFailure], None]
107
+ """Receives each :data:`ApprovalFailure`; its ``kind`` names whose repair it is."""
108
+
109
+
110
+ class ApprovalRouter:
111
+ """Routes folded ``approval/requested`` frames to the consumer's handler
112
+ and authors the ``approval/decide`` command (spec 638 FR-638-019b)."""
113
+
114
+ def __init__(self, session_id: str, connection: Connection | None) -> None:
115
+ """Builds the router; ``connection`` is absent on a fold-only session.
116
+
117
+ Args:
118
+ session_id: The owning session's id, echoed into every decide.
119
+ connection: The wire seam the decide command authors through.
120
+ """
121
+ self._session_id = session_id
122
+ self._connection = connection
123
+ self._handler: ApprovalHandler | None = None
124
+ self._on_failure: ApprovalFailureHandler | None = None
125
+ # Approval STAGES already decided, keyed by ``approvalId`` +
126
+ # ``requirementId``. Not ``approvalId`` alone: ``requirementId`` is
127
+ # SS5.4's multi-stage race guard, so a stage-2 request must get its own
128
+ # decision or the approval pends forever. Not per-request either: a
129
+ # redelivered ``approval/requested`` for the SAME stage must author no
130
+ # second decide, or a two-stage approval races itself. The key is kept
131
+ # on FAILURE too — a genuinely advancing stage brings a new
132
+ # ``requirementId`` and therefore a new key, so "ask the consumer once
133
+ # per stage" holds absolutely.
134
+ self._decided_stages: set[str] = set()
135
+
136
+ def on_approval(self, handler: ApprovalHandler) -> None:
137
+ """Register the decision callback (FR-638-019b).
138
+
139
+ REPLACES any previous handler rather than adding to a list: two
140
+ handlers would race to decide one approval and only one decision can
141
+ win, so the loser's would be silently discarded.
142
+ """
143
+ self._handler = handler
144
+
145
+ def on_approval_error(self, handler: ApprovalFailureHandler) -> None:
146
+ """Observe round trips that did not complete. See :data:`ApprovalFailure`."""
147
+ self._on_failure = handler
148
+
149
+ def requested(self, params: ApprovalRequestParams) -> Awaitable[None] | None:
150
+ """An ``approval/requested`` folded. Returns the decision round trip to
151
+ await, or ``None`` when this call schedules nothing — no handler, an
152
+ already-decided stage, or a fold-only session (whose ``SubmitFailed``
153
+ is reported synchronously before returning).
154
+
155
+ No handler at all is the supported default posture, NOT a degraded one
156
+ (D-008): the client runs under the server's own default-deny, and this
157
+ SDK parks nothing — no queued decision, no synthesized denial, no
158
+ local hold.
159
+ """
160
+ handler = self._handler
161
+ if handler is None:
162
+ return None
163
+ stage = self._stage_key(params)
164
+ if stage in self._decided_stages:
165
+ return None
166
+ # Latched BEFORE anything can yield: a redelivery folded on the very
167
+ # next frame must find the stage already claimed, and an await boundary
168
+ # here would let both pass the check.
169
+ self._decided_stages.add(stage)
170
+ connection = self._connection
171
+ # Checked HERE, synchronously, not inside the coroutine: a fold-only
172
+ # session may apply events with no running loop at all, and a returned
173
+ # coroutine would force ``Session.apply`` to schedule one just to
174
+ # deliver this report (PR #32249 review round 1). Before the handler
175
+ # runs, deliberately — asking a consumer to decide and then dropping
176
+ # the answer is worse than not asking.
177
+ if connection is None:
178
+ # A plain ``RuntimeError``: constructing fold-only and then
179
+ # registering a decide handler is API misuse, not a protocol STATE
180
+ # a typed error names.
181
+ self._report(
182
+ SubmitFailed(
183
+ approval_id=params["approvalId"],
184
+ error=RuntimeError(
185
+ "approval/decide needs a connection; this Session was "
186
+ "constructed fold-only"
187
+ ),
188
+ )
189
+ )
190
+ return None
191
+ return self._decide(connection, params, handler)
192
+
193
+ @staticmethod
194
+ def _stage_key(params: ApprovalRequestParams) -> str:
195
+ requirement = params["currentRequirementId"]
196
+ return (
197
+ f"{params['approvalId']} "
198
+ f"{requirement['approvalId']}:{requirement['sourceIndex']}"
199
+ )
200
+
201
+ async def _decide(
202
+ self,
203
+ connection: Connection,
204
+ params: ApprovalRequestParams,
205
+ handler: ApprovalHandler,
206
+ ) -> None:
207
+ try:
208
+ answered = handler(params)
209
+ decision = (
210
+ await answered if inspect.isawaitable(answered) else answered
211
+ )
212
+ # The reads are INSIDE the try: a malformed return (a handler that
213
+ # forgot its ``return``, a wrong type) is the consumer's callback
214
+ # misbehaving, exactly like a raise — reported as ``handlerThrew``,
215
+ # never escaped into the never-rejecting ``io`` (PR #32249 review
216
+ # round 1).
217
+ choice_id = decision.choice_id
218
+ feedback = decision.feedback
219
+ except Exception as error: # noqa: BLE001 - the consumer's callback
220
+ self._report(HandlerRaised(approval_id=params["approvalId"], error=error))
221
+ return
222
+
223
+ available_choice_ids = tuple(
224
+ choice["choiceId"] for choice in params["availableChoices"]
225
+ )
226
+ if choice_id not in available_choice_ids:
227
+ # D-006 select-never-create, enforced on THIS side of the
228
+ # transport. An invented choice bounces -32052 a round trip later,
229
+ # by which time the stage may have advanced — so the diagnosis a
230
+ # consumer would actually see is a stale-requirement error about a
231
+ # frame it should never have sent.
232
+ self._report(
233
+ UnofferedChoice(
234
+ approval_id=params["approvalId"],
235
+ choice_id=choice_id,
236
+ available_choice_ids=available_choice_ids,
237
+ )
238
+ )
239
+ return
240
+
241
+ decide_params: dict[str, Any] = {
242
+ "approvalId": params["approvalId"],
243
+ "choiceId": choice_id,
244
+ # Echoed from the request, never remembered: SS5.4 makes a stale
245
+ # value a -32053, and the request in hand is the only current
246
+ # statement of it.
247
+ "requirementId": params["currentRequirementId"],
248
+ "sessionId": self._session_id,
249
+ }
250
+ # Omitted rather than nulled (SS1.2): ``feedback`` is valid only on
251
+ # choices with ``acceptsFeedback``, so an explicit null would be
252
+ # rejected by the host.
253
+ if feedback is not None:
254
+ decide_params["feedback"] = feedback
255
+
256
+ try:
257
+ # No explicit ``command_id``: unlike ``send_user_turn``, nothing
258
+ # here needs the id before the ack, so ``Connection.command()``
259
+ # mints and stamps it — and its own SS3.1.1 retry then reuses that
260
+ # id for free.
261
+ await connection.command("approval/decide", decide_params)
262
+ except Exception as error: # noqa: BLE001 - reported, never escapes
263
+ self._report(SubmitFailed(approval_id=params["approvalId"], error=error))
264
+
265
+ def _report(self, failure: ApprovalFailure) -> None:
266
+ handler = self._on_failure
267
+ if handler is None:
268
+ return
269
+ try:
270
+ handler(failure)
271
+ except Exception: # noqa: BLE001, S110
272
+ # The consumer's failure OBSERVER raised. Swallowed, deliberately:
273
+ # an exception escaping here rides ``SessionApplyOutcome.io``,
274
+ # whose contract is "IT NEVER REJECTS" — and since most consumers
275
+ # never await ``io``, it would surface as an escaping failure in
276
+ # the pump, the exact hazard that contract exists to prevent. The
277
+ # failure itself was already handed to the observer; there is no
278
+ # one left to tell.
279
+ pass