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,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
|
+
)
|