bazaar-compute-node 0.1.3__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.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
import aiosqlite
|
|
8
|
+
|
|
9
|
+
from ...core.models import (
|
|
10
|
+
BcnSession,
|
|
11
|
+
ChannelSession,
|
|
12
|
+
ChannelTargetKind,
|
|
13
|
+
ConsumerCursor,
|
|
14
|
+
FreshCheckState,
|
|
15
|
+
InboundAttachment,
|
|
16
|
+
InboundMessage,
|
|
17
|
+
OutboundDeliveryState,
|
|
18
|
+
OutboundMessage,
|
|
19
|
+
RuntimeAttempt,
|
|
20
|
+
RuntimeEvent,
|
|
21
|
+
RuntimeEventState,
|
|
22
|
+
RuntimeSession,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _channel_session_from_row(row: aiosqlite.Row) -> ChannelSession:
|
|
27
|
+
following = row["following"]
|
|
28
|
+
if (
|
|
29
|
+
isinstance(following, bool)
|
|
30
|
+
or not isinstance(following, int)
|
|
31
|
+
or following not in (0, 1)
|
|
32
|
+
):
|
|
33
|
+
raise ValueError("channel session following value is invalid")
|
|
34
|
+
return ChannelSession(
|
|
35
|
+
id=_required_text(row["id"], "id"),
|
|
36
|
+
channel=_required_text(row["channel"], "channel"),
|
|
37
|
+
provider_thread_id=_required_text(
|
|
38
|
+
row["provider_thread_id"], "provider_thread_id"
|
|
39
|
+
),
|
|
40
|
+
created_at_ms=_required_non_negative_int(row["created_at_ms"], "created_at_ms"),
|
|
41
|
+
updated_at_ms=_required_non_negative_int(row["updated_at_ms"], "updated_at_ms"),
|
|
42
|
+
target_kind=ChannelTargetKind(
|
|
43
|
+
_required_text(row["target_kind"], "channel_session.target_kind")
|
|
44
|
+
),
|
|
45
|
+
following=bool(following),
|
|
46
|
+
last_inbound_at_ms=_optional_non_negative_int(
|
|
47
|
+
row["last_inbound_at_ms"], "last_inbound_at_ms"
|
|
48
|
+
),
|
|
49
|
+
last_outbound_at_ms=_optional_non_negative_int(
|
|
50
|
+
row["last_outbound_at_ms"], "last_outbound_at_ms"
|
|
51
|
+
),
|
|
52
|
+
metadata=_decode_metadata(
|
|
53
|
+
row["provider_identity_ref_json"], "provider_identity_ref_json"
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _bcn_session_from_row(row: aiosqlite.Row) -> BcnSession:
|
|
59
|
+
return BcnSession(
|
|
60
|
+
id=_required_text(row["id"], "id"),
|
|
61
|
+
channel_session_id=_required_text(
|
|
62
|
+
row["channel_session_id"], "channel_session_id"
|
|
63
|
+
),
|
|
64
|
+
workspace_id=_required_text(row["workspace_id"], "workspace_id"),
|
|
65
|
+
created_at_ms=_required_non_negative_int(row["created_at_ms"], "created_at_ms"),
|
|
66
|
+
updated_at_ms=_required_non_negative_int(row["updated_at_ms"], "updated_at_ms"),
|
|
67
|
+
last_activity_at_ms=_optional_non_negative_int(
|
|
68
|
+
row["last_activity_at_ms"], "last_activity_at_ms"
|
|
69
|
+
),
|
|
70
|
+
metadata=_decode_metadata(row["metadata_json"], "metadata_json"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _runtime_session_from_row(row: aiosqlite.Row) -> RuntimeSession:
|
|
75
|
+
return RuntimeSession(
|
|
76
|
+
id=_required_text(row["id"], "id"),
|
|
77
|
+
bcn_session_id=_required_text(row["bcn_session_id"], "bcn_session_id"),
|
|
78
|
+
channel_session_id=_required_text(
|
|
79
|
+
row["channel_session_id"], "channel_session_id"
|
|
80
|
+
),
|
|
81
|
+
runtime=_required_text(row["runtime"], "runtime"),
|
|
82
|
+
workspace_id=_required_text(row["workspace_id"], "workspace_id"),
|
|
83
|
+
created_at_ms=_required_non_negative_int(row["created_at_ms"], "created_at_ms"),
|
|
84
|
+
updated_at_ms=_required_non_negative_int(row["updated_at_ms"], "updated_at_ms"),
|
|
85
|
+
provider_thread_id=_optional_text(
|
|
86
|
+
row["provider_thread_id"], "provider_thread_id"
|
|
87
|
+
),
|
|
88
|
+
metadata=_decode_metadata(row["metadata_json"], "metadata_json"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _runtime_attempt_from_row(row: aiosqlite.Row) -> RuntimeAttempt:
|
|
93
|
+
return RuntimeAttempt(
|
|
94
|
+
turn_id=_required_text(row["turn_id"], "turn_id"),
|
|
95
|
+
session_id=_required_text(row["session_id"], "session_id"),
|
|
96
|
+
client_user_message_id=_required_text(
|
|
97
|
+
row["client_user_message_id"], "client_user_message_id"
|
|
98
|
+
),
|
|
99
|
+
started_at_ms=_required_non_negative_int(row["started_at_ms"], "started_at_ms"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _inbound_message_from_row(
|
|
104
|
+
row: aiosqlite.Row,
|
|
105
|
+
attachments: tuple[InboundAttachment, ...] = (),
|
|
106
|
+
) -> InboundMessage:
|
|
107
|
+
return InboundMessage(
|
|
108
|
+
seq=_required_non_negative_int(row["seq"], "seq"),
|
|
109
|
+
message_id=_required_text(row["message_id"], "message_id"),
|
|
110
|
+
session_id=_required_text(row["session_id"], "session_id"),
|
|
111
|
+
channel_session_id=_required_text(
|
|
112
|
+
row["channel_session_id"], "channel_session_id"
|
|
113
|
+
),
|
|
114
|
+
channel=_required_text(row["channel"], "channel"),
|
|
115
|
+
provider_thread_id=_required_text(
|
|
116
|
+
row["provider_thread_id"], "provider_thread_id"
|
|
117
|
+
),
|
|
118
|
+
provider_message_id=_required_text(
|
|
119
|
+
row["provider_message_id"], "provider_message_id"
|
|
120
|
+
),
|
|
121
|
+
received_at_ms=_required_non_negative_int(
|
|
122
|
+
row["received_at_ms"], "received_at_ms"
|
|
123
|
+
),
|
|
124
|
+
sender=_optional_text(row["sender"], "sender"),
|
|
125
|
+
message_type=_required_text(row["message_type"], "message_type"),
|
|
126
|
+
canonical_target=_required_text(row["canonical_target"], "canonical_target"),
|
|
127
|
+
body=_string_value(row["body"], "body", allow_empty=True),
|
|
128
|
+
target_kind=ChannelTargetKind(
|
|
129
|
+
_required_text(row["target_kind"], "inbound_message.target_kind")
|
|
130
|
+
),
|
|
131
|
+
mentions_agent=bool(_required_boolean(row["mentions_agent"], "mentions_agent")),
|
|
132
|
+
notifies_runtime=bool(
|
|
133
|
+
_required_boolean(row["notifies_runtime"], "notifies_runtime")
|
|
134
|
+
),
|
|
135
|
+
attachments=attachments,
|
|
136
|
+
provider_time_ms=_optional_non_negative_int(
|
|
137
|
+
row["provider_time_ms"], "provider_time_ms"
|
|
138
|
+
),
|
|
139
|
+
reply_to_message_id=_optional_string_value(
|
|
140
|
+
row["reply_to_message_id"],
|
|
141
|
+
"reply_to_message_id",
|
|
142
|
+
allow_empty=False,
|
|
143
|
+
),
|
|
144
|
+
provider_payload_ref=_optional_string_value(
|
|
145
|
+
row["provider_payload_ref"], "provider_payload_ref", allow_empty=False
|
|
146
|
+
),
|
|
147
|
+
metadata=_decode_metadata(row["metadata_json"], "metadata_json"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _inbound_attachment_from_row(row: aiosqlite.Row) -> InboundAttachment:
|
|
152
|
+
return InboundAttachment(
|
|
153
|
+
attachment_id=_required_text(row["attachment_id"], "attachment_id"),
|
|
154
|
+
name=_required_text(row["name"], "attachment.name"),
|
|
155
|
+
kind=_required_text(row["kind"], "attachment.kind"),
|
|
156
|
+
state=_required_text(row["state"], "attachment.state"),
|
|
157
|
+
media_type=_optional_text(row["media_type"], "attachment.media_type"),
|
|
158
|
+
relative_path=_optional_text(row["relative_path"], "attachment.relative_path"),
|
|
159
|
+
size_bytes=_optional_non_negative_int(
|
|
160
|
+
row["size_bytes"], "attachment.size_bytes"
|
|
161
|
+
),
|
|
162
|
+
error=_optional_text(row["error"], "attachment.error"),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _outbound_message_from_row(row: aiosqlite.Row) -> OutboundMessage:
|
|
167
|
+
return OutboundMessage(
|
|
168
|
+
outbound_message_id=_required_text(
|
|
169
|
+
row["outbound_message_id"], "outbound_message_id"
|
|
170
|
+
),
|
|
171
|
+
command_id=_required_text(row["command_id"], "command_id"),
|
|
172
|
+
session_id=_required_text(row["session_id"], "session_id"),
|
|
173
|
+
channel_session_id=_required_text(
|
|
174
|
+
row["channel_session_id"], "channel_session_id"
|
|
175
|
+
),
|
|
176
|
+
target=_required_text(row["target"], "target"),
|
|
177
|
+
body=_string_value(row["body"], "body", allow_empty=True),
|
|
178
|
+
reply_to_message_id=_optional_text(
|
|
179
|
+
row["reply_to_message_id"],
|
|
180
|
+
"reply_to_message_id",
|
|
181
|
+
),
|
|
182
|
+
state=OutboundDeliveryState(
|
|
183
|
+
_required_text(row["state"], "outbound_message.state")
|
|
184
|
+
),
|
|
185
|
+
fresh_check_state=FreshCheckState(
|
|
186
|
+
_required_text(
|
|
187
|
+
row["fresh_check_state"], "outbound_message.fresh_check_state"
|
|
188
|
+
)
|
|
189
|
+
),
|
|
190
|
+
created_at_ms=_required_non_negative_int(row["created_at_ms"], "created_at_ms"),
|
|
191
|
+
snapshot_seq=_optional_non_negative_int(row["snapshot_seq"], "snapshot_seq"),
|
|
192
|
+
current_inbound_seq=_optional_non_negative_int(
|
|
193
|
+
row["current_inbound_seq"], "current_inbound_seq"
|
|
194
|
+
),
|
|
195
|
+
provider_message_id=_optional_text(
|
|
196
|
+
row["provider_message_id"], "provider_message_id"
|
|
197
|
+
),
|
|
198
|
+
provider_receipt_ref=_optional_text(
|
|
199
|
+
row["provider_receipt_ref"], "provider_receipt_ref"
|
|
200
|
+
),
|
|
201
|
+
provider_attempted_at_ms=_optional_non_negative_int(
|
|
202
|
+
row["provider_attempted_at_ms"], "provider_attempted_at_ms"
|
|
203
|
+
),
|
|
204
|
+
completed_at_ms=_optional_non_negative_int(
|
|
205
|
+
row["completed_at_ms"], "completed_at_ms"
|
|
206
|
+
),
|
|
207
|
+
draft_saved_at_ms=_optional_non_negative_int(
|
|
208
|
+
row["draft_saved_at_ms"], "draft_saved_at_ms"
|
|
209
|
+
),
|
|
210
|
+
error_kind=_optional_text(row["error_kind"], "error_kind"),
|
|
211
|
+
error_message=_optional_text(row["error_message"], "error_message"),
|
|
212
|
+
next_action=_optional_text(row["next_action"], "next_action"),
|
|
213
|
+
metadata=_decode_metadata(row["metadata_json"], "metadata_json"),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _runtime_event_from_row(row: aiosqlite.Row) -> RuntimeEvent:
|
|
218
|
+
return RuntimeEvent(
|
|
219
|
+
event_seq=_required_non_negative_int(row["event_seq"], "event_seq"),
|
|
220
|
+
event_id=_required_text(row["event_id"], "event_id"),
|
|
221
|
+
created_at_ms=_required_non_negative_int(row["created_at_ms"], "created_at_ms"),
|
|
222
|
+
level=_required_text(row["level"], "level"),
|
|
223
|
+
event_name=_required_text(row["event_name"], "event_name"),
|
|
224
|
+
state=RuntimeEventState(_required_text(row["state"], "runtime_event.state")),
|
|
225
|
+
duration_ms=_optional_non_negative_int(row["duration_ms"], "duration_ms"),
|
|
226
|
+
node_id=_optional_text(row["node_id"], "node_id"),
|
|
227
|
+
channel=_optional_text(row["channel"], "channel"),
|
|
228
|
+
runtime=_optional_text(row["runtime"], "runtime"),
|
|
229
|
+
channel_session_id=_optional_text(
|
|
230
|
+
row["channel_session_id"], "channel_session_id"
|
|
231
|
+
),
|
|
232
|
+
bcn_session_id=_optional_text(row["bcn_session_id"], "bcn_session_id"),
|
|
233
|
+
runtime_session_id=_optional_text(
|
|
234
|
+
row["runtime_session_id"], "runtime_session_id"
|
|
235
|
+
),
|
|
236
|
+
turn_id=_optional_text(row["turn_id"], "turn_id"),
|
|
237
|
+
request_id=_optional_text(row["request_id"], "request_id"),
|
|
238
|
+
command_id=_optional_text(row["command_id"], "command_id"),
|
|
239
|
+
inbound_seq=_optional_non_negative_int(row["inbound_seq"], "inbound_seq"),
|
|
240
|
+
outbound_message_id=_optional_text(
|
|
241
|
+
row["outbound_message_id"], "outbound_message_id"
|
|
242
|
+
),
|
|
243
|
+
error_kind=_optional_text(row["error_kind"], "error_kind"),
|
|
244
|
+
error_type=_optional_text(row["error_type"], "error_type"),
|
|
245
|
+
error_message=_optional_text(row["error_message"], "error_message"),
|
|
246
|
+
traceback_ref=_optional_text(row["traceback_ref"], "traceback_ref"),
|
|
247
|
+
metadata=_decode_metadata(row["metadata_json"], "metadata_json"),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _consumer_cursor_from_row(row: aiosqlite.Row) -> ConsumerCursor:
|
|
252
|
+
return ConsumerCursor(
|
|
253
|
+
session_id=_required_text(row["session_id"], "session_id"),
|
|
254
|
+
delivered_through_seq=_required_non_negative_int(
|
|
255
|
+
row["delivered_through_seq"], "delivered_through_seq"
|
|
256
|
+
),
|
|
257
|
+
inbox_snapshot_seq=_optional_non_negative_int(
|
|
258
|
+
row["inbox_snapshot_seq"], "inbox_snapshot_seq"
|
|
259
|
+
),
|
|
260
|
+
inbox_snapshot_source=_optional_string_value(
|
|
261
|
+
row["inbox_snapshot_source"],
|
|
262
|
+
"inbox_snapshot_source",
|
|
263
|
+
allow_empty=False,
|
|
264
|
+
),
|
|
265
|
+
inbox_snapshot_at_ms=_optional_non_negative_int(
|
|
266
|
+
row["inbox_snapshot_at_ms"], "inbox_snapshot_at_ms"
|
|
267
|
+
),
|
|
268
|
+
last_check_at_ms=_optional_non_negative_int(
|
|
269
|
+
row["last_check_at_ms"], "last_check_at_ms"
|
|
270
|
+
),
|
|
271
|
+
last_read_at_ms=_optional_non_negative_int(
|
|
272
|
+
row["last_read_at_ms"], "last_read_at_ms"
|
|
273
|
+
),
|
|
274
|
+
updated_at_ms=_required_non_negative_int(row["updated_at_ms"], "updated_at_ms"),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _validate_inbound_message_input(message: InboundMessage) -> None:
|
|
279
|
+
if not isinstance(message, InboundMessage):
|
|
280
|
+
raise TypeError("message must be an InboundMessage")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _validate_outbound_message_input(message: OutboundMessage) -> None:
|
|
284
|
+
if not isinstance(message, OutboundMessage):
|
|
285
|
+
raise TypeError("message must be an OutboundMessage")
|
|
286
|
+
if not isinstance(message.state, OutboundDeliveryState):
|
|
287
|
+
raise TypeError("outbound message state is invalid")
|
|
288
|
+
if not isinstance(message.fresh_check_state, FreshCheckState):
|
|
289
|
+
raise TypeError("outbound fresh-check state is invalid")
|
|
290
|
+
if not isinstance(message.body, str):
|
|
291
|
+
raise TypeError("outbound body must be a string")
|
|
292
|
+
for value, field_name in (
|
|
293
|
+
(message.reply_to_message_id, "reply_to_message_id"),
|
|
294
|
+
(message.provider_message_id, "provider_message_id"),
|
|
295
|
+
(message.provider_receipt_ref, "provider_receipt_ref"),
|
|
296
|
+
(message.error_kind, "error_kind"),
|
|
297
|
+
(message.error_message, "error_message"),
|
|
298
|
+
(message.next_action, "next_action"),
|
|
299
|
+
):
|
|
300
|
+
_validate_optional_input_text(value, field_name)
|
|
301
|
+
if message.fresh_check_state is FreshCheckState.REQUIRED and (
|
|
302
|
+
message.snapshot_seq is not None or message.current_inbound_seq is not None
|
|
303
|
+
):
|
|
304
|
+
raise ValueError("a required outbound fresh check cannot contain evidence")
|
|
305
|
+
if message.fresh_check_state is FreshCheckState.PASSED:
|
|
306
|
+
if message.snapshot_seq is None or message.current_inbound_seq is None:
|
|
307
|
+
raise ValueError("a passed outbound fresh check requires sequence bounds")
|
|
308
|
+
if message.current_inbound_seq > message.snapshot_seq:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
"outbound current inbound sequence exceeds snapshot sequence"
|
|
311
|
+
)
|
|
312
|
+
if (
|
|
313
|
+
message.state
|
|
314
|
+
in {
|
|
315
|
+
OutboundDeliveryState.PENDING,
|
|
316
|
+
OutboundDeliveryState.QUEUED,
|
|
317
|
+
OutboundDeliveryState.SENT,
|
|
318
|
+
OutboundDeliveryState.PARTIAL,
|
|
319
|
+
OutboundDeliveryState.FAILED,
|
|
320
|
+
OutboundDeliveryState.UNKNOWN,
|
|
321
|
+
}
|
|
322
|
+
and message.fresh_check_state is not FreshCheckState.PASSED
|
|
323
|
+
):
|
|
324
|
+
raise ValueError("outbound delivery state requires a passed fresh check")
|
|
325
|
+
if (
|
|
326
|
+
message.state is OutboundDeliveryState.REJECTED
|
|
327
|
+
and message.fresh_check_state is FreshCheckState.PASSED
|
|
328
|
+
):
|
|
329
|
+
raise ValueError("rejected outbound message cannot have a passed fresh check")
|
|
330
|
+
for value, field_name in (
|
|
331
|
+
(message.provider_attempted_at_ms, "provider_attempted_at_ms"),
|
|
332
|
+
(message.completed_at_ms, "completed_at_ms"),
|
|
333
|
+
(message.draft_saved_at_ms, "draft_saved_at_ms"),
|
|
334
|
+
):
|
|
335
|
+
if value is not None and value < message.created_at_ms:
|
|
336
|
+
raise ValueError(f"outbound {field_name} cannot precede creation")
|
|
337
|
+
if message.state is OutboundDeliveryState.DRAFT and any(
|
|
338
|
+
value is not None
|
|
339
|
+
for value in (
|
|
340
|
+
message.provider_message_id,
|
|
341
|
+
message.provider_receipt_ref,
|
|
342
|
+
message.provider_attempted_at_ms,
|
|
343
|
+
message.completed_at_ms,
|
|
344
|
+
message.draft_saved_at_ms,
|
|
345
|
+
)
|
|
346
|
+
):
|
|
347
|
+
raise ValueError("draft outbound message cannot contain delivery evidence")
|
|
348
|
+
if message.state in {
|
|
349
|
+
OutboundDeliveryState.PENDING,
|
|
350
|
+
OutboundDeliveryState.QUEUED,
|
|
351
|
+
} and (
|
|
352
|
+
message.completed_at_ms is not None or message.draft_saved_at_ms is not None
|
|
353
|
+
):
|
|
354
|
+
raise ValueError("non-terminal outbound message cannot be terminal")
|
|
355
|
+
if message.state is OutboundDeliveryState.REJECTED and any(
|
|
356
|
+
value is not None
|
|
357
|
+
for value in (
|
|
358
|
+
message.provider_message_id,
|
|
359
|
+
message.provider_receipt_ref,
|
|
360
|
+
message.provider_attempted_at_ms,
|
|
361
|
+
)
|
|
362
|
+
):
|
|
363
|
+
raise ValueError("rejected outbound message cannot contain provider evidence")
|
|
364
|
+
if (
|
|
365
|
+
message.state
|
|
366
|
+
in {
|
|
367
|
+
OutboundDeliveryState.SENT,
|
|
368
|
+
OutboundDeliveryState.PARTIAL,
|
|
369
|
+
OutboundDeliveryState.FAILED,
|
|
370
|
+
OutboundDeliveryState.UNKNOWN,
|
|
371
|
+
OutboundDeliveryState.REJECTED,
|
|
372
|
+
}
|
|
373
|
+
and message.completed_at_ms is None
|
|
374
|
+
):
|
|
375
|
+
raise ValueError("terminal outbound message requires completed_at_ms")
|
|
376
|
+
if (
|
|
377
|
+
message.state is OutboundDeliveryState.REJECTED
|
|
378
|
+
and message.draft_saved_at_ms is None
|
|
379
|
+
):
|
|
380
|
+
raise ValueError("rejected outbound message requires draft_saved_at_ms")
|
|
381
|
+
if (
|
|
382
|
+
message.state in {OutboundDeliveryState.SENT, OutboundDeliveryState.PARTIAL}
|
|
383
|
+
and message.provider_message_id is None
|
|
384
|
+
and message.provider_receipt_ref is None
|
|
385
|
+
):
|
|
386
|
+
raise ValueError("delivered outbound message requires a provider receipt")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _validate_outbound_insert(message: OutboundMessage) -> None:
|
|
390
|
+
if message.state is not OutboundDeliveryState.DRAFT:
|
|
391
|
+
raise ValueError("a new outbound message must start in draft state")
|
|
392
|
+
if message.fresh_check_state is not FreshCheckState.REQUIRED:
|
|
393
|
+
raise ValueError("a new outbound draft requires a required fresh check")
|
|
394
|
+
if any(
|
|
395
|
+
value is not None
|
|
396
|
+
for value in (
|
|
397
|
+
message.provider_message_id,
|
|
398
|
+
message.provider_receipt_ref,
|
|
399
|
+
message.provider_attempted_at_ms,
|
|
400
|
+
message.completed_at_ms,
|
|
401
|
+
message.draft_saved_at_ms,
|
|
402
|
+
)
|
|
403
|
+
):
|
|
404
|
+
raise ValueError("a new outbound draft cannot contain delivery timestamps")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _validate_outbound_update(
|
|
408
|
+
existing: OutboundMessage,
|
|
409
|
+
incoming: OutboundMessage,
|
|
410
|
+
) -> OutboundMessage:
|
|
411
|
+
candidate = existing
|
|
412
|
+
sequence_changed = (
|
|
413
|
+
incoming.snapshot_seq != existing.snapshot_seq
|
|
414
|
+
or incoming.current_inbound_seq != existing.current_inbound_seq
|
|
415
|
+
)
|
|
416
|
+
fresh_state_changed = incoming.fresh_check_state is not existing.fresh_check_state
|
|
417
|
+
if existing.fresh_check_state is FreshCheckState.REQUIRED:
|
|
418
|
+
if fresh_state_changed or sequence_changed:
|
|
419
|
+
candidate = existing.record_fresh_check(
|
|
420
|
+
incoming.fresh_check_state,
|
|
421
|
+
snapshot_seq=incoming.snapshot_seq,
|
|
422
|
+
current_inbound_seq=incoming.current_inbound_seq,
|
|
423
|
+
)
|
|
424
|
+
elif fresh_state_changed or sequence_changed:
|
|
425
|
+
raise ValueError("outbound fresh-check evidence cannot change")
|
|
426
|
+
|
|
427
|
+
if candidate.state is incoming.state:
|
|
428
|
+
transitioned = candidate
|
|
429
|
+
else:
|
|
430
|
+
transitioned = candidate.transition_to(
|
|
431
|
+
incoming.state,
|
|
432
|
+
at_ms=_outbound_transition_time(incoming),
|
|
433
|
+
provider_message_id=incoming.provider_message_id,
|
|
434
|
+
provider_receipt_ref=incoming.provider_receipt_ref,
|
|
435
|
+
error_kind=incoming.error_kind,
|
|
436
|
+
error_message=incoming.error_message,
|
|
437
|
+
next_action=incoming.next_action,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if (
|
|
441
|
+
transitioned.completed_at_ms is not None
|
|
442
|
+
and incoming.completed_at_ms is not None
|
|
443
|
+
and transitioned.completed_at_ms != incoming.completed_at_ms
|
|
444
|
+
):
|
|
445
|
+
raise ValueError("outbound completion time cannot change")
|
|
446
|
+
if (
|
|
447
|
+
transitioned.draft_saved_at_ms is not None
|
|
448
|
+
and incoming.draft_saved_at_ms is not None
|
|
449
|
+
and transitioned.draft_saved_at_ms != incoming.draft_saved_at_ms
|
|
450
|
+
):
|
|
451
|
+
raise ValueError("outbound draft time cannot change")
|
|
452
|
+
return replace(
|
|
453
|
+
transitioned,
|
|
454
|
+
provider_message_id=_merge_optional_text(
|
|
455
|
+
transitioned.provider_message_id,
|
|
456
|
+
incoming.provider_message_id,
|
|
457
|
+
"provider_message_id",
|
|
458
|
+
),
|
|
459
|
+
provider_receipt_ref=_merge_optional_text(
|
|
460
|
+
transitioned.provider_receipt_ref,
|
|
461
|
+
incoming.provider_receipt_ref,
|
|
462
|
+
"provider_receipt_ref",
|
|
463
|
+
),
|
|
464
|
+
provider_attempted_at_ms=_merge_timestamp(
|
|
465
|
+
transitioned.provider_attempted_at_ms,
|
|
466
|
+
incoming.provider_attempted_at_ms,
|
|
467
|
+
"provider_attempted_at_ms",
|
|
468
|
+
),
|
|
469
|
+
completed_at_ms=transitioned.completed_at_ms
|
|
470
|
+
if transitioned.completed_at_ms is not None
|
|
471
|
+
else incoming.completed_at_ms,
|
|
472
|
+
draft_saved_at_ms=transitioned.draft_saved_at_ms
|
|
473
|
+
if transitioned.draft_saved_at_ms is not None
|
|
474
|
+
else incoming.draft_saved_at_ms,
|
|
475
|
+
error_kind=incoming.error_kind or transitioned.error_kind,
|
|
476
|
+
error_message=incoming.error_message or transitioned.error_message,
|
|
477
|
+
next_action=incoming.next_action or transitioned.next_action,
|
|
478
|
+
metadata=incoming.metadata,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _outbound_transition_time(message: OutboundMessage) -> int:
|
|
483
|
+
return (
|
|
484
|
+
message.completed_at_ms
|
|
485
|
+
or message.draft_saved_at_ms
|
|
486
|
+
or message.provider_attempted_at_ms
|
|
487
|
+
or message.created_at_ms
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _merge_optional_text(
|
|
492
|
+
existing: str | None,
|
|
493
|
+
incoming: str | None,
|
|
494
|
+
field_name: str,
|
|
495
|
+
) -> str | None:
|
|
496
|
+
if existing is not None and incoming is not None and existing != incoming:
|
|
497
|
+
raise ValueError(f"outbound {field_name} cannot change")
|
|
498
|
+
return incoming or existing
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _merge_timestamp(
|
|
502
|
+
existing: int | None,
|
|
503
|
+
incoming: int | None,
|
|
504
|
+
field_name: str,
|
|
505
|
+
) -> int | None:
|
|
506
|
+
if existing is not None and incoming is not None and existing != incoming:
|
|
507
|
+
raise ValueError(f"outbound {field_name} cannot change")
|
|
508
|
+
return incoming if incoming is not None else existing
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _validate_runtime_event_input(event: RuntimeEvent) -> None:
|
|
512
|
+
if not isinstance(event, RuntimeEvent):
|
|
513
|
+
raise TypeError("event must be a RuntimeEvent")
|
|
514
|
+
if not isinstance(event.state, RuntimeEventState):
|
|
515
|
+
raise TypeError("runtime event state is invalid")
|
|
516
|
+
for value, field_name in (
|
|
517
|
+
(event.node_id, "node_id"),
|
|
518
|
+
(event.channel, "channel"),
|
|
519
|
+
(event.runtime, "runtime"),
|
|
520
|
+
(event.channel_session_id, "channel_session_id"),
|
|
521
|
+
(event.bcn_session_id, "bcn_session_id"),
|
|
522
|
+
(event.runtime_session_id, "runtime_session_id"),
|
|
523
|
+
(event.turn_id, "turn_id"),
|
|
524
|
+
(event.request_id, "request_id"),
|
|
525
|
+
(event.command_id, "command_id"),
|
|
526
|
+
(event.outbound_message_id, "outbound_message_id"),
|
|
527
|
+
(event.error_kind, "error_kind"),
|
|
528
|
+
(event.error_type, "error_type"),
|
|
529
|
+
(event.error_message, "error_message"),
|
|
530
|
+
(event.traceback_ref, "traceback_ref"),
|
|
531
|
+
):
|
|
532
|
+
_validate_optional_input_text(value, field_name)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _same_runtime_event_payload(
|
|
536
|
+
existing: RuntimeEvent,
|
|
537
|
+
incoming: RuntimeEvent,
|
|
538
|
+
) -> bool:
|
|
539
|
+
return replace(existing, event_seq=incoming.event_seq) == incoming
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _validate_optional_input_text(value: object, field_name: str) -> None:
|
|
543
|
+
if value is not None and (not isinstance(value, str) or not value):
|
|
544
|
+
raise ValueError(f"{field_name} must be a non-empty string when present")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _validate_consumer_cursor_input(cursor: ConsumerCursor) -> None:
|
|
548
|
+
if not isinstance(cursor, ConsumerCursor):
|
|
549
|
+
raise TypeError("cursor must be a ConsumerCursor")
|
|
550
|
+
source = cursor.inbox_snapshot_source
|
|
551
|
+
if source is not None and source not in {"check", "read"}:
|
|
552
|
+
raise ValueError("inbox_snapshot_source must be 'check' or 'read'")
|
|
553
|
+
if cursor.inbox_snapshot_seq is None:
|
|
554
|
+
if cursor.inbox_snapshot_source is not None:
|
|
555
|
+
raise ValueError("inbox snapshot source requires a snapshot sequence")
|
|
556
|
+
if cursor.inbox_snapshot_at_ms is not None:
|
|
557
|
+
raise ValueError("inbox snapshot time requires a snapshot sequence")
|
|
558
|
+
elif cursor.inbox_snapshot_at_ms is None:
|
|
559
|
+
raise ValueError("inbox snapshot sequence requires a snapshot time")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _validate_cursor_bounds(cursor: ConsumerCursor, latest_seq: int) -> None:
|
|
563
|
+
if cursor.delivered_through_seq > latest_seq:
|
|
564
|
+
raise ValueError("delivered cursor cannot exceed the latest inbound sequence")
|
|
565
|
+
if cursor.inbox_snapshot_seq is not None and cursor.inbox_snapshot_seq > latest_seq:
|
|
566
|
+
raise ValueError("inbox snapshot cannot exceed the latest inbound sequence")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _validate_consumer_cursor_update(
|
|
570
|
+
existing: ConsumerCursor,
|
|
571
|
+
incoming: ConsumerCursor,
|
|
572
|
+
) -> None:
|
|
573
|
+
if incoming.updated_at_ms < existing.updated_at_ms:
|
|
574
|
+
raise ValueError("consumer cursor updated_at_ms cannot move backwards")
|
|
575
|
+
if incoming.delivered_through_seq < existing.delivered_through_seq:
|
|
576
|
+
raise ValueError("delivered cursor cannot move backwards")
|
|
577
|
+
if existing.inbox_snapshot_seq is not None and (
|
|
578
|
+
incoming.inbox_snapshot_seq is None
|
|
579
|
+
or incoming.inbox_snapshot_seq < existing.inbox_snapshot_seq
|
|
580
|
+
):
|
|
581
|
+
raise ValueError("inbox snapshot cannot move backwards")
|
|
582
|
+
if (
|
|
583
|
+
incoming.inbox_snapshot_source == "read"
|
|
584
|
+
and incoming.delivered_through_seq != existing.delivered_through_seq
|
|
585
|
+
):
|
|
586
|
+
raise ValueError("read snapshot cannot advance the delivered cursor")
|
|
587
|
+
for incoming_value, existing_value, field_name in (
|
|
588
|
+
(
|
|
589
|
+
incoming.inbox_snapshot_at_ms,
|
|
590
|
+
existing.inbox_snapshot_at_ms,
|
|
591
|
+
"inbox_snapshot_at_ms",
|
|
592
|
+
),
|
|
593
|
+
(incoming.last_check_at_ms, existing.last_check_at_ms, "last_check_at_ms"),
|
|
594
|
+
(incoming.last_read_at_ms, existing.last_read_at_ms, "last_read_at_ms"),
|
|
595
|
+
):
|
|
596
|
+
if (
|
|
597
|
+
incoming_value is not None
|
|
598
|
+
and existing_value is not None
|
|
599
|
+
and incoming_value < existing_value
|
|
600
|
+
):
|
|
601
|
+
raise ValueError(f"{field_name} cannot move backwards")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _validate_channel_session_input(session: ChannelSession) -> None:
|
|
605
|
+
if not isinstance(session.following, bool):
|
|
606
|
+
raise TypeError("channel session following must be a boolean")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _validate_channel_session_update(
|
|
610
|
+
existing: ChannelSession,
|
|
611
|
+
incoming: ChannelSession,
|
|
612
|
+
) -> ChannelSession:
|
|
613
|
+
if (
|
|
614
|
+
existing.channel != incoming.channel
|
|
615
|
+
or existing.provider_thread_id != incoming.provider_thread_id
|
|
616
|
+
or existing.target_kind is not incoming.target_kind
|
|
617
|
+
or existing.created_at_ms != incoming.created_at_ms
|
|
618
|
+
):
|
|
619
|
+
raise ValueError("channel session identity cannot change")
|
|
620
|
+
_validate_updated_at(existing.updated_at_ms, incoming.updated_at_ms)
|
|
621
|
+
return replace(
|
|
622
|
+
existing,
|
|
623
|
+
updated_at_ms=incoming.updated_at_ms,
|
|
624
|
+
following=incoming.following,
|
|
625
|
+
last_inbound_at_ms=incoming.last_inbound_at_ms,
|
|
626
|
+
last_outbound_at_ms=incoming.last_outbound_at_ms,
|
|
627
|
+
metadata=incoming.metadata,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _validate_bcn_session_update(
|
|
632
|
+
existing: BcnSession,
|
|
633
|
+
incoming: BcnSession,
|
|
634
|
+
) -> BcnSession:
|
|
635
|
+
if (
|
|
636
|
+
existing.channel_session_id != incoming.channel_session_id
|
|
637
|
+
or existing.workspace_id != incoming.workspace_id
|
|
638
|
+
or existing.created_at_ms != incoming.created_at_ms
|
|
639
|
+
):
|
|
640
|
+
raise ValueError("bcn session binding cannot change")
|
|
641
|
+
_validate_updated_at(existing.updated_at_ms, incoming.updated_at_ms)
|
|
642
|
+
return replace(
|
|
643
|
+
existing,
|
|
644
|
+
updated_at_ms=incoming.updated_at_ms,
|
|
645
|
+
last_activity_at_ms=incoming.last_activity_at_ms,
|
|
646
|
+
metadata=incoming.metadata,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _validate_runtime_session_update(
|
|
651
|
+
existing: RuntimeSession,
|
|
652
|
+
incoming: RuntimeSession,
|
|
653
|
+
) -> RuntimeSession:
|
|
654
|
+
if (
|
|
655
|
+
existing.bcn_session_id != incoming.bcn_session_id
|
|
656
|
+
or existing.channel_session_id != incoming.channel_session_id
|
|
657
|
+
or existing.runtime != incoming.runtime
|
|
658
|
+
or existing.workspace_id != incoming.workspace_id
|
|
659
|
+
or existing.created_at_ms != incoming.created_at_ms
|
|
660
|
+
):
|
|
661
|
+
raise ValueError("runtime session binding cannot change")
|
|
662
|
+
_validate_updated_at(existing.updated_at_ms, incoming.updated_at_ms)
|
|
663
|
+
return replace(
|
|
664
|
+
existing,
|
|
665
|
+
updated_at_ms=incoming.updated_at_ms,
|
|
666
|
+
provider_thread_id=incoming.provider_thread_id,
|
|
667
|
+
metadata=incoming.metadata,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _validate_updated_at(existing: int, incoming: int) -> None:
|
|
672
|
+
if incoming < existing:
|
|
673
|
+
raise ValueError("session updated_at_ms cannot move backwards")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _required_text(value: object, field_name: str) -> str:
|
|
677
|
+
return _string_value(value, field_name, allow_empty=False)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _validate_non_empty_text(value: object, field_name: str) -> None:
|
|
681
|
+
_required_text(value, field_name)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _validate_non_negative_int(value: object, field_name: str) -> None:
|
|
685
|
+
_required_non_negative_int(value, field_name)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _validate_positive_int(value: object, field_name: str) -> None:
|
|
689
|
+
_required_positive_int(value, field_name)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _optional_text(value: object, field_name: str) -> str | None:
|
|
693
|
+
if value is None:
|
|
694
|
+
return None
|
|
695
|
+
return _string_value(value, field_name, allow_empty=False)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _optional_string_value(
|
|
699
|
+
value: object,
|
|
700
|
+
field_name: str,
|
|
701
|
+
*,
|
|
702
|
+
allow_empty: bool,
|
|
703
|
+
) -> str | None:
|
|
704
|
+
if value is None:
|
|
705
|
+
return None
|
|
706
|
+
return _string_value(value, field_name, allow_empty=allow_empty)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _string_value(value: object, field_name: str, *, allow_empty: bool) -> str:
|
|
710
|
+
if not isinstance(value, str) or (not allow_empty and not value):
|
|
711
|
+
requirement = "a string" if allow_empty else "a non-empty string"
|
|
712
|
+
raise ValueError(f"{field_name} must be {requirement}")
|
|
713
|
+
return value
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _required_non_negative_int(value: object, field_name: str) -> int:
|
|
717
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
718
|
+
raise ValueError(f"{field_name} must be a non-negative integer")
|
|
719
|
+
return value
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _required_positive_int(value: object, field_name: str) -> int:
|
|
723
|
+
result = _required_non_negative_int(value, field_name)
|
|
724
|
+
if result == 0:
|
|
725
|
+
raise ValueError(f"{field_name} must be a positive integer")
|
|
726
|
+
return result
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _required_boolean(value: object, field_name: str) -> int:
|
|
730
|
+
if isinstance(value, bool) or not isinstance(value, int) or value not in (0, 1):
|
|
731
|
+
raise ValueError(f"{field_name} must be a boolean integer")
|
|
732
|
+
return value
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _optional_non_negative_int(value: object, field_name: str) -> int | None:
|
|
736
|
+
if value is None:
|
|
737
|
+
return None
|
|
738
|
+
return _required_non_negative_int(value, field_name)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _encode_metadata(metadata: Mapping[str, object]) -> str:
|
|
742
|
+
if not isinstance(metadata, Mapping):
|
|
743
|
+
raise TypeError("metadata must be a mapping")
|
|
744
|
+
if any(not isinstance(key, str) for key in metadata):
|
|
745
|
+
raise ValueError("metadata keys must be strings")
|
|
746
|
+
try:
|
|
747
|
+
return json.dumps(
|
|
748
|
+
dict(metadata),
|
|
749
|
+
ensure_ascii=False,
|
|
750
|
+
separators=(",", ":"),
|
|
751
|
+
sort_keys=True,
|
|
752
|
+
)
|
|
753
|
+
except (TypeError, ValueError) as error:
|
|
754
|
+
raise ValueError("metadata must be JSON serializable") from error
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _decode_metadata(value: object, field_name: str) -> dict[str, object]:
|
|
758
|
+
if value is None:
|
|
759
|
+
return {}
|
|
760
|
+
if not isinstance(value, str):
|
|
761
|
+
raise TypeError(f"{field_name} must contain a JSON object")
|
|
762
|
+
try:
|
|
763
|
+
decoded = json.loads(value)
|
|
764
|
+
except json.JSONDecodeError as error:
|
|
765
|
+
raise ValueError(f"{field_name} contains invalid JSON") from error
|
|
766
|
+
if not isinstance(decoded, dict):
|
|
767
|
+
raise TypeError(f"{field_name} must contain a JSON object")
|
|
768
|
+
return decoded
|