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.
- muse_code/__init__.py +129 -0
- muse_code/connection/__init__.py +49 -0
- muse_code/connection/connection.py +905 -0
- muse_code/connection/discovery.py +55 -0
- muse_code/connection/spawn.py +1067 -0
- muse_code/connection/validation.py +149 -0
- muse_code/errors.py +199 -0
- muse_code/facade/__init__.py +89 -0
- muse_code/facade/approval.py +279 -0
- muse_code/facade/client.py +444 -0
- muse_code/facade/discarded.py +45 -0
- muse_code/facade/gap_fill.py +483 -0
- muse_code/facade/host_death.py +254 -0
- muse_code/facade/session.py +977 -0
- muse_code/facade/turn_handle.py +440 -0
- muse_code/facade/turn_submit.py +289 -0
- muse_code/fold/__init__.py +82 -0
- muse_code/fold/item_store.py +268 -0
- muse_code/fold/session_fold.py +785 -0
- muse_code/fold/state_store.py +141 -0
- muse_code/pending/__init__.py +53 -0
- muse_code/pending/pending_command_set.py +842 -0
- muse_code/py.typed +0 -0
- muse_code/replay.py +144 -0
- muse_code/sync_facade.py +493 -0
- muse_code_sdk-1.3.0.dist-info/METADATA +47 -0
- muse_code_sdk-1.3.0.dist-info/RECORD +30 -0
- muse_code_sdk-1.3.0.dist-info/WHEEL +5 -0
- muse_code_sdk-1.3.0.dist-info/licenses/LICENSE +21 -0
- muse_code_sdk-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""``turn/start`` submission and same-``commandId`` replay (spec 638
|
|
2
|
+
FR-638-019a carrying spec 14990 FR-018 obligations (a) and (d); tdd SS3.1.1,
|
|
3
|
+
SS3.2, SS4.13). Port of ``clients/sdk-ts/src/facade/turn-submit.ts``.
|
|
4
|
+
|
|
5
|
+
It holds the ONE piece of state :class:`~muse_code.pending.PendingCommandSet`
|
|
6
|
+
deliberately cannot: the ``turn/start`` params per ``commandId``. The set
|
|
7
|
+
stores the COMPOSER input and is wire-blind by design (INV-638-01), so a
|
|
8
|
+
replay — which must re-send the SAME logical command to be the sanctioned
|
|
9
|
+
retry rather than a double execution — needs the params on this side of that
|
|
10
|
+
boundary.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, fields
|
|
16
|
+
from typing import Generic, List, Sequence, TypeVar, cast
|
|
17
|
+
|
|
18
|
+
from muse_code_msp import (
|
|
19
|
+
IfBusy,
|
|
20
|
+
ReasoningEffort,
|
|
21
|
+
TurnInputPart,
|
|
22
|
+
TurnStartParams,
|
|
23
|
+
TurnStartResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from ..connection.connection import Connection, MspError, Params
|
|
27
|
+
from ..pending.pending_command_set import (
|
|
28
|
+
CommandErrorResponse,
|
|
29
|
+
PendingCommandAck,
|
|
30
|
+
PendingCommandSet,
|
|
31
|
+
PendingRetirement,
|
|
32
|
+
ReplayAck,
|
|
33
|
+
ReplayAnswer,
|
|
34
|
+
ReplayError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_I = TypeVar("_I")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SendUserTurnOptions(Generic[_I]):
|
|
42
|
+
"""What :meth:`Session.send_user_turn` sends, composed from the generated
|
|
43
|
+
``turn/start`` params rather than restated (INV-638-02).
|
|
44
|
+
|
|
45
|
+
``commandId`` is excluded because ``Connection`` is the single minter
|
|
46
|
+
(INV-638-04 carrying INV-013); ``sessionId`` because the session already
|
|
47
|
+
knows its own and a caller-supplied one could only disagree.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
input: The turn's input parts (the wire ``input`` member).
|
|
51
|
+
display_text: Optional composer display text (wire ``displayText``).
|
|
52
|
+
if_busy: How to treat a submit while a turn runs (wire ``ifBusy``).
|
|
53
|
+
reasoning_effort: The turn's reasoning effort (wire
|
|
54
|
+
``reasoningEffort``).
|
|
55
|
+
composer_input: What to hand back to the composer if this submit is
|
|
56
|
+
ever retired — SS4.13 restores the INPUT, not the wire parts.
|
|
57
|
+
Optional: a consumer rendering nothing optimistically has nothing
|
|
58
|
+
to restore.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
input: List[TurnInputPart]
|
|
62
|
+
display_text: str | None = None
|
|
63
|
+
if_busy: IfBusy | None = None
|
|
64
|
+
reasoning_effort: ReasoningEffort | None = None
|
|
65
|
+
composer_input: _I | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _camel(field_name: str) -> str:
|
|
69
|
+
"""snake_case → camelCase, the C-638-1 facade→wire member mapping.
|
|
70
|
+
|
|
71
|
+
The single copy: ``client.py`` imports this for its own drift pins rather
|
|
72
|
+
than restating it (that direction has no import cycle — this module reaches
|
|
73
|
+
neither ``client`` nor ``session``).
|
|
74
|
+
"""
|
|
75
|
+
head, *rest = field_name.split("_")
|
|
76
|
+
return head + "".join(part.capitalize() for part in rest)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Drift pin — the Python twin of the TS `AssertNever` on TURN_START_FORWARDED
|
|
80
|
+
# (turn-submit.ts). A schema regen that adds a member to TurnStartParams but not
|
|
81
|
+
# to SendUserTurnOptions reds THIS import, so a regenerated member cannot
|
|
82
|
+
# silently fail to reach the wire (INV-638-02). `composer_input` is the SDK's
|
|
83
|
+
# own field (the SS4.13 restore payload), never a wire member, so it is dropped
|
|
84
|
+
# before the compare; `commandId`/`sessionId` are excluded because the
|
|
85
|
+
# connection mints the first and the session owns the second.
|
|
86
|
+
_TURN_WIRE_FIELDS = {"input", "display_text", "if_busy", "reasoning_effort"}
|
|
87
|
+
_TURN_EXCLUDED = {"commandId", "sessionId"}
|
|
88
|
+
assert {
|
|
89
|
+
_camel(f.name) for f in fields(SendUserTurnOptions) if f.name in _TURN_WIRE_FIELDS
|
|
90
|
+
} | _TURN_EXCLUDED == set(
|
|
91
|
+
TurnStartParams.__annotations__
|
|
92
|
+
), "SendUserTurnOptions drifted from TurnStartParams — forward the new member"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def command_error_response(error: BaseException) -> CommandErrorResponse | None:
|
|
96
|
+
"""An :class:`MspError` as the pending set reads it (SS4.13's settlement
|
|
97
|
+
test).
|
|
98
|
+
|
|
99
|
+
``None`` for anything that is NOT a server-authored MSP error — a
|
|
100
|
+
:class:`ProtocolError`, a dead transport — because those admit nothing AND
|
|
101
|
+
prove nothing about the intake, so the entry must HOLD rather than be
|
|
102
|
+
judged.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
error: The exception a submit or replay raised.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The typed response, or ``None`` for a non-MSP failure.
|
|
109
|
+
"""
|
|
110
|
+
if not isinstance(error, MspError):
|
|
111
|
+
return None
|
|
112
|
+
reason = error.data.get("reason")
|
|
113
|
+
return CommandErrorResponse(
|
|
114
|
+
code=error.code,
|
|
115
|
+
kind=error.kind,
|
|
116
|
+
reason=reason if isinstance(reason, str) else None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TurnSubmitter(Generic[_I]):
|
|
121
|
+
"""Shapes ``turn/start`` frames, records the optimistic SS4.13 entry, and
|
|
122
|
+
replays a remembered command under its original ``commandId``."""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
session_id: str,
|
|
127
|
+
connection: Connection | None,
|
|
128
|
+
pending: PendingCommandSet[_I],
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Binds the submitter; ``connection`` is ``None`` on a fold-only
|
|
131
|
+
session."""
|
|
132
|
+
self._session_id = session_id
|
|
133
|
+
self._connection = connection
|
|
134
|
+
self._pending = pending
|
|
135
|
+
# See the module doc. Pruned on retirement, so it tracks the set.
|
|
136
|
+
self._memory: dict[str, Params] = {}
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def wired(self) -> bool:
|
|
140
|
+
"""Whether a connection is present to author through."""
|
|
141
|
+
return self._connection is not None
|
|
142
|
+
|
|
143
|
+
def require_connection(self, verb: str) -> Connection:
|
|
144
|
+
"""The connection, or a plain error naming the fold-only misuse.
|
|
145
|
+
|
|
146
|
+
A plain :class:`Exception`: calling a submit verb on a fold-only
|
|
147
|
+
session is API misuse, not the foreign-frame STATE
|
|
148
|
+
:class:`MuseForeignSessionError` names.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
verb: The verb name, for the error message.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The live connection.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
RuntimeError: This session was constructed fold-only.
|
|
158
|
+
"""
|
|
159
|
+
if self._connection is None:
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
f"{verb} needs a connection; this Session was constructed fold-only"
|
|
162
|
+
)
|
|
163
|
+
return self._connection
|
|
164
|
+
|
|
165
|
+
async def submit(
|
|
166
|
+
self, options: SendUserTurnOptions[_I], anchor_after_item_id: str | None
|
|
167
|
+
) -> PendingCommandAck:
|
|
168
|
+
"""Records the optimistic SS4.13 entry, then sends ``turn/start``.
|
|
169
|
+
|
|
170
|
+
The ORDER is the point. An entry created only on the ack is invisible
|
|
171
|
+
for exactly the window it exists to cover — between the user pressing
|
|
172
|
+
enter and the host answering — and ``anchor_after_item_id`` is fixed
|
|
173
|
+
here, at submission time, because SS4.13's insertion point may never
|
|
174
|
+
be relocated by events that fold in afterwards.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
options: The caller's turn options.
|
|
178
|
+
anchor_after_item_id: The last item the session holds now.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The admission ack (``turnId`` + ``disposition``).
|
|
182
|
+
"""
|
|
183
|
+
connection = self.require_connection("send_user_turn")
|
|
184
|
+
# Delegated, not re-implemented: the connection owns the single mint,
|
|
185
|
+
# so INV-013's single-minter property survives this verb needing the
|
|
186
|
+
# id early.
|
|
187
|
+
command_id = connection.mint_command_id()
|
|
188
|
+
params = self._params(command_id, options)
|
|
189
|
+
self._pending.submitted(
|
|
190
|
+
command_id,
|
|
191
|
+
# ``composer_input`` is optional on the options object; a consumer
|
|
192
|
+
# rendering nothing optimistically has nothing to restore, and the
|
|
193
|
+
# set stores whatever it is handed for restoration. ``None`` is a
|
|
194
|
+
# valid stored input, so the cast preserves it rather than
|
|
195
|
+
# inventing a value.
|
|
196
|
+
cast(_I, options.composer_input),
|
|
197
|
+
display_text=params.get("displayText"),
|
|
198
|
+
anchor_after_item_id=anchor_after_item_id,
|
|
199
|
+
)
|
|
200
|
+
self._memory[command_id] = params
|
|
201
|
+
try:
|
|
202
|
+
ack = await self._send(connection, params, command_id)
|
|
203
|
+
self._pending.acked(command_id, ack)
|
|
204
|
+
return ack
|
|
205
|
+
except MspError as error:
|
|
206
|
+
response = command_error_response(error)
|
|
207
|
+
# Only a durable -32030 settles; every other error admitted
|
|
208
|
+
# nothing and the entry HOLDS for the caller's same-commandId
|
|
209
|
+
# retry (SS4.13).
|
|
210
|
+
if response is not None:
|
|
211
|
+
settled = self._pending.ack_errored(command_id, response)
|
|
212
|
+
if settled != "held":
|
|
213
|
+
self.forget_retired([settled])
|
|
214
|
+
# Re-raised, deliberately: the caller who submitted still holds its
|
|
215
|
+
# own input, so this path needs no second retirement channel.
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
async def replay(self, command_id: str) -> ReplayAnswer[_I] | None:
|
|
219
|
+
"""Re-sends one remembered ``turn/start`` under its ORIGINAL
|
|
220
|
+
``commandId`` (SS3.1.1 — always safe, the only re-send that is not a
|
|
221
|
+
double execution).
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
command_id: The command to replay.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The answer, or ``None`` for "no answer this set can act on" —
|
|
228
|
+
either this session never authored the command, or the failure was
|
|
229
|
+
transport/protocol, which proves nothing about the intake.
|
|
230
|
+
"""
|
|
231
|
+
params = self._memory.get(command_id)
|
|
232
|
+
if params is None:
|
|
233
|
+
return None
|
|
234
|
+
connection = self.require_connection("turn/start replay")
|
|
235
|
+
try:
|
|
236
|
+
ack = await self._send(connection, params, command_id)
|
|
237
|
+
return ReplayAck(ack=ack)
|
|
238
|
+
except MspError as error:
|
|
239
|
+
response = command_error_response(error)
|
|
240
|
+
if response is None:
|
|
241
|
+
return None
|
|
242
|
+
return ReplayError(error=response)
|
|
243
|
+
|
|
244
|
+
async def drive_replay(
|
|
245
|
+
self, command_id: str, into: List[PendingRetirement[_I]]
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Replay one entry and feed the answer back through the live SS4.13
|
|
248
|
+
rules."""
|
|
249
|
+
answer = await self.replay(command_id)
|
|
250
|
+
if answer is None:
|
|
251
|
+
return
|
|
252
|
+
settled = self._pending.replay_answered(command_id, answer)
|
|
253
|
+
if settled != "held":
|
|
254
|
+
into.append(settled)
|
|
255
|
+
|
|
256
|
+
def forget_retired(
|
|
257
|
+
self, retirements: Sequence[PendingRetirement[_I]]
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Drop the replay memory for retired entries so it tracks the set."""
|
|
260
|
+
for retirement in retirements:
|
|
261
|
+
self._memory.pop(retirement.command_id, None)
|
|
262
|
+
|
|
263
|
+
async def _send(
|
|
264
|
+
self, connection: Connection, params: Params, command_id: str
|
|
265
|
+
) -> PendingCommandAck:
|
|
266
|
+
raw = await connection.command("turn/start", params, command_id=command_id)
|
|
267
|
+
result: TurnStartResult = raw # type: ignore[assignment]
|
|
268
|
+
# ``turnId`` comes from the ACK, never from the ``commandId``: SS3.2
|
|
269
|
+
# makes the ack authoritative, and a queued submit's turn may already
|
|
270
|
+
# exist.
|
|
271
|
+
return PendingCommandAck(
|
|
272
|
+
turnId=result["turnId"], disposition=result["disposition"]
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _params(self, command_id: str, options: SendUserTurnOptions[_I]) -> Params:
|
|
276
|
+
# OPTIONAL MEANS OMITTED, NEVER null (tdd SS1.2): an unset member is
|
|
277
|
+
# dropped from the frame rather than nulled.
|
|
278
|
+
params: Params = {
|
|
279
|
+
"commandId": command_id,
|
|
280
|
+
"input": options.input,
|
|
281
|
+
"sessionId": self._session_id,
|
|
282
|
+
}
|
|
283
|
+
if options.display_text is not None:
|
|
284
|
+
params["displayText"] = options.display_text
|
|
285
|
+
if options.if_busy is not None:
|
|
286
|
+
params["ifBusy"] = options.if_busy
|
|
287
|
+
if options.reasoning_effort is not None:
|
|
288
|
+
params["reasoningEffort"] = options.reasoning_effort
|
|
289
|
+
return params
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The SS4 client fold: item store, session-state store, and their
|
|
2
|
+
composition ``SessionFold`` (spec 638 FR-638-007; port of
|
|
3
|
+
``clients/sdk-ts/src/fold``)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .item_store import (
|
|
8
|
+
Appended,
|
|
9
|
+
BufferedForAbsentItem,
|
|
10
|
+
DeltaApplyOutcome,
|
|
11
|
+
IgnoredStaleRevision,
|
|
12
|
+
Inserted,
|
|
13
|
+
ItemApplyOutcome,
|
|
14
|
+
ItemLike,
|
|
15
|
+
ItemStore,
|
|
16
|
+
Replaced,
|
|
17
|
+
TerminalUnknownItemAnnotation,
|
|
18
|
+
)
|
|
19
|
+
from .session_fold import (
|
|
20
|
+
SESSION_STATE_METHODS,
|
|
21
|
+
LIVE_HOST_STATE_PROJECTIONS,
|
|
22
|
+
LIVE_HOST_STATE_PROJECTIONS,
|
|
23
|
+
VIEW_EVENT_METHODS,
|
|
24
|
+
ApprovalPending,
|
|
25
|
+
ApprovalResolvedFold,
|
|
26
|
+
DeliveryGap,
|
|
27
|
+
FoldItems,
|
|
28
|
+
FoldOutcome,
|
|
29
|
+
FoldSessionState,
|
|
30
|
+
IgnoredMissingParams,
|
|
31
|
+
IgnoredStaleFrame,
|
|
32
|
+
IgnoredUnrecognizedMethod,
|
|
33
|
+
ItemDeltaFold,
|
|
34
|
+
ItemFold,
|
|
35
|
+
PendingApproval,
|
|
36
|
+
SessionFold,
|
|
37
|
+
SessionStateFold,
|
|
38
|
+
TurnEntry,
|
|
39
|
+
TurnFold,
|
|
40
|
+
TurnState,
|
|
41
|
+
UserInputPending,
|
|
42
|
+
UserInputSettledFold,
|
|
43
|
+
)
|
|
44
|
+
from .state_store import SessionStateStore, StateApplyOutcome, StateValue
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"SESSION_STATE_METHODS",
|
|
48
|
+
"LIVE_HOST_STATE_PROJECTIONS",
|
|
49
|
+
"VIEW_EVENT_METHODS",
|
|
50
|
+
"Appended",
|
|
51
|
+
"ApprovalPending",
|
|
52
|
+
"ApprovalResolvedFold",
|
|
53
|
+
"BufferedForAbsentItem",
|
|
54
|
+
"DeliveryGap",
|
|
55
|
+
"DeltaApplyOutcome",
|
|
56
|
+
"FoldItems",
|
|
57
|
+
"FoldOutcome",
|
|
58
|
+
"FoldSessionState",
|
|
59
|
+
"IgnoredMissingParams",
|
|
60
|
+
"IgnoredStaleFrame",
|
|
61
|
+
"IgnoredStaleRevision",
|
|
62
|
+
"IgnoredUnrecognizedMethod",
|
|
63
|
+
"Inserted",
|
|
64
|
+
"ItemApplyOutcome",
|
|
65
|
+
"ItemDeltaFold",
|
|
66
|
+
"ItemFold",
|
|
67
|
+
"ItemLike",
|
|
68
|
+
"ItemStore",
|
|
69
|
+
"PendingApproval",
|
|
70
|
+
"Replaced",
|
|
71
|
+
"SessionFold",
|
|
72
|
+
"SessionStateFold",
|
|
73
|
+
"SessionStateStore",
|
|
74
|
+
"StateApplyOutcome",
|
|
75
|
+
"StateValue",
|
|
76
|
+
"TerminalUnknownItemAnnotation",
|
|
77
|
+
"TurnEntry",
|
|
78
|
+
"TurnFold",
|
|
79
|
+
"TurnState",
|
|
80
|
+
"UserInputPending",
|
|
81
|
+
"UserInputSettledFold",
|
|
82
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""The item half of the SS4 client fold: upsert-by-revision plus delta
|
|
2
|
+
accumulation (spec 638 FR-638-007 carrying spec 14990 FR-007,
|
|
3
|
+
INV-003/INV-004).
|
|
4
|
+
|
|
5
|
+
Items are the generated ``muse_code_msp`` shapes at runtime — plain dicts
|
|
6
|
+
carrying at least ``itemId`` and ``revision`` (the algebraic precondition the
|
|
7
|
+
rules need). The store reads only those two members, so it stays
|
|
8
|
+
wire-shape-blind exactly as the TS ``ItemStore`` is (INV-638-01/02); the
|
|
9
|
+
``SessionFold`` binding supplies wire-aware probes where a rule needs one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Iterable, List, Mapping
|
|
16
|
+
|
|
17
|
+
from ..errors import MuseSessionDiscardedError
|
|
18
|
+
|
|
19
|
+
# The store's item view: a wire item is a dict at runtime. Reading only
|
|
20
|
+
# `itemId`/`revision` here keeps the store wire-shape-blind (INV-638-01).
|
|
21
|
+
ItemLike = Mapping[str, object]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Inserted:
|
|
26
|
+
"""A first sighting: the item was inserted."""
|
|
27
|
+
|
|
28
|
+
item_id: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Replaced:
|
|
33
|
+
"""A strictly higher revision replaced the held item (INV-003)."""
|
|
34
|
+
|
|
35
|
+
item_id: str
|
|
36
|
+
from_revision: int
|
|
37
|
+
to_revision: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class IgnoredStaleRevision:
|
|
42
|
+
"""A stale or replayed re-emission. The store did not change (INV-003)."""
|
|
43
|
+
|
|
44
|
+
item_id: str
|
|
45
|
+
held: int
|
|
46
|
+
offered: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
ItemApplyOutcome = Inserted | Replaced | IgnoredStaleRevision
|
|
50
|
+
"""What an apply did — enough for a consumer to render without re-diffing."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Appended:
|
|
55
|
+
"""A delta appended to a held item's field accumulator."""
|
|
56
|
+
|
|
57
|
+
item_id: str
|
|
58
|
+
field: str
|
|
59
|
+
length: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class BufferedForAbsentItem:
|
|
64
|
+
"""A delta for an item the store does not hold.
|
|
65
|
+
|
|
66
|
+
Deltas are a streaming optimization and are never a fold's only source
|
|
67
|
+
of a fact (tdd SS4.7.3), so this is buffered against the item's arrival
|
|
68
|
+
rather than dropped or treated as an error.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
item_id: str
|
|
72
|
+
field: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DeltaApplyOutcome = Appended | BufferedForAbsentItem
|
|
76
|
+
"""What a delta did. Deltas bump no revision (tdd SS4.4.1)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class TerminalUnknownItemAnnotation:
|
|
81
|
+
"""Client-only annotation required when an ephemeral host dies abnormally."""
|
|
82
|
+
|
|
83
|
+
item_id: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _item_id(item: ItemLike) -> str:
|
|
87
|
+
return str(item["itemId"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _revision(item: ItemLike) -> int:
|
|
91
|
+
revision = item["revision"]
|
|
92
|
+
if not isinstance(revision, int):
|
|
93
|
+
raise TypeError(f"item revision is not an integer: {revision!r}")
|
|
94
|
+
return revision
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ItemStore:
|
|
98
|
+
"""Items in first-opened order (tdd SS4.9.1), each at its latest revision.
|
|
99
|
+
|
|
100
|
+
Holds per-field delta accumulators beside the items, and the
|
|
101
|
+
terminal-unknown annotation set an ephemeral host death produces.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
"""Create an empty store."""
|
|
106
|
+
self._items: dict[str, ItemLike] = {}
|
|
107
|
+
# item id -> field path -> accumulated text.
|
|
108
|
+
self._deltas: dict[str, dict[str, str]] = {}
|
|
109
|
+
# First-opened order; an item id appears exactly once.
|
|
110
|
+
self._order: list[str] = []
|
|
111
|
+
self._terminal_unknown: set[str] = set()
|
|
112
|
+
self._ephemeral_session_discarded = False
|
|
113
|
+
|
|
114
|
+
def apply(self, item: ItemLike) -> ItemApplyOutcome:
|
|
115
|
+
"""Apply an ``item/started`` | ``item/updated`` | ``item/completed`` payload.
|
|
116
|
+
|
|
117
|
+
Replace iff the incoming revision is strictly higher (INV-003).
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
item: The wire item object, verbatim.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
What the apply did.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
MuseSessionDiscardedError: The ephemeral session was discarded.
|
|
127
|
+
"""
|
|
128
|
+
self._assert_session_active()
|
|
129
|
+
item_id = _item_id(item)
|
|
130
|
+
held = self._items.get(item_id)
|
|
131
|
+
if held is None:
|
|
132
|
+
self._items[item_id] = item
|
|
133
|
+
self._order.append(item_id)
|
|
134
|
+
return Inserted(item_id)
|
|
135
|
+
held_revision = _revision(held)
|
|
136
|
+
offered = _revision(item)
|
|
137
|
+
if offered <= held_revision:
|
|
138
|
+
return IgnoredStaleRevision(item_id, held=held_revision, offered=offered)
|
|
139
|
+
self._items[item_id] = item
|
|
140
|
+
return Replaced(item_id, from_revision=held_revision, to_revision=offered)
|
|
141
|
+
|
|
142
|
+
def apply_delta(
|
|
143
|
+
self, item_id: str, delta: str, field: str = "text"
|
|
144
|
+
) -> DeltaApplyOutcome:
|
|
145
|
+
"""Apply an ``item/delta``: a lossless append to a field accumulator.
|
|
146
|
+
|
|
147
|
+
The concatenation of all deltas for a field path equals that field's
|
|
148
|
+
value on the final ``item/completed`` object (INV-004, tdd SS4.3.1).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
item_id: The delta's item.
|
|
152
|
+
delta: The appended text, verbatim.
|
|
153
|
+
field: The streamed field path (wire default ``"text"``).
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
``Appended`` for a held item, ``BufferedForAbsentItem`` otherwise.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
MuseSessionDiscardedError: The ephemeral session was discarded.
|
|
160
|
+
"""
|
|
161
|
+
self._assert_session_active()
|
|
162
|
+
fields = self._deltas.setdefault(item_id, {})
|
|
163
|
+
fields[field] = fields.get(field, "") + delta
|
|
164
|
+
if item_id not in self._items:
|
|
165
|
+
return BufferedForAbsentItem(item_id, field)
|
|
166
|
+
return Appended(item_id, field, length=len(fields[field]))
|
|
167
|
+
|
|
168
|
+
def accumulated(self, item_id: str, field: str = "text") -> str | None:
|
|
169
|
+
"""The accumulated delta text for a field path, or ``None`` if none."""
|
|
170
|
+
fields = self._deltas.get(item_id)
|
|
171
|
+
return None if fields is None else fields.get(field)
|
|
172
|
+
|
|
173
|
+
def accumulated_fields(self, item_id: str) -> List[str]:
|
|
174
|
+
"""Every field path that has accumulated deltas for this item, sorted."""
|
|
175
|
+
fields = self._deltas.get(item_id)
|
|
176
|
+
return [] if fields is None else sorted(fields)
|
|
177
|
+
|
|
178
|
+
def get(self, item_id: str) -> ItemLike | None:
|
|
179
|
+
"""The held item at its latest revision, or ``None``."""
|
|
180
|
+
return self._items.get(item_id)
|
|
181
|
+
|
|
182
|
+
def has(self, item_id: str) -> bool:
|
|
183
|
+
"""Whether the store holds this item."""
|
|
184
|
+
return item_id in self._items
|
|
185
|
+
|
|
186
|
+
def list(self) -> List[ItemLike]:
|
|
187
|
+
"""Items in first-opened order (tdd SS4.9.1)."""
|
|
188
|
+
return [self._items[item_id] for item_id in self._order if item_id in self._items]
|
|
189
|
+
|
|
190
|
+
def last_opened_item_id(self) -> str | None:
|
|
191
|
+
"""The id of the most recently opened item, or ``None`` when empty."""
|
|
192
|
+
return self._order[-1] if self._order else None
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def size(self) -> int:
|
|
196
|
+
"""How many items the store holds."""
|
|
197
|
+
return len(self._items)
|
|
198
|
+
|
|
199
|
+
def seed(self, items: Iterable[ItemLike]) -> None:
|
|
200
|
+
"""Seed from a snapshot's ``state.items`` (tdd SS4.9.1).
|
|
201
|
+
|
|
202
|
+
Seeding REPLACES the store — a snapshot is authoritative (tdd SS4.9),
|
|
203
|
+
never merged into stale local state. Delta accumulators are
|
|
204
|
+
intentionally NOT seeded: a snapshot carries streamed fields at their
|
|
205
|
+
accumulated-so-far values on the item itself, and ``view/page`` never
|
|
206
|
+
replays ephemeral-sourced deltas (tdd SS4.7.3).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
items: Every item at its latest revision, in first-opened order.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
MuseSessionDiscardedError: The ephemeral session was discarded.
|
|
213
|
+
"""
|
|
214
|
+
self._assert_session_active()
|
|
215
|
+
self._items.clear()
|
|
216
|
+
self._deltas.clear()
|
|
217
|
+
self._terminal_unknown.clear()
|
|
218
|
+
self._order.clear()
|
|
219
|
+
for item in items:
|
|
220
|
+
item_id = _item_id(item)
|
|
221
|
+
if item_id not in self._items:
|
|
222
|
+
self._order.append(item_id)
|
|
223
|
+
self._items[item_id] = item
|
|
224
|
+
|
|
225
|
+
def mark_ephemeral_host_death(
|
|
226
|
+
self, is_in_progress: Callable[[ItemLike], bool]
|
|
227
|
+
) -> List[TerminalUnknownItemAnnotation]:
|
|
228
|
+
"""Permanently discard an ephemeral session after abnormal host death.
|
|
229
|
+
|
|
230
|
+
The last wire item remains byte-for-byte intact: terminal-unknown is a
|
|
231
|
+
client display annotation, never a fabricated ``item/completed`` or
|
|
232
|
+
wire status (SS2.13.3b / SS4.4.3). Further events and snapshot seeding
|
|
233
|
+
are refused because this session has no resume surface.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
is_in_progress: The wire-aware probe (the store never knows wire
|
|
237
|
+
shapes, INV-638-01); the ``SessionFold`` binding passes
|
|
238
|
+
``item["status"] == "inProgress"`` against the generated
|
|
239
|
+
``Item``.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The terminal-unknown annotations, in first-opened item order.
|
|
243
|
+
"""
|
|
244
|
+
if not self._ephemeral_session_discarded:
|
|
245
|
+
for item in self._items.values():
|
|
246
|
+
if is_in_progress(item):
|
|
247
|
+
self._terminal_unknown.add(_item_id(item))
|
|
248
|
+
self._ephemeral_session_discarded = True
|
|
249
|
+
return [
|
|
250
|
+
TerminalUnknownItemAnnotation(item_id)
|
|
251
|
+
for item_id in self._order
|
|
252
|
+
if item_id in self._terminal_unknown
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
def is_terminal_unknown(self, item_id: str) -> bool:
|
|
256
|
+
"""Whether this item carries the terminal-unknown annotation."""
|
|
257
|
+
return item_id in self._terminal_unknown
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def ephemeral_session_discarded(self) -> bool:
|
|
261
|
+
"""Whether an ephemeral host death discarded this session."""
|
|
262
|
+
return self._ephemeral_session_discarded
|
|
263
|
+
|
|
264
|
+
def _assert_session_active(self) -> None:
|
|
265
|
+
if self._ephemeral_session_discarded:
|
|
266
|
+
raise MuseSessionDiscardedError(
|
|
267
|
+
"ephemeral session was discarded after abnormal host death"
|
|
268
|
+
)
|