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.
Files changed (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. 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