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,614 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+ from dataclasses import replace
6
+
7
+ from ..audit import ErrorKind
8
+ from ..channel import ChannelSendRequest, IChannel
9
+ from ..command import (
10
+ ICommandService,
11
+ MessageCheckResult,
12
+ MessageReadResult,
13
+ SessionNotFoundError,
14
+ )
15
+ from ..concurrency import ISessionConcurrency
16
+ from ..correlation import CorrelationContext
17
+ from ..models import (
18
+ ChannelTargetKind,
19
+ ConsumerCursor,
20
+ FreshCheckState,
21
+ InboundMessage,
22
+ OutboundDeliveryState,
23
+ OutboundMessage,
24
+ RuntimeEventState,
25
+ )
26
+ from ..outcomes import ProviderCallStatus
27
+ from ..storage import IStorage, IStorageTransaction
28
+ from .services import SessionAuditRecorder
29
+
30
+
31
+ class SessionCommandService(ICommandService):
32
+ """Execute session-scoped check, read, and send commands."""
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ channel: IChannel,
38
+ storage: IStorage,
39
+ audit: SessionAuditRecorder,
40
+ provider_call_timeout: float,
41
+ concurrency: ISessionConcurrency,
42
+ node_id: Callable[[], str],
43
+ clock: Callable[[], int],
44
+ ) -> None:
45
+ self._channel = channel
46
+ self._storage = storage
47
+ self._audit = audit
48
+ self._provider_call_timeout = provider_call_timeout
49
+ self._concurrency = concurrency
50
+ self._node_id = node_id
51
+ self._clock = clock
52
+
53
+ async def check(self, session_id: str) -> MessageCheckResult:
54
+ async with (
55
+ self._concurrency.for_session(session_id),
56
+ self._storage.transaction() as transaction,
57
+ ):
58
+ bcn_session = await transaction.get_bcn_session(session_id)
59
+ if bcn_session is None:
60
+ raise SessionNotFoundError(f"unknown bcn session: {session_id}")
61
+ cursor = await transaction.get_consumer_cursor(session_id)
62
+ if cursor is None:
63
+ cursor = ConsumerCursor(session_id=session_id)
64
+ latest_seq = await transaction.get_latest_inbound_seq(session_id)
65
+ messages = await transaction.list_inbound_messages(
66
+ session_id,
67
+ after_seq=cursor.delivered_through_seq,
68
+ notifying_only=True,
69
+ )
70
+ referenced_messages = await self._referenced_messages(
71
+ transaction,
72
+ session_id=session_id,
73
+ messages=messages,
74
+ )
75
+ now_ms = self._clock()
76
+ cursor = replace(
77
+ cursor,
78
+ delivered_through_seq=latest_seq,
79
+ inbox_snapshot_seq=latest_seq,
80
+ inbox_snapshot_source="check",
81
+ inbox_snapshot_at_ms=now_ms,
82
+ last_check_at_ms=now_ms,
83
+ updated_at_ms=now_ms,
84
+ )
85
+ await transaction.save_consumer_cursor(cursor)
86
+ result = MessageCheckResult(
87
+ messages=messages,
88
+ snapshot_seq=latest_seq,
89
+ delivered_through_seq=latest_seq,
90
+ referenced_messages=referenced_messages,
91
+ )
92
+ await self._audit.append_tool(
93
+ operation="bcc.message.check",
94
+ status="completed",
95
+ state=RuntimeEventState.COMPLETED,
96
+ correlation=self._correlation(session_id=session_id),
97
+ arguments={"session_id": session_id},
98
+ )
99
+ return result
100
+
101
+ async def read(
102
+ self,
103
+ session_id: str,
104
+ *,
105
+ target: str,
106
+ around_message_id: str | None = None,
107
+ limit: int = 100,
108
+ ) -> MessageReadResult:
109
+ if not target:
110
+ raise ValueError("target must be a non-empty string")
111
+ if limit <= 0:
112
+ raise ValueError("limit must be positive")
113
+ async with self._concurrency.for_session(session_id):
114
+ async with self._storage.transaction() as transaction:
115
+ bcn_session = await transaction.get_bcn_session(session_id)
116
+ if bcn_session is None:
117
+ raise SessionNotFoundError(f"unknown bcn session: {session_id}")
118
+ messages = await transaction.list_inbound_messages(
119
+ session_id,
120
+ target=target,
121
+ around_message_id=around_message_id,
122
+ limit=limit,
123
+ )
124
+ referenced_messages = await self._referenced_messages(
125
+ transaction,
126
+ session_id=session_id,
127
+ messages=messages,
128
+ )
129
+ latest_seq = await transaction.get_latest_inbound_seq(session_id)
130
+ cursor = await transaction.get_consumer_cursor(session_id)
131
+ if cursor is None:
132
+ cursor = ConsumerCursor(session_id=session_id)
133
+ now_ms = self._clock()
134
+ cursor = replace(
135
+ cursor,
136
+ inbox_snapshot_seq=latest_seq,
137
+ inbox_snapshot_source="read",
138
+ inbox_snapshot_at_ms=now_ms,
139
+ last_read_at_ms=now_ms,
140
+ updated_at_ms=now_ms,
141
+ )
142
+ await transaction.save_consumer_cursor(cursor)
143
+ result = MessageReadResult(
144
+ messages=messages,
145
+ snapshot_seq=latest_seq,
146
+ first_seq=messages[0].seq if messages else None,
147
+ last_seq=messages[-1].seq if messages else None,
148
+ referenced_messages=referenced_messages,
149
+ )
150
+ await self._audit.append_tool(
151
+ operation="bcc.message.read",
152
+ status="completed",
153
+ state=RuntimeEventState.COMPLETED,
154
+ correlation=self._correlation(session_id=session_id),
155
+ arguments={
156
+ "session_id": session_id,
157
+ "target": target,
158
+ "around_message_id": around_message_id,
159
+ "limit": limit,
160
+ },
161
+ )
162
+ return result
163
+
164
+ @staticmethod
165
+ async def _referenced_messages(
166
+ transaction: IStorageTransaction,
167
+ *,
168
+ session_id: str,
169
+ messages: tuple[InboundMessage, ...],
170
+ ) -> tuple[InboundMessage, ...]:
171
+ message_ids = {message.message_id for message in messages}
172
+ referenced: list[InboundMessage] = []
173
+ referenced_ids: set[str] = set()
174
+ for message in messages:
175
+ reference_id = message.reply_to_message_id
176
+ if (
177
+ reference_id is None
178
+ or reference_id in message_ids
179
+ or reference_id in referenced_ids
180
+ ):
181
+ continue
182
+ history = await transaction.list_inbound_messages(
183
+ session_id,
184
+ target=message.canonical_target,
185
+ around_message_id=reference_id,
186
+ limit=1,
187
+ )
188
+ referenced_message = history[0]
189
+ if referenced_message.message_id != reference_id:
190
+ raise RuntimeError(
191
+ "referenced inbound lookup returned a different message"
192
+ )
193
+ referenced.append(referenced_message)
194
+ referenced_ids.add(reference_id)
195
+ return tuple(referenced)
196
+
197
+ async def send(
198
+ self,
199
+ *,
200
+ session_id: str,
201
+ command_id: str,
202
+ target: str,
203
+ body: str,
204
+ created_at_ms: int,
205
+ reply_to_message_id: str | None = None,
206
+ ) -> OutboundMessage:
207
+ if not command_id:
208
+ raise ValueError("command_id must be a non-empty string")
209
+ if not target:
210
+ raise ValueError("target must be a non-empty string")
211
+ outbound_id = f"outbound-{session_id}-{command_id}"
212
+ async with self._concurrency.for_session(session_id):
213
+ async with self._storage.transaction() as transaction:
214
+ bcn_session = await transaction.get_bcn_session(session_id)
215
+ if bcn_session is None:
216
+ raise SessionNotFoundError(f"unknown bcn session: {session_id}")
217
+ channel_session = await transaction.get_channel_session(
218
+ bcn_session.channel_session_id
219
+ )
220
+ if channel_session is None:
221
+ raise ValueError(
222
+ f"unknown channel session: {bcn_session.channel_session_id}"
223
+ )
224
+ cursor = await transaction.get_consumer_cursor(session_id)
225
+ if cursor is None:
226
+ cursor = ConsumerCursor(session_id=session_id)
227
+ current_seq = await transaction.get_latest_inbound_seq(session_id)
228
+ target_messages = await transaction.list_inbound_messages(
229
+ session_id,
230
+ target=target,
231
+ limit=1,
232
+ )
233
+ reply_to_provider_message_id = None
234
+ if reply_to_message_id is not None:
235
+ reply_messages = await transaction.list_inbound_messages(
236
+ session_id,
237
+ target=target,
238
+ around_message_id=reply_to_message_id,
239
+ limit=1,
240
+ )
241
+ reply_to_provider_message_id = reply_messages[0].provider_message_id
242
+ outbound = OutboundMessage(
243
+ outbound_message_id=outbound_id,
244
+ command_id=command_id,
245
+ session_id=session_id,
246
+ channel_session_id=channel_session.id,
247
+ target=target,
248
+ body=body,
249
+ state=OutboundDeliveryState.DRAFT,
250
+ fresh_check_state=FreshCheckState.REQUIRED,
251
+ created_at_ms=created_at_ms,
252
+ reply_to_message_id=reply_to_message_id,
253
+ )
254
+ if body.strip():
255
+ outbound = await transaction.save_outbound_message(outbound)
256
+ outbound_id = outbound.outbound_message_id
257
+ rejection_event_name = "bcc.send.fresh_check.failed"
258
+ if not body.strip():
259
+ outbound = outbound.transition_to(
260
+ OutboundDeliveryState.REJECTED,
261
+ at_ms=self._clock(),
262
+ save_draft=False,
263
+ error_kind=ErrorKind.EMPTY_BODY.value,
264
+ error_message="Outbound message body must not be empty.",
265
+ next_action="Provide a non-empty message body and retry.",
266
+ )
267
+ audit_context = self._correlation(
268
+ session_id=session_id,
269
+ channel=channel_session.channel,
270
+ channel_session_id=channel_session.id,
271
+ command_id=command_id,
272
+ inbound_seq=current_seq,
273
+ outbound_message_id=None,
274
+ )
275
+ audit_state = RuntimeEventState.FAILED
276
+ audit_kind = ErrorKind.EMPTY_BODY
277
+ rejection_event_name = "bcc.send.empty_body.failed"
278
+ elif not target_messages:
279
+ outbound = outbound.transition_to(
280
+ OutboundDeliveryState.REJECTED,
281
+ at_ms=self._clock(),
282
+ error_kind=ErrorKind.TARGET_NOT_REPLYABLE.value,
283
+ error_message=(
284
+ f"Thread target is not found or is not replyable: {target}"
285
+ ),
286
+ next_action=(
287
+ "Run `bcc message read` or `bcc message check` for this "
288
+ "target to verify whether the message already landed; "
289
+ "retry only after stable verification."
290
+ ),
291
+ )
292
+ await transaction.save_outbound_message(outbound)
293
+ audit_context = self._correlation(
294
+ session_id=session_id,
295
+ channel=channel_session.channel,
296
+ channel_session_id=channel_session.id,
297
+ command_id=command_id,
298
+ inbound_seq=current_seq,
299
+ outbound_message_id=outbound_id,
300
+ )
301
+ audit_state = RuntimeEventState.FAILED
302
+ audit_kind = ErrorKind.TARGET_NOT_REPLYABLE
303
+ rejection_event_name = "bcc.send.target.failed"
304
+ elif cursor.inbox_snapshot_seq is None:
305
+ outbound = outbound.record_fresh_check(
306
+ FreshCheckState.FAILED,
307
+ snapshot_seq=None,
308
+ current_inbound_seq=current_seq,
309
+ )
310
+ outbound = outbound.transition_to(
311
+ OutboundDeliveryState.REJECTED,
312
+ at_ms=self._clock(),
313
+ error_kind=ErrorKind.FRESH_CHECK_REQUIRED.value,
314
+ error_message=(
315
+ "No inbox snapshot is available; outbound send was refused."
316
+ ),
317
+ next_action=(
318
+ "Run `bcc message check` or `bcc message read` before "
319
+ "retrying."
320
+ ),
321
+ )
322
+ await transaction.save_outbound_message(outbound)
323
+ audit_context = self._correlation(
324
+ session_id=session_id,
325
+ channel=channel_session.channel,
326
+ channel_session_id=channel_session.id,
327
+ command_id=command_id,
328
+ inbound_seq=current_seq,
329
+ outbound_message_id=outbound_id,
330
+ )
331
+ audit_state = RuntimeEventState.FAILED
332
+ audit_kind = ErrorKind.FRESH_CHECK_REQUIRED
333
+ elif current_seq > cursor.inbox_snapshot_seq:
334
+ outbound = outbound.record_fresh_check(
335
+ FreshCheckState.FAILED,
336
+ snapshot_seq=cursor.inbox_snapshot_seq,
337
+ current_inbound_seq=current_seq,
338
+ )
339
+ outbound = outbound.transition_to(
340
+ OutboundDeliveryState.REJECTED,
341
+ at_ms=self._clock(),
342
+ error_kind=ErrorKind.FRESH_CHECK_FAILED.value,
343
+ error_message=(
344
+ "New inbound message(s) arrived after the latest inbox "
345
+ "snapshot; outbound send was refused."
346
+ ),
347
+ next_action=(
348
+ "Run `bcc message check` to read the new messages, then "
349
+ "retry `bcc message send` if still appropriate."
350
+ ),
351
+ )
352
+ await transaction.save_outbound_message(outbound)
353
+ audit_context = self._correlation(
354
+ session_id=session_id,
355
+ channel=channel_session.channel,
356
+ channel_session_id=channel_session.id,
357
+ command_id=command_id,
358
+ inbound_seq=current_seq,
359
+ outbound_message_id=outbound_id,
360
+ )
361
+ audit_state = RuntimeEventState.FAILED
362
+ audit_kind = ErrorKind.FRESH_CHECK_FAILED
363
+ else:
364
+ outbound = outbound.record_fresh_check(
365
+ FreshCheckState.PASSED,
366
+ snapshot_seq=cursor.inbox_snapshot_seq,
367
+ current_inbound_seq=current_seq,
368
+ )
369
+ outbound = outbound.transition_to(
370
+ OutboundDeliveryState.PENDING,
371
+ at_ms=self._clock(),
372
+ )
373
+ outbound = replace(
374
+ outbound,
375
+ provider_attempted_at_ms=self._clock(),
376
+ )
377
+ await transaction.save_outbound_message(outbound)
378
+ audit_context = self._correlation(
379
+ session_id=session_id,
380
+ channel=channel_session.channel,
381
+ channel_session_id=channel_session.id,
382
+ command_id=command_id,
383
+ inbound_seq=current_seq,
384
+ outbound_message_id=outbound_id,
385
+ )
386
+ audit_state = RuntimeEventState.STARTED
387
+ audit_kind = None
388
+
389
+ if outbound.state is OutboundDeliveryState.REJECTED:
390
+ await self._audit.append(
391
+ event_name=rejection_event_name,
392
+ state=audit_state,
393
+ correlation=audit_context,
394
+ error_kind=audit_kind,
395
+ error_message=outbound.error_message,
396
+ )
397
+ await self._audit.append_tool(
398
+ operation="bcc.message.send",
399
+ status="rejected",
400
+ state=audit_state,
401
+ correlation=audit_context,
402
+ arguments={
403
+ "command_id": command_id,
404
+ "target": target,
405
+ "reason": outbound.error_kind,
406
+ },
407
+ error_kind=audit_kind,
408
+ error_message=outbound.error_message,
409
+ )
410
+ return outbound
411
+
412
+ await self._audit.append(
413
+ event_name="bcc.send.fresh_check.passed",
414
+ state=RuntimeEventState.COMPLETED,
415
+ correlation=audit_context,
416
+ )
417
+ await self._audit.append(
418
+ event_name="channel.outbound.pending",
419
+ state=RuntimeEventState.STARTED,
420
+ correlation=audit_context,
421
+ )
422
+ try:
423
+ provider_result = await self._channel.send(
424
+ ChannelSendRequest(
425
+ outbound=outbound,
426
+ target_kind=channel_session.target_kind,
427
+ provider_thread_id=channel_session.provider_thread_id,
428
+ provider_reply_to_message_id=(reply_to_provider_message_id),
429
+ ),
430
+ timeout=self._provider_call_timeout,
431
+ )
432
+ except asyncio.CancelledError:
433
+ raise
434
+ except Exception as error: # noqa: BLE001
435
+ provider_result = None
436
+ provider_error = error
437
+ else:
438
+ provider_error = None
439
+
440
+ attempted_at_ms = outbound.provider_attempted_at_ms or self._clock()
441
+ outbound = replace(outbound, provider_attempted_at_ms=attempted_at_ms)
442
+ if provider_result is None:
443
+ outbound = outbound.transition_to(
444
+ OutboundDeliveryState.UNKNOWN,
445
+ at_ms=self._clock(),
446
+ error_kind=ErrorKind.PROVIDER_UNKNOWN.value,
447
+ error_message=str(provider_error),
448
+ next_action="reconcile channel delivery before retrying",
449
+ )
450
+ terminal_kind = ErrorKind.PROVIDER_UNKNOWN
451
+ terminal_state = RuntimeEventState.UNKNOWN
452
+ elif provider_result.status is ProviderCallStatus.CONFIRMED:
453
+ receipt = provider_result.value
454
+ if receipt is None:
455
+ raise ValueError("confirmed channel delivery has no receipt")
456
+ outbound = outbound.transition_to(
457
+ OutboundDeliveryState.SENT,
458
+ at_ms=self._clock(),
459
+ provider_message_id=receipt.provider_message_id,
460
+ provider_receipt_ref=receipt.provider_receipt_ref,
461
+ )
462
+ terminal_kind = None
463
+ terminal_state = RuntimeEventState.COMPLETED
464
+ elif provider_result.status is ProviderCallStatus.QUEUED:
465
+ receipt = provider_result.value
466
+ if receipt is None:
467
+ raise ValueError("queued channel delivery has no receipt")
468
+ outbound = outbound.transition_to(
469
+ OutboundDeliveryState.QUEUED,
470
+ at_ms=self._clock(),
471
+ provider_message_id=receipt.provider_message_id,
472
+ provider_receipt_ref=receipt.provider_receipt_ref,
473
+ )
474
+ terminal_kind = None
475
+ terminal_state = RuntimeEventState.STARTED
476
+ elif provider_result.status is ProviderCallStatus.PARTIAL:
477
+ receipt = provider_result.value
478
+ if receipt is None:
479
+ raise ValueError("partial channel delivery has no receipt")
480
+ outbound = outbound.transition_to(
481
+ OutboundDeliveryState.PARTIAL,
482
+ at_ms=self._clock(),
483
+ provider_message_id=receipt.provider_message_id,
484
+ provider_receipt_ref=receipt.provider_receipt_ref,
485
+ error_kind=provider_result.error_kind
486
+ or ErrorKind.PROVIDER_PARTIAL.value,
487
+ error_message=provider_result.error_message,
488
+ next_action="do not retry the complete message automatically",
489
+ )
490
+ terminal_kind = ErrorKind.PROVIDER_PARTIAL
491
+ terminal_state = RuntimeEventState.FAILED
492
+ elif provider_result.status is ProviderCallStatus.FAILED:
493
+ outbound = outbound.transition_to(
494
+ OutboundDeliveryState.FAILED,
495
+ at_ms=self._clock(),
496
+ error_kind=provider_result.error_kind
497
+ or ErrorKind.PROVIDER_FAILED.value,
498
+ error_message=provider_result.error_message,
499
+ )
500
+ terminal_kind = ErrorKind.PROVIDER_FAILED
501
+ terminal_state = RuntimeEventState.FAILED
502
+ else:
503
+ outbound = outbound.transition_to(
504
+ OutboundDeliveryState.UNKNOWN,
505
+ at_ms=self._clock(),
506
+ error_kind=provider_result.error_kind
507
+ or ErrorKind.PROVIDER_UNKNOWN.value,
508
+ error_message=provider_result.error_message,
509
+ next_action="reconcile channel delivery before retrying",
510
+ )
511
+ terminal_kind = ErrorKind.PROVIDER_UNKNOWN
512
+ terminal_state = RuntimeEventState.UNKNOWN
513
+
514
+ if provider_result is not None and provider_result.receipt:
515
+ outbound = replace(
516
+ outbound,
517
+ metadata={
518
+ **outbound.metadata,
519
+ "delivery_receipt": dict(provider_result.receipt),
520
+ },
521
+ )
522
+
523
+ async with self._storage.transaction() as transaction:
524
+ await transaction.save_outbound_message(outbound)
525
+ await self._audit.append(
526
+ event_name=f"channel.outbound.{outbound.state.value}",
527
+ state=terminal_state,
528
+ correlation=audit_context,
529
+ error_kind=terminal_kind,
530
+ error_message=outbound.error_message,
531
+ metadata=provider_result.receipt if provider_result is not None else {},
532
+ )
533
+ await self._audit.append_tool(
534
+ operation="bcc.message.send",
535
+ status=outbound.state.value,
536
+ state=terminal_state,
537
+ correlation=audit_context,
538
+ arguments={
539
+ "command_id": command_id,
540
+ "target": target,
541
+ "delivery_state": outbound.state.value,
542
+ },
543
+ error_kind=terminal_kind,
544
+ error_message=outbound.error_message,
545
+ )
546
+ return outbound
547
+
548
+ async def unfollow(self, session_id: str, *, target: str) -> bool:
549
+ if not target:
550
+ raise ValueError("target must be a non-empty string")
551
+ async with (
552
+ self._concurrency.for_session(session_id),
553
+ self._storage.transaction() as transaction,
554
+ ):
555
+ bcn_session = await transaction.get_bcn_session(session_id)
556
+ if bcn_session is None:
557
+ raise SessionNotFoundError(f"unknown bcn session: {session_id}")
558
+ channel_session = await transaction.get_channel_session(
559
+ bcn_session.channel_session_id
560
+ )
561
+ if channel_session is None:
562
+ raise ValueError(
563
+ f"unknown channel session: {bcn_session.channel_session_id}"
564
+ )
565
+ target_messages = await transaction.list_inbound_messages(
566
+ session_id,
567
+ target=target,
568
+ limit=1,
569
+ )
570
+ if not target_messages:
571
+ raise ValueError(f"Thread target is not found: {target}")
572
+ changed = (
573
+ channel_session.target_kind is ChannelTargetKind.GROUP
574
+ and channel_session.following
575
+ )
576
+ if changed:
577
+ channel_session = replace(
578
+ channel_session,
579
+ following=False,
580
+ updated_at_ms=self._clock(),
581
+ )
582
+ await transaction.save_channel_session(channel_session)
583
+ await self._audit.append_tool(
584
+ operation="bcc.thread.unfollow",
585
+ status="completed",
586
+ state=RuntimeEventState.COMPLETED,
587
+ correlation=self._correlation(
588
+ session_id=session_id,
589
+ channel=channel_session.channel,
590
+ channel_session_id=channel_session.id,
591
+ ),
592
+ arguments={"session_id": session_id, "target": target, "changed": changed},
593
+ )
594
+ return changed
595
+
596
+ def _correlation(
597
+ self,
598
+ *,
599
+ session_id: str,
600
+ channel: str | None = None,
601
+ channel_session_id: str | None = None,
602
+ command_id: str | None = None,
603
+ inbound_seq: int | None = None,
604
+ outbound_message_id: str | None = None,
605
+ ) -> CorrelationContext:
606
+ return CorrelationContext(
607
+ node_id=self._node_id(),
608
+ channel=channel,
609
+ channel_session_id=channel_session_id,
610
+ bcn_session_id=session_id,
611
+ command_id=command_id,
612
+ inbound_seq=inbound_seq,
613
+ outbound_message_id=outbound_message_id,
614
+ )