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,1059 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from contextlib import AbstractAsyncContextManager
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import TYPE_CHECKING, Self
|
|
9
|
+
from uuid import uuid7
|
|
10
|
+
|
|
11
|
+
import aiosqlite
|
|
12
|
+
|
|
13
|
+
from ...core.models import (
|
|
14
|
+
BcnSession,
|
|
15
|
+
ChannelSession,
|
|
16
|
+
ConsumerCursor,
|
|
17
|
+
InboundMessage,
|
|
18
|
+
OutboundMessage,
|
|
19
|
+
RuntimeAttempt,
|
|
20
|
+
RuntimeEvent,
|
|
21
|
+
RuntimeSession,
|
|
22
|
+
)
|
|
23
|
+
from .codec import (
|
|
24
|
+
_bcn_session_from_row,
|
|
25
|
+
_channel_session_from_row,
|
|
26
|
+
_consumer_cursor_from_row,
|
|
27
|
+
_encode_metadata,
|
|
28
|
+
_inbound_attachment_from_row,
|
|
29
|
+
_inbound_message_from_row,
|
|
30
|
+
_outbound_message_from_row,
|
|
31
|
+
_required_non_negative_int,
|
|
32
|
+
_required_positive_int,
|
|
33
|
+
_runtime_attempt_from_row,
|
|
34
|
+
_runtime_event_from_row,
|
|
35
|
+
_runtime_session_from_row,
|
|
36
|
+
_same_runtime_event_payload,
|
|
37
|
+
_validate_bcn_session_update,
|
|
38
|
+
_validate_channel_session_input,
|
|
39
|
+
_validate_channel_session_update,
|
|
40
|
+
_validate_consumer_cursor_input,
|
|
41
|
+
_validate_consumer_cursor_update,
|
|
42
|
+
_validate_cursor_bounds,
|
|
43
|
+
_validate_inbound_message_input,
|
|
44
|
+
_validate_non_empty_text,
|
|
45
|
+
_validate_non_negative_int,
|
|
46
|
+
_validate_outbound_insert,
|
|
47
|
+
_validate_outbound_message_input,
|
|
48
|
+
_validate_outbound_update,
|
|
49
|
+
_validate_positive_int,
|
|
50
|
+
_validate_runtime_event_input,
|
|
51
|
+
_validate_runtime_session_update,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from .database import SqliteDatabase
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SqliteTransaction(AbstractAsyncContextManager["SqliteTransaction"]):
|
|
59
|
+
"""An explicit IMMEDIATE transaction on the database's long-lived connection."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, database: SqliteDatabase) -> None:
|
|
62
|
+
self._database = database
|
|
63
|
+
self._connection: aiosqlite.Connection | None = None
|
|
64
|
+
self._active = False
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> Self:
|
|
67
|
+
await self._database._transaction_lock.acquire()
|
|
68
|
+
try:
|
|
69
|
+
connection = self._database._require_connection()
|
|
70
|
+
await connection.execute("BEGIN IMMEDIATE")
|
|
71
|
+
except BaseException:
|
|
72
|
+
self._database._transaction_lock.release()
|
|
73
|
+
raise
|
|
74
|
+
self._connection = connection
|
|
75
|
+
self._active = True
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(
|
|
79
|
+
self,
|
|
80
|
+
exc_type: type[BaseException] | None,
|
|
81
|
+
exc_value: BaseException | None,
|
|
82
|
+
traceback: TracebackType | None,
|
|
83
|
+
) -> bool:
|
|
84
|
+
connection = self._connection
|
|
85
|
+
if not self._active or connection is None:
|
|
86
|
+
return False
|
|
87
|
+
try:
|
|
88
|
+
await connection.execute("ROLLBACK" if exc_type is not None else "COMMIT")
|
|
89
|
+
except (Exception, asyncio.CancelledError) as error:
|
|
90
|
+
if exc_type is None:
|
|
91
|
+
try:
|
|
92
|
+
await connection.execute("ROLLBACK")
|
|
93
|
+
except (Exception, asyncio.CancelledError) as rollback_error:
|
|
94
|
+
raise error from rollback_error
|
|
95
|
+
raise
|
|
96
|
+
finally:
|
|
97
|
+
self._active = False
|
|
98
|
+
self._connection = None
|
|
99
|
+
self._database._transaction_lock.release()
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
async def execute(
|
|
103
|
+
self,
|
|
104
|
+
statement: str,
|
|
105
|
+
parameters: Sequence[object] = (),
|
|
106
|
+
) -> aiosqlite.Cursor:
|
|
107
|
+
connection = self._require_active_connection()
|
|
108
|
+
return await connection.execute(statement, parameters)
|
|
109
|
+
|
|
110
|
+
async def fetchone(
|
|
111
|
+
self,
|
|
112
|
+
statement: str,
|
|
113
|
+
parameters: Sequence[object] = (),
|
|
114
|
+
) -> aiosqlite.Row | None:
|
|
115
|
+
cursor = await self.execute(statement, parameters)
|
|
116
|
+
try:
|
|
117
|
+
return await cursor.fetchone()
|
|
118
|
+
finally:
|
|
119
|
+
await cursor.close()
|
|
120
|
+
|
|
121
|
+
async def fetchall(
|
|
122
|
+
self,
|
|
123
|
+
statement: str,
|
|
124
|
+
parameters: Sequence[object] = (),
|
|
125
|
+
) -> list[aiosqlite.Row]:
|
|
126
|
+
cursor = await self.execute(statement, parameters)
|
|
127
|
+
try:
|
|
128
|
+
return list(await cursor.fetchall())
|
|
129
|
+
finally:
|
|
130
|
+
await cursor.close()
|
|
131
|
+
|
|
132
|
+
async def find_channel_session(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
channel: str,
|
|
136
|
+
provider_thread_id: str,
|
|
137
|
+
) -> ChannelSession | None:
|
|
138
|
+
row = await self._fetch_one_or_conflict(
|
|
139
|
+
"SELECT id, channel, "
|
|
140
|
+
"provider_thread_id, target_kind, following, "
|
|
141
|
+
"created_at_ms, updated_at_ms, last_inbound_at_ms, last_outbound_at_ms, "
|
|
142
|
+
"provider_identity_ref_json "
|
|
143
|
+
"FROM channel_sessions "
|
|
144
|
+
"WHERE channel = ? AND provider_thread_id = ? ORDER BY rowid",
|
|
145
|
+
(channel, provider_thread_id),
|
|
146
|
+
"channel provider identity",
|
|
147
|
+
)
|
|
148
|
+
return _channel_session_from_row(row) if row is not None else None
|
|
149
|
+
|
|
150
|
+
async def get_channel_session(self, session_id: str) -> ChannelSession | None:
|
|
151
|
+
row = await self.fetchone(
|
|
152
|
+
"SELECT id, channel, "
|
|
153
|
+
"provider_thread_id, target_kind, following, "
|
|
154
|
+
"created_at_ms, updated_at_ms, last_inbound_at_ms, last_outbound_at_ms, "
|
|
155
|
+
"provider_identity_ref_json "
|
|
156
|
+
"FROM channel_sessions WHERE id = ?",
|
|
157
|
+
(session_id,),
|
|
158
|
+
)
|
|
159
|
+
return _channel_session_from_row(row) if row is not None else None
|
|
160
|
+
|
|
161
|
+
async def get_bcn_session(self, session_id: str) -> BcnSession | None:
|
|
162
|
+
row = await self.fetchone(
|
|
163
|
+
"SELECT id, channel_session_id, workspace_id, "
|
|
164
|
+
"created_at_ms, updated_at_ms, last_activity_at_ms, "
|
|
165
|
+
"metadata_json FROM bcn_sessions WHERE id = ?",
|
|
166
|
+
(session_id,),
|
|
167
|
+
)
|
|
168
|
+
return _bcn_session_from_row(row) if row is not None else None
|
|
169
|
+
|
|
170
|
+
async def find_bcn_session(self, channel_session_id: str) -> BcnSession | None:
|
|
171
|
+
row = await self._fetch_one_or_conflict(
|
|
172
|
+
"SELECT id, channel_session_id, workspace_id, "
|
|
173
|
+
"created_at_ms, updated_at_ms, last_activity_at_ms, "
|
|
174
|
+
"metadata_json FROM bcn_sessions "
|
|
175
|
+
"WHERE channel_session_id = ? ORDER BY rowid",
|
|
176
|
+
(channel_session_id,),
|
|
177
|
+
"channel-to-bcn session binding",
|
|
178
|
+
)
|
|
179
|
+
return _bcn_session_from_row(row) if row is not None else None
|
|
180
|
+
|
|
181
|
+
async def get_runtime_session(self, session_id: str) -> RuntimeSession | None:
|
|
182
|
+
row = await self.fetchone(
|
|
183
|
+
"SELECT runtime_sessions.id, "
|
|
184
|
+
"runtime_sessions.bcn_session_id, runtime_sessions.channel_session_id, "
|
|
185
|
+
"runtime_sessions.runtime, bcn_sessions.workspace_id AS workspace_id, "
|
|
186
|
+
"runtime_sessions.provider_thread_id, runtime_sessions.created_at_ms, "
|
|
187
|
+
"runtime_sessions.updated_at_ms, "
|
|
188
|
+
"runtime_sessions.metadata_json "
|
|
189
|
+
"FROM runtime_sessions LEFT JOIN bcn_sessions "
|
|
190
|
+
"ON bcn_sessions.id = runtime_sessions.bcn_session_id "
|
|
191
|
+
"WHERE runtime_sessions.id = ?",
|
|
192
|
+
(session_id,),
|
|
193
|
+
)
|
|
194
|
+
return _runtime_session_from_row(row) if row is not None else None
|
|
195
|
+
|
|
196
|
+
async def find_runtime_session(self, session_id: str) -> RuntimeSession | None:
|
|
197
|
+
row = await self._fetch_one_or_conflict(
|
|
198
|
+
"SELECT runtime_sessions.id, "
|
|
199
|
+
"runtime_sessions.bcn_session_id, runtime_sessions.channel_session_id, "
|
|
200
|
+
"runtime_sessions.runtime, bcn_sessions.workspace_id AS workspace_id, "
|
|
201
|
+
"runtime_sessions.provider_thread_id, runtime_sessions.created_at_ms, "
|
|
202
|
+
"runtime_sessions.updated_at_ms, "
|
|
203
|
+
"runtime_sessions.metadata_json FROM runtime_sessions "
|
|
204
|
+
"LEFT JOIN bcn_sessions ON bcn_sessions.id = "
|
|
205
|
+
"runtime_sessions.bcn_session_id "
|
|
206
|
+
"WHERE runtime_sessions.bcn_session_id = ? "
|
|
207
|
+
"ORDER BY runtime_sessions.rowid",
|
|
208
|
+
(session_id,),
|
|
209
|
+
"bcn-to-runtime session binding",
|
|
210
|
+
)
|
|
211
|
+
return _runtime_session_from_row(row) if row is not None else None
|
|
212
|
+
|
|
213
|
+
async def get_runtime_attempt(self, turn_id: str) -> RuntimeAttempt | None:
|
|
214
|
+
row = await self.fetchone(
|
|
215
|
+
"SELECT turn_id, session_id, client_user_message_id, started_at_ms "
|
|
216
|
+
"FROM runtime_attempts WHERE turn_id = ?",
|
|
217
|
+
(turn_id,),
|
|
218
|
+
)
|
|
219
|
+
return _runtime_attempt_from_row(row) if row is not None else None
|
|
220
|
+
|
|
221
|
+
async def get_consumer_cursor(self, session_id: str) -> ConsumerCursor | None:
|
|
222
|
+
row = await self.fetchone(
|
|
223
|
+
"SELECT session_id, delivered_through_seq, inbox_snapshot_seq, "
|
|
224
|
+
"inbox_snapshot_source, inbox_snapshot_at_ms, last_check_at_ms, "
|
|
225
|
+
"last_read_at_ms, updated_at_ms FROM consumer_cursors "
|
|
226
|
+
"WHERE session_id = ?",
|
|
227
|
+
(session_id,),
|
|
228
|
+
)
|
|
229
|
+
return _consumer_cursor_from_row(row) if row is not None else None
|
|
230
|
+
|
|
231
|
+
async def get_latest_inbound_seq(self, session_id: str) -> int:
|
|
232
|
+
row = await self.fetchone(
|
|
233
|
+
"SELECT COALESCE(MAX(seq), 0) AS latest_seq FROM inbound_messages "
|
|
234
|
+
"WHERE session_id = ?",
|
|
235
|
+
(session_id,),
|
|
236
|
+
)
|
|
237
|
+
if row is None:
|
|
238
|
+
raise RuntimeError("SQLite latest inbound sequence query returned no row")
|
|
239
|
+
return _required_non_negative_int(row["latest_seq"], "latest_inbound_seq")
|
|
240
|
+
|
|
241
|
+
async def find_inbound_message(
|
|
242
|
+
self,
|
|
243
|
+
channel: str,
|
|
244
|
+
provider_thread_id: str,
|
|
245
|
+
provider_message_id: str,
|
|
246
|
+
) -> InboundMessage | None:
|
|
247
|
+
row = await self._fetch_one_or_conflict(
|
|
248
|
+
"SELECT seq, message_id, session_id, channel_session_id, "
|
|
249
|
+
"channel, provider_thread_id, provider_message_id, provider_time_ms, "
|
|
250
|
+
"received_at_ms, sender, message_type, "
|
|
251
|
+
"canonical_target, target_kind, "
|
|
252
|
+
"reply_to_message_id, body, mentions_agent, "
|
|
253
|
+
"notifies_runtime, provider_payload_ref, metadata_json "
|
|
254
|
+
"FROM inbound_messages WHERE channel = ? "
|
|
255
|
+
"AND provider_thread_id = ? AND provider_message_id = ? ORDER BY seq",
|
|
256
|
+
(channel, provider_thread_id, provider_message_id),
|
|
257
|
+
"provider inbound identity",
|
|
258
|
+
)
|
|
259
|
+
if row is None:
|
|
260
|
+
return None
|
|
261
|
+
return _inbound_message_from_row(
|
|
262
|
+
row, await self._attachments(row["message_id"])
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
async def list_ready_attachment_paths(self) -> tuple[str, ...]:
|
|
266
|
+
rows = await self.fetchall(
|
|
267
|
+
"SELECT relative_path FROM inbound_attachments "
|
|
268
|
+
"WHERE state = 'ready' ORDER BY attachment_id"
|
|
269
|
+
)
|
|
270
|
+
return tuple(str(row["relative_path"]) for row in rows)
|
|
271
|
+
|
|
272
|
+
async def list_inbound_messages(
|
|
273
|
+
self,
|
|
274
|
+
session_id: str,
|
|
275
|
+
*,
|
|
276
|
+
after_seq: int | None = None,
|
|
277
|
+
target: str | None = None,
|
|
278
|
+
around_message_id: str | None = None,
|
|
279
|
+
notifying_only: bool = False,
|
|
280
|
+
limit: int = 100,
|
|
281
|
+
) -> tuple[InboundMessage, ...]:
|
|
282
|
+
_validate_non_empty_text(session_id, "session_id")
|
|
283
|
+
if after_seq is not None:
|
|
284
|
+
_validate_non_negative_int(after_seq, "after_seq")
|
|
285
|
+
if target is not None:
|
|
286
|
+
_validate_non_empty_text(target, "target")
|
|
287
|
+
if around_message_id is not None:
|
|
288
|
+
_validate_non_empty_text(around_message_id, "around_message_id")
|
|
289
|
+
_validate_positive_int(limit, "limit")
|
|
290
|
+
|
|
291
|
+
predicates = ["session_id = ?"]
|
|
292
|
+
parameters: list[object] = [session_id]
|
|
293
|
+
if after_seq is not None:
|
|
294
|
+
predicates.append("seq > ?")
|
|
295
|
+
parameters.append(after_seq)
|
|
296
|
+
if target is not None:
|
|
297
|
+
predicates.append("canonical_target = ?")
|
|
298
|
+
parameters.append(target)
|
|
299
|
+
if notifying_only:
|
|
300
|
+
predicates.append("notifies_runtime = 1")
|
|
301
|
+
where_clause = " AND ".join(predicates)
|
|
302
|
+
|
|
303
|
+
if around_message_id is None:
|
|
304
|
+
rows = await self.fetchall(
|
|
305
|
+
"SELECT seq, message_id, session_id, channel_session_id, "
|
|
306
|
+
"channel, provider_thread_id, provider_message_id, provider_time_ms, "
|
|
307
|
+
"received_at_ms, sender, message_type, "
|
|
308
|
+
"canonical_target, target_kind, "
|
|
309
|
+
"reply_to_message_id, body, mentions_agent, notifies_runtime, provider_payload_ref, "
|
|
310
|
+
"metadata_json FROM inbound_messages "
|
|
311
|
+
f"WHERE {where_clause} ORDER BY seq LIMIT ?",
|
|
312
|
+
(*parameters, limit),
|
|
313
|
+
)
|
|
314
|
+
messages = []
|
|
315
|
+
for row in rows:
|
|
316
|
+
messages.append(
|
|
317
|
+
_inbound_message_from_row(
|
|
318
|
+
row, await self._attachments(row["message_id"])
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
return tuple(messages)
|
|
322
|
+
|
|
323
|
+
anchor = await self.fetchone(
|
|
324
|
+
f"SELECT seq FROM inbound_messages WHERE {where_clause} AND message_id = ?",
|
|
325
|
+
(*parameters, around_message_id),
|
|
326
|
+
)
|
|
327
|
+
if anchor is None:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"message not found in requested history: {around_message_id}"
|
|
330
|
+
)
|
|
331
|
+
anchor_seq = _required_non_negative_int(anchor["seq"], "anchor_seq")
|
|
332
|
+
count_row = await self.fetchone(
|
|
333
|
+
"SELECT COUNT(*) AS message_count FROM inbound_messages "
|
|
334
|
+
f"WHERE {where_clause}",
|
|
335
|
+
parameters,
|
|
336
|
+
)
|
|
337
|
+
if count_row is None:
|
|
338
|
+
raise RuntimeError("SQLite inbound history count query returned no row")
|
|
339
|
+
message_count = _required_non_negative_int(
|
|
340
|
+
count_row["message_count"], "message_count"
|
|
341
|
+
)
|
|
342
|
+
position_row = await self.fetchone(
|
|
343
|
+
"SELECT COUNT(*) AS anchor_position FROM inbound_messages "
|
|
344
|
+
f"WHERE {where_clause} AND seq <= ?",
|
|
345
|
+
(*parameters, anchor_seq),
|
|
346
|
+
)
|
|
347
|
+
if position_row is None:
|
|
348
|
+
raise RuntimeError("SQLite inbound anchor position query returned no row")
|
|
349
|
+
anchor_position = _required_positive_int(
|
|
350
|
+
position_row["anchor_position"], "anchor_position"
|
|
351
|
+
)
|
|
352
|
+
before_count = limit // 2
|
|
353
|
+
start_position = max(anchor_position - before_count, 1)
|
|
354
|
+
start_position = min(
|
|
355
|
+
start_position,
|
|
356
|
+
max(message_count - limit + 1, 1),
|
|
357
|
+
)
|
|
358
|
+
end_position = start_position + limit - 1
|
|
359
|
+
|
|
360
|
+
filtered_query = (
|
|
361
|
+
"SELECT seq, message_id, session_id, channel_session_id, "
|
|
362
|
+
"channel, provider_thread_id, provider_message_id, provider_time_ms, "
|
|
363
|
+
"received_at_ms, sender, message_type, "
|
|
364
|
+
"canonical_target, target_kind, "
|
|
365
|
+
"reply_to_message_id, body, mentions_agent, notifies_runtime, provider_payload_ref, "
|
|
366
|
+
"metadata_json, ROW_NUMBER() OVER (ORDER BY seq) AS row_number "
|
|
367
|
+
"FROM inbound_messages "
|
|
368
|
+
f"WHERE {where_clause}"
|
|
369
|
+
)
|
|
370
|
+
rows = await self.fetchall(
|
|
371
|
+
"WITH filtered AS ("
|
|
372
|
+
+ filtered_query
|
|
373
|
+
+ ") SELECT seq, message_id, session_id, channel_session_id, "
|
|
374
|
+
"channel, provider_thread_id, provider_message_id, provider_time_ms, "
|
|
375
|
+
"received_at_ms, sender, message_type, "
|
|
376
|
+
"canonical_target, target_kind, "
|
|
377
|
+
"reply_to_message_id, body, mentions_agent, notifies_runtime, provider_payload_ref, "
|
|
378
|
+
"metadata_json FROM filtered WHERE row_number BETWEEN ? AND ? "
|
|
379
|
+
"ORDER BY row_number",
|
|
380
|
+
(*parameters, start_position, end_position),
|
|
381
|
+
)
|
|
382
|
+
messages = []
|
|
383
|
+
for row in rows:
|
|
384
|
+
messages.append(
|
|
385
|
+
_inbound_message_from_row(
|
|
386
|
+
row, await self._attachments(row["message_id"])
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
return tuple(messages)
|
|
390
|
+
|
|
391
|
+
async def append_inbound_message(self, message: InboundMessage) -> InboundMessage:
|
|
392
|
+
_validate_inbound_message_input(message)
|
|
393
|
+
bcn_session = await self.get_bcn_session(message.session_id)
|
|
394
|
+
if bcn_session is None:
|
|
395
|
+
raise ValueError(f"unknown bcn session: {message.session_id}")
|
|
396
|
+
channel_session = await self.get_channel_session(bcn_session.channel_session_id)
|
|
397
|
+
if channel_session is None:
|
|
398
|
+
raise ValueError(
|
|
399
|
+
f"unknown channel session: {bcn_session.channel_session_id}"
|
|
400
|
+
)
|
|
401
|
+
if (
|
|
402
|
+
message.channel_session_id != channel_session.id
|
|
403
|
+
or message.channel != channel_session.channel
|
|
404
|
+
or message.provider_thread_id != channel_session.provider_thread_id
|
|
405
|
+
):
|
|
406
|
+
raise ValueError("inbound message binding does not match channel session")
|
|
407
|
+
|
|
408
|
+
existing_row = await self._fetch_one_or_conflict(
|
|
409
|
+
"SELECT seq, message_id, session_id, channel_session_id, "
|
|
410
|
+
"channel, provider_thread_id, provider_message_id, provider_time_ms, "
|
|
411
|
+
"received_at_ms, sender, message_type, "
|
|
412
|
+
"canonical_target, target_kind, "
|
|
413
|
+
"reply_to_message_id, body, mentions_agent, notifies_runtime, provider_payload_ref, "
|
|
414
|
+
"metadata_json FROM inbound_messages "
|
|
415
|
+
"WHERE channel = ? AND provider_thread_id = ? "
|
|
416
|
+
"AND provider_message_id = ? ORDER BY seq",
|
|
417
|
+
(
|
|
418
|
+
message.channel,
|
|
419
|
+
message.provider_thread_id,
|
|
420
|
+
message.provider_message_id,
|
|
421
|
+
),
|
|
422
|
+
"provider inbound identity",
|
|
423
|
+
)
|
|
424
|
+
if existing_row is not None:
|
|
425
|
+
existing = _inbound_message_from_row(
|
|
426
|
+
existing_row, await self._attachments(existing_row["message_id"])
|
|
427
|
+
)
|
|
428
|
+
return existing
|
|
429
|
+
|
|
430
|
+
message_id_row = await self.fetchone(
|
|
431
|
+
"SELECT 1 FROM inbound_messages WHERE message_id = ?",
|
|
432
|
+
(message.message_id,),
|
|
433
|
+
)
|
|
434
|
+
if message_id_row is not None:
|
|
435
|
+
raise ValueError("message id is already bound to another inbound message")
|
|
436
|
+
|
|
437
|
+
sequence_row = await self.fetchone(
|
|
438
|
+
"SELECT COALESCE(MAX(seq), 0) + 1 AS next_seq FROM inbound_messages"
|
|
439
|
+
)
|
|
440
|
+
if sequence_row is None:
|
|
441
|
+
raise RuntimeError("SQLite inbound sequence query returned no row")
|
|
442
|
+
next_seq = _required_positive_int(sequence_row["next_seq"], "next_seq")
|
|
443
|
+
canonical = replace(message, seq=next_seq)
|
|
444
|
+
if canonical.reply_to_message_id is not None:
|
|
445
|
+
referenced = await self.fetchone(
|
|
446
|
+
"SELECT session_id, seq FROM inbound_messages WHERE message_id = ?",
|
|
447
|
+
(canonical.reply_to_message_id,),
|
|
448
|
+
)
|
|
449
|
+
if referenced is None:
|
|
450
|
+
raise ValueError("reply_to_message_id does not reference a message")
|
|
451
|
+
if referenced["session_id"] != canonical.session_id:
|
|
452
|
+
raise ValueError("reply_to_message_id must belong to the same session")
|
|
453
|
+
referenced_seq = _required_positive_int(
|
|
454
|
+
referenced["seq"], "reply_to_message_seq"
|
|
455
|
+
)
|
|
456
|
+
if referenced_seq >= canonical.seq:
|
|
457
|
+
raise ValueError(
|
|
458
|
+
"reply_to_message_id must reference an earlier message"
|
|
459
|
+
)
|
|
460
|
+
await self.execute(
|
|
461
|
+
"INSERT INTO inbound_messages ("
|
|
462
|
+
"message_id, seq, session_id, channel_session_id, channel, "
|
|
463
|
+
"provider_thread_id, provider_message_id, provider_time_ms, "
|
|
464
|
+
"received_at_ms, sender, message_type, canonical_target, target_kind, "
|
|
465
|
+
"reply_to_message_id, body, "
|
|
466
|
+
"mentions_agent, notifies_runtime, provider_payload_ref, metadata_json"
|
|
467
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
468
|
+
(
|
|
469
|
+
canonical.message_id,
|
|
470
|
+
canonical.seq,
|
|
471
|
+
canonical.session_id,
|
|
472
|
+
canonical.channel_session_id,
|
|
473
|
+
canonical.channel,
|
|
474
|
+
canonical.provider_thread_id,
|
|
475
|
+
canonical.provider_message_id,
|
|
476
|
+
canonical.provider_time_ms,
|
|
477
|
+
canonical.received_at_ms,
|
|
478
|
+
canonical.sender,
|
|
479
|
+
canonical.message_type,
|
|
480
|
+
canonical.canonical_target,
|
|
481
|
+
canonical.target_kind.value,
|
|
482
|
+
canonical.reply_to_message_id,
|
|
483
|
+
canonical.body,
|
|
484
|
+
int(canonical.mentions_agent),
|
|
485
|
+
int(canonical.notifies_runtime),
|
|
486
|
+
canonical.provider_payload_ref,
|
|
487
|
+
_encode_metadata(canonical.metadata),
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
for ordinal, attachment in enumerate(canonical.attachments):
|
|
491
|
+
await self.execute(
|
|
492
|
+
"INSERT INTO inbound_attachments ("
|
|
493
|
+
"attachment_id, message_id, ordinal, name, kind, state, media_type, "
|
|
494
|
+
"relative_path, size_bytes, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
495
|
+
(
|
|
496
|
+
attachment.attachment_id,
|
|
497
|
+
canonical.message_id,
|
|
498
|
+
ordinal,
|
|
499
|
+
attachment.name,
|
|
500
|
+
attachment.kind,
|
|
501
|
+
attachment.state,
|
|
502
|
+
attachment.media_type,
|
|
503
|
+
attachment.relative_path,
|
|
504
|
+
attachment.size_bytes,
|
|
505
|
+
attachment.error,
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
return canonical
|
|
509
|
+
|
|
510
|
+
async def _attachments(self, message_id: str):
|
|
511
|
+
rows = await self.fetchall(
|
|
512
|
+
"SELECT attachment_id, name, kind, state, media_type, relative_path, "
|
|
513
|
+
"size_bytes, error FROM inbound_attachments WHERE message_id = ? "
|
|
514
|
+
"ORDER BY ordinal",
|
|
515
|
+
(message_id,),
|
|
516
|
+
)
|
|
517
|
+
return tuple(_inbound_attachment_from_row(row) for row in rows)
|
|
518
|
+
|
|
519
|
+
async def save_consumer_cursor(self, cursor: ConsumerCursor) -> None:
|
|
520
|
+
_validate_consumer_cursor_input(cursor)
|
|
521
|
+
if await self.get_bcn_session(cursor.session_id) is None:
|
|
522
|
+
raise ValueError(f"unknown bcn session: {cursor.session_id}")
|
|
523
|
+
latest_seq = await self.get_latest_inbound_seq(cursor.session_id)
|
|
524
|
+
_validate_cursor_bounds(cursor, latest_seq)
|
|
525
|
+
existing = await self.get_consumer_cursor(cursor.session_id)
|
|
526
|
+
if existing is not None:
|
|
527
|
+
_validate_consumer_cursor_update(existing, cursor)
|
|
528
|
+
await self.execute(
|
|
529
|
+
"UPDATE consumer_cursors SET delivered_through_seq = ?, "
|
|
530
|
+
"inbox_snapshot_seq = ?, inbox_snapshot_source = ?, "
|
|
531
|
+
"inbox_snapshot_at_ms = ?, last_check_at_ms = ?, "
|
|
532
|
+
"last_read_at_ms = ?, updated_at_ms = ? WHERE session_id = ?",
|
|
533
|
+
(
|
|
534
|
+
cursor.delivered_through_seq,
|
|
535
|
+
cursor.inbox_snapshot_seq,
|
|
536
|
+
cursor.inbox_snapshot_source,
|
|
537
|
+
cursor.inbox_snapshot_at_ms,
|
|
538
|
+
cursor.last_check_at_ms,
|
|
539
|
+
cursor.last_read_at_ms,
|
|
540
|
+
cursor.updated_at_ms,
|
|
541
|
+
cursor.session_id,
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
return
|
|
545
|
+
await self.execute(
|
|
546
|
+
"INSERT INTO consumer_cursors ("
|
|
547
|
+
"session_id, delivered_through_seq, inbox_snapshot_seq, "
|
|
548
|
+
"inbox_snapshot_source, inbox_snapshot_at_ms, last_check_at_ms, "
|
|
549
|
+
"last_read_at_ms, updated_at_ms"
|
|
550
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
551
|
+
(
|
|
552
|
+
cursor.session_id,
|
|
553
|
+
cursor.delivered_through_seq,
|
|
554
|
+
cursor.inbox_snapshot_seq,
|
|
555
|
+
cursor.inbox_snapshot_source,
|
|
556
|
+
cursor.inbox_snapshot_at_ms,
|
|
557
|
+
cursor.last_check_at_ms,
|
|
558
|
+
cursor.last_read_at_ms,
|
|
559
|
+
cursor.updated_at_ms,
|
|
560
|
+
),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
async def get_outbound_message(
|
|
564
|
+
self, outbound_message_id: str
|
|
565
|
+
) -> OutboundMessage | None:
|
|
566
|
+
row = await self.fetchone(
|
|
567
|
+
"SELECT outbound_message_id, command_id, session_id, "
|
|
568
|
+
"channel_session_id, target, reply_to_message_id, body, "
|
|
569
|
+
"state, fresh_check_state, "
|
|
570
|
+
"snapshot_seq, current_inbound_seq, provider_message_id, "
|
|
571
|
+
"provider_receipt_ref, created_at_ms, provider_attempted_at_ms, "
|
|
572
|
+
"completed_at_ms, draft_saved_at_ms, error_kind, error_message, "
|
|
573
|
+
"next_action, metadata_json FROM outbound_messages "
|
|
574
|
+
"WHERE outbound_message_id = ?",
|
|
575
|
+
(outbound_message_id,),
|
|
576
|
+
)
|
|
577
|
+
return _outbound_message_from_row(row) if row is not None else None
|
|
578
|
+
|
|
579
|
+
async def save_outbound_message(self, message: OutboundMessage) -> OutboundMessage:
|
|
580
|
+
_validate_outbound_message_input(message)
|
|
581
|
+
bcn_session = await self.get_bcn_session(message.session_id)
|
|
582
|
+
if bcn_session is None:
|
|
583
|
+
raise ValueError(f"unknown bcn session: {message.session_id}")
|
|
584
|
+
channel_session = await self.get_channel_session(message.channel_session_id)
|
|
585
|
+
if channel_session is None:
|
|
586
|
+
raise ValueError(f"unknown channel session: {message.channel_session_id}")
|
|
587
|
+
if bcn_session.channel_session_id != message.channel_session_id:
|
|
588
|
+
raise ValueError("outbound message binding does not match bcn session")
|
|
589
|
+
|
|
590
|
+
existing = await self.get_outbound_message(message.outbound_message_id)
|
|
591
|
+
if existing is None:
|
|
592
|
+
canonical = replace(message, outbound_message_id=str(uuid7()))
|
|
593
|
+
_validate_outbound_insert(canonical)
|
|
594
|
+
await self.execute(
|
|
595
|
+
"INSERT INTO outbound_messages ("
|
|
596
|
+
"outbound_message_id, command_id, session_id, "
|
|
597
|
+
"channel_session_id, target, reply_to_message_id, body, "
|
|
598
|
+
"state, fresh_check_state, "
|
|
599
|
+
"snapshot_seq, current_inbound_seq, provider_message_id, "
|
|
600
|
+
"provider_receipt_ref, created_at_ms, provider_attempted_at_ms, "
|
|
601
|
+
"completed_at_ms, draft_saved_at_ms, error_kind, error_message, "
|
|
602
|
+
"next_action, metadata_json"
|
|
603
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
604
|
+
(
|
|
605
|
+
canonical.outbound_message_id,
|
|
606
|
+
canonical.command_id,
|
|
607
|
+
canonical.session_id,
|
|
608
|
+
canonical.channel_session_id,
|
|
609
|
+
canonical.target,
|
|
610
|
+
canonical.reply_to_message_id,
|
|
611
|
+
canonical.body,
|
|
612
|
+
canonical.state.value,
|
|
613
|
+
canonical.fresh_check_state.value,
|
|
614
|
+
canonical.snapshot_seq,
|
|
615
|
+
canonical.current_inbound_seq,
|
|
616
|
+
canonical.provider_message_id,
|
|
617
|
+
canonical.provider_receipt_ref,
|
|
618
|
+
canonical.created_at_ms,
|
|
619
|
+
canonical.provider_attempted_at_ms,
|
|
620
|
+
canonical.completed_at_ms,
|
|
621
|
+
canonical.draft_saved_at_ms,
|
|
622
|
+
canonical.error_kind,
|
|
623
|
+
canonical.error_message,
|
|
624
|
+
canonical.next_action,
|
|
625
|
+
_encode_metadata(canonical.metadata),
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
return canonical
|
|
629
|
+
|
|
630
|
+
if (
|
|
631
|
+
existing.command_id != message.command_id
|
|
632
|
+
or existing.session_id != message.session_id
|
|
633
|
+
or existing.channel_session_id != message.channel_session_id
|
|
634
|
+
or existing.target != message.target
|
|
635
|
+
or existing.reply_to_message_id != message.reply_to_message_id
|
|
636
|
+
or existing.body != message.body
|
|
637
|
+
or existing.created_at_ms != message.created_at_ms
|
|
638
|
+
):
|
|
639
|
+
raise ValueError("outbound message identity cannot change")
|
|
640
|
+
canonical = _validate_outbound_update(existing, message)
|
|
641
|
+
await self.execute(
|
|
642
|
+
"UPDATE outbound_messages SET state = ?, fresh_check_state = ?, "
|
|
643
|
+
"snapshot_seq = ?, current_inbound_seq = ?, provider_message_id = ?, "
|
|
644
|
+
"provider_receipt_ref = ?, provider_attempted_at_ms = ?, "
|
|
645
|
+
"completed_at_ms = ?, draft_saved_at_ms = ?, error_kind = ?, "
|
|
646
|
+
"error_message = ?, next_action = ?, metadata_json = ? "
|
|
647
|
+
"WHERE outbound_message_id = ?",
|
|
648
|
+
(
|
|
649
|
+
canonical.state.value,
|
|
650
|
+
canonical.fresh_check_state.value,
|
|
651
|
+
canonical.snapshot_seq,
|
|
652
|
+
canonical.current_inbound_seq,
|
|
653
|
+
canonical.provider_message_id,
|
|
654
|
+
canonical.provider_receipt_ref,
|
|
655
|
+
canonical.provider_attempted_at_ms,
|
|
656
|
+
canonical.completed_at_ms,
|
|
657
|
+
canonical.draft_saved_at_ms,
|
|
658
|
+
canonical.error_kind,
|
|
659
|
+
canonical.error_message,
|
|
660
|
+
canonical.next_action,
|
|
661
|
+
_encode_metadata(canonical.metadata),
|
|
662
|
+
canonical.outbound_message_id,
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
return canonical
|
|
666
|
+
|
|
667
|
+
async def append_runtime_event(self, event: RuntimeEvent) -> RuntimeEvent:
|
|
668
|
+
_validate_runtime_event_input(event)
|
|
669
|
+
existing_row = await self._fetch_one_or_conflict(
|
|
670
|
+
"SELECT event_seq, event_id, created_at_ms, level, event_name, state, "
|
|
671
|
+
"duration_ms, node_id, channel, runtime, "
|
|
672
|
+
"channel_session_id, bcn_session_id, runtime_session_id, "
|
|
673
|
+
"turn_id, request_id, command_id, inbound_seq, outbound_message_id, "
|
|
674
|
+
"error_kind, error_type, error_message, traceback_ref, metadata_json "
|
|
675
|
+
"FROM runtime_events WHERE event_id = ? ORDER BY event_seq",
|
|
676
|
+
(event.event_id,),
|
|
677
|
+
"runtime event identity",
|
|
678
|
+
)
|
|
679
|
+
if existing_row is not None:
|
|
680
|
+
existing = _runtime_event_from_row(existing_row)
|
|
681
|
+
if _same_runtime_event_payload(existing, event):
|
|
682
|
+
return existing
|
|
683
|
+
raise ValueError(
|
|
684
|
+
"runtime event id is already bound to different event content"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
await self._validate_runtime_event_references(event)
|
|
688
|
+
sequence_row = await self.fetchone(
|
|
689
|
+
"SELECT COALESCE(MAX(event_seq), 0) + 1 AS next_event_seq "
|
|
690
|
+
"FROM runtime_events"
|
|
691
|
+
)
|
|
692
|
+
if sequence_row is None:
|
|
693
|
+
raise RuntimeError("SQLite runtime event sequence query returned no row")
|
|
694
|
+
next_event_seq = _required_positive_int(
|
|
695
|
+
sequence_row["next_event_seq"], "next_event_seq"
|
|
696
|
+
)
|
|
697
|
+
canonical = replace(event, event_seq=next_event_seq)
|
|
698
|
+
await self.execute(
|
|
699
|
+
"INSERT INTO runtime_events ("
|
|
700
|
+
"event_seq, event_id, created_at_ms, level, event_name, state, "
|
|
701
|
+
"duration_ms, node_id, channel, runtime, "
|
|
702
|
+
"channel_session_id, bcn_session_id, runtime_session_id, "
|
|
703
|
+
"turn_id, request_id, command_id, inbound_seq, outbound_message_id, "
|
|
704
|
+
"error_kind, error_type, error_message, traceback_ref, metadata_json"
|
|
705
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
706
|
+
(
|
|
707
|
+
canonical.event_seq,
|
|
708
|
+
canonical.event_id,
|
|
709
|
+
canonical.created_at_ms,
|
|
710
|
+
canonical.level,
|
|
711
|
+
canonical.event_name,
|
|
712
|
+
canonical.state.value,
|
|
713
|
+
canonical.duration_ms,
|
|
714
|
+
canonical.node_id,
|
|
715
|
+
canonical.channel,
|
|
716
|
+
canonical.runtime,
|
|
717
|
+
canonical.channel_session_id,
|
|
718
|
+
canonical.bcn_session_id,
|
|
719
|
+
canonical.runtime_session_id,
|
|
720
|
+
canonical.turn_id,
|
|
721
|
+
canonical.request_id,
|
|
722
|
+
canonical.command_id,
|
|
723
|
+
canonical.inbound_seq,
|
|
724
|
+
canonical.outbound_message_id,
|
|
725
|
+
canonical.error_kind,
|
|
726
|
+
canonical.error_type,
|
|
727
|
+
canonical.error_message,
|
|
728
|
+
canonical.traceback_ref,
|
|
729
|
+
_encode_metadata(canonical.metadata),
|
|
730
|
+
),
|
|
731
|
+
)
|
|
732
|
+
return canonical
|
|
733
|
+
|
|
734
|
+
async def _validate_runtime_event_references(self, event: RuntimeEvent) -> None:
|
|
735
|
+
channel_session = None
|
|
736
|
+
if event.channel_session_id is not None:
|
|
737
|
+
channel_session = await self.get_channel_session(event.channel_session_id)
|
|
738
|
+
if channel_session is None:
|
|
739
|
+
raise ValueError(f"unknown channel session: {event.channel_session_id}")
|
|
740
|
+
if event.channel is not None and event.channel != channel_session.channel:
|
|
741
|
+
raise ValueError("runtime event channel binding does not match")
|
|
742
|
+
|
|
743
|
+
bcn_session = None
|
|
744
|
+
if event.bcn_session_id is not None:
|
|
745
|
+
bcn_session = await self.get_bcn_session(event.bcn_session_id)
|
|
746
|
+
if bcn_session is None:
|
|
747
|
+
raise ValueError(f"unknown bcn session: {event.bcn_session_id}")
|
|
748
|
+
if (
|
|
749
|
+
event.channel_session_id is not None
|
|
750
|
+
and bcn_session.channel_session_id != event.channel_session_id
|
|
751
|
+
):
|
|
752
|
+
raise ValueError("runtime event bcn/channel binding does not match")
|
|
753
|
+
if channel_session is None:
|
|
754
|
+
channel_session = await self.get_channel_session(
|
|
755
|
+
bcn_session.channel_session_id
|
|
756
|
+
)
|
|
757
|
+
if (
|
|
758
|
+
event.channel is not None
|
|
759
|
+
and channel_session is not None
|
|
760
|
+
and event.channel != channel_session.channel
|
|
761
|
+
):
|
|
762
|
+
raise ValueError("runtime event channel binding does not match")
|
|
763
|
+
|
|
764
|
+
runtime_session = None
|
|
765
|
+
if event.runtime_session_id is not None:
|
|
766
|
+
runtime_session = await self.get_runtime_session(event.runtime_session_id)
|
|
767
|
+
if runtime_session is None:
|
|
768
|
+
raise ValueError(f"unknown runtime session: {event.runtime_session_id}")
|
|
769
|
+
if (
|
|
770
|
+
event.bcn_session_id is not None
|
|
771
|
+
and runtime_session.bcn_session_id != event.bcn_session_id
|
|
772
|
+
):
|
|
773
|
+
raise ValueError("runtime event runtime/bcn binding does not match")
|
|
774
|
+
if (
|
|
775
|
+
event.channel_session_id is not None
|
|
776
|
+
and runtime_session.channel_session_id != event.channel_session_id
|
|
777
|
+
):
|
|
778
|
+
raise ValueError("runtime event runtime/channel binding does not match")
|
|
779
|
+
if event.runtime is not None and runtime_session.runtime != event.runtime:
|
|
780
|
+
raise ValueError("runtime event runtime name does not match")
|
|
781
|
+
if event.channel is not None:
|
|
782
|
+
runtime_channel = await self.get_channel_session(
|
|
783
|
+
runtime_session.channel_session_id
|
|
784
|
+
)
|
|
785
|
+
if (
|
|
786
|
+
runtime_channel is not None
|
|
787
|
+
and runtime_channel.channel != event.channel
|
|
788
|
+
):
|
|
789
|
+
raise ValueError("runtime event channel binding does not match")
|
|
790
|
+
|
|
791
|
+
if event.turn_id is not None:
|
|
792
|
+
attempt = await self.get_runtime_attempt(event.turn_id)
|
|
793
|
+
if attempt is None:
|
|
794
|
+
raise ValueError(f"unknown runtime attempt: {event.turn_id}")
|
|
795
|
+
if (
|
|
796
|
+
event.runtime_session_id is not None
|
|
797
|
+
and attempt.session_id != event.runtime_session_id
|
|
798
|
+
):
|
|
799
|
+
raise ValueError("runtime event attempt/runtime binding does not match")
|
|
800
|
+
if runtime_session is None:
|
|
801
|
+
runtime_session = await self.get_runtime_session(attempt.session_id)
|
|
802
|
+
if (
|
|
803
|
+
event.bcn_session_id is not None
|
|
804
|
+
and runtime_session is not None
|
|
805
|
+
and runtime_session.bcn_session_id != event.bcn_session_id
|
|
806
|
+
):
|
|
807
|
+
raise ValueError("runtime event turn/bcn binding does not match")
|
|
808
|
+
if runtime_session is not None:
|
|
809
|
+
if (
|
|
810
|
+
event.channel_session_id is not None
|
|
811
|
+
and runtime_session.channel_session_id != event.channel_session_id
|
|
812
|
+
):
|
|
813
|
+
raise ValueError(
|
|
814
|
+
"runtime event turn/channel binding does not match"
|
|
815
|
+
)
|
|
816
|
+
if (
|
|
817
|
+
event.runtime is not None
|
|
818
|
+
and runtime_session.runtime != event.runtime
|
|
819
|
+
):
|
|
820
|
+
raise ValueError("runtime event turn/runtime name does not match")
|
|
821
|
+
if event.channel is not None:
|
|
822
|
+
turn_channel = await self.get_channel_session(
|
|
823
|
+
runtime_session.channel_session_id
|
|
824
|
+
)
|
|
825
|
+
if (
|
|
826
|
+
turn_channel is not None
|
|
827
|
+
and turn_channel.channel != event.channel
|
|
828
|
+
):
|
|
829
|
+
raise ValueError(
|
|
830
|
+
"runtime event turn/channel binding does not match"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
if event.outbound_message_id is not None:
|
|
834
|
+
outbound = await self.get_outbound_message(event.outbound_message_id)
|
|
835
|
+
if outbound is None:
|
|
836
|
+
raise ValueError(
|
|
837
|
+
f"unknown outbound message: {event.outbound_message_id}"
|
|
838
|
+
)
|
|
839
|
+
if (
|
|
840
|
+
event.bcn_session_id is not None
|
|
841
|
+
and outbound.session_id != event.bcn_session_id
|
|
842
|
+
):
|
|
843
|
+
raise ValueError("runtime event outbound/bcn binding does not match")
|
|
844
|
+
if (
|
|
845
|
+
event.channel_session_id is not None
|
|
846
|
+
and outbound.channel_session_id != event.channel_session_id
|
|
847
|
+
):
|
|
848
|
+
raise ValueError(
|
|
849
|
+
"runtime event outbound/channel binding does not match"
|
|
850
|
+
)
|
|
851
|
+
if event.channel is not None:
|
|
852
|
+
outbound_channel = await self.get_channel_session(
|
|
853
|
+
outbound.channel_session_id
|
|
854
|
+
)
|
|
855
|
+
if (
|
|
856
|
+
outbound_channel is not None
|
|
857
|
+
and outbound_channel.channel != event.channel
|
|
858
|
+
):
|
|
859
|
+
raise ValueError(
|
|
860
|
+
"runtime event outbound/channel binding does not match"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if event.inbound_seq is not None and event.bcn_session_id is not None:
|
|
864
|
+
row = await self.fetchone(
|
|
865
|
+
"SELECT 1 FROM inbound_messages WHERE session_id = ? AND seq = ?",
|
|
866
|
+
(event.bcn_session_id, event.inbound_seq),
|
|
867
|
+
)
|
|
868
|
+
if row is None:
|
|
869
|
+
raise ValueError(
|
|
870
|
+
f"unknown inbound sequence for bcn session: {event.inbound_seq}"
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
async def save_channel_session(self, session: ChannelSession) -> None:
|
|
874
|
+
_validate_channel_session_input(session)
|
|
875
|
+
existing = await self.get_channel_session(session.id)
|
|
876
|
+
if existing is None:
|
|
877
|
+
duplicate = await self.find_channel_session(
|
|
878
|
+
channel=session.channel,
|
|
879
|
+
provider_thread_id=session.provider_thread_id,
|
|
880
|
+
)
|
|
881
|
+
if duplicate is not None:
|
|
882
|
+
raise ValueError(
|
|
883
|
+
f"channel provider identity is already bound to {duplicate.id}"
|
|
884
|
+
)
|
|
885
|
+
await self.execute(
|
|
886
|
+
"INSERT INTO channel_sessions ("
|
|
887
|
+
"id, channel, provider_thread_id, target_kind, following, "
|
|
888
|
+
"provider_identity_ref_json, created_at_ms, updated_at_ms, "
|
|
889
|
+
"last_inbound_at_ms, last_outbound_at_ms"
|
|
890
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
891
|
+
(
|
|
892
|
+
session.id,
|
|
893
|
+
session.channel,
|
|
894
|
+
session.provider_thread_id,
|
|
895
|
+
session.target_kind.value,
|
|
896
|
+
int(session.following),
|
|
897
|
+
_encode_metadata(session.metadata),
|
|
898
|
+
session.created_at_ms,
|
|
899
|
+
session.updated_at_ms,
|
|
900
|
+
session.last_inbound_at_ms,
|
|
901
|
+
session.last_outbound_at_ms,
|
|
902
|
+
),
|
|
903
|
+
)
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
session = _validate_channel_session_update(existing, session)
|
|
907
|
+
await self.execute(
|
|
908
|
+
"UPDATE channel_sessions SET target_kind = ?, following = ?, "
|
|
909
|
+
"updated_at_ms = ?, last_inbound_at_ms = ?, last_outbound_at_ms = ?, "
|
|
910
|
+
"provider_identity_ref_json = ? WHERE id = ?",
|
|
911
|
+
(
|
|
912
|
+
session.target_kind.value,
|
|
913
|
+
int(session.following),
|
|
914
|
+
session.updated_at_ms,
|
|
915
|
+
session.last_inbound_at_ms,
|
|
916
|
+
session.last_outbound_at_ms,
|
|
917
|
+
_encode_metadata(session.metadata),
|
|
918
|
+
session.id,
|
|
919
|
+
),
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
async def save_bcn_session(self, session: BcnSession) -> None:
|
|
923
|
+
self._require_workspace(session.workspace_id)
|
|
924
|
+
channel_session = await self.get_channel_session(session.channel_session_id)
|
|
925
|
+
if channel_session is None:
|
|
926
|
+
raise ValueError(f"unknown channel session: {session.channel_session_id}")
|
|
927
|
+
|
|
928
|
+
existing = await self.get_bcn_session(session.id)
|
|
929
|
+
if existing is None:
|
|
930
|
+
duplicate = await self.find_bcn_session(session.channel_session_id)
|
|
931
|
+
if duplicate is not None:
|
|
932
|
+
raise ValueError(f"channel session is already bound to {duplicate.id}")
|
|
933
|
+
await self.execute(
|
|
934
|
+
"INSERT INTO bcn_sessions ("
|
|
935
|
+
"id, channel_session_id, workspace_id, "
|
|
936
|
+
"created_at_ms, updated_at_ms, last_activity_at_ms, "
|
|
937
|
+
"metadata_json"
|
|
938
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
939
|
+
(
|
|
940
|
+
session.id,
|
|
941
|
+
session.channel_session_id,
|
|
942
|
+
session.workspace_id,
|
|
943
|
+
session.created_at_ms,
|
|
944
|
+
session.updated_at_ms,
|
|
945
|
+
session.last_activity_at_ms,
|
|
946
|
+
_encode_metadata(session.metadata),
|
|
947
|
+
),
|
|
948
|
+
)
|
|
949
|
+
return
|
|
950
|
+
|
|
951
|
+
session = _validate_bcn_session_update(existing, session)
|
|
952
|
+
await self.execute(
|
|
953
|
+
"UPDATE bcn_sessions SET updated_at_ms = ?, "
|
|
954
|
+
"last_activity_at_ms = ?, metadata_json = ? "
|
|
955
|
+
"WHERE id = ?",
|
|
956
|
+
(
|
|
957
|
+
session.updated_at_ms,
|
|
958
|
+
session.last_activity_at_ms,
|
|
959
|
+
_encode_metadata(session.metadata),
|
|
960
|
+
session.id,
|
|
961
|
+
),
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
async def save_runtime_session(self, session: RuntimeSession) -> None:
|
|
965
|
+
self._require_workspace(session.workspace_id)
|
|
966
|
+
bcn_session = await self.get_bcn_session(session.bcn_session_id)
|
|
967
|
+
if bcn_session is None:
|
|
968
|
+
raise ValueError(f"unknown bcn session: {session.bcn_session_id}")
|
|
969
|
+
if await self.get_channel_session(bcn_session.channel_session_id) is None:
|
|
970
|
+
raise ValueError(
|
|
971
|
+
f"unknown channel session: {bcn_session.channel_session_id}"
|
|
972
|
+
)
|
|
973
|
+
if (
|
|
974
|
+
bcn_session.channel_session_id != session.channel_session_id
|
|
975
|
+
or bcn_session.workspace_id != session.workspace_id
|
|
976
|
+
):
|
|
977
|
+
raise ValueError("runtime session binding does not match bcn session")
|
|
978
|
+
|
|
979
|
+
existing = await self.get_runtime_session(session.id)
|
|
980
|
+
if existing is None:
|
|
981
|
+
duplicate = await self.find_runtime_session(session.bcn_session_id)
|
|
982
|
+
if duplicate is not None:
|
|
983
|
+
raise ValueError(f"bcn session is already bound to {duplicate.id}")
|
|
984
|
+
await self.execute(
|
|
985
|
+
"INSERT INTO runtime_sessions ("
|
|
986
|
+
"id, bcn_session_id, channel_session_id, "
|
|
987
|
+
"runtime, runtime_version, provider_thread_id, "
|
|
988
|
+
"created_at_ms, updated_at_ms, metadata_json"
|
|
989
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
990
|
+
(
|
|
991
|
+
session.id,
|
|
992
|
+
session.bcn_session_id,
|
|
993
|
+
session.channel_session_id,
|
|
994
|
+
session.runtime,
|
|
995
|
+
None,
|
|
996
|
+
session.provider_thread_id,
|
|
997
|
+
session.created_at_ms,
|
|
998
|
+
session.updated_at_ms,
|
|
999
|
+
_encode_metadata(session.metadata),
|
|
1000
|
+
),
|
|
1001
|
+
)
|
|
1002
|
+
return
|
|
1003
|
+
|
|
1004
|
+
session = _validate_runtime_session_update(existing, session)
|
|
1005
|
+
await self.execute(
|
|
1006
|
+
"UPDATE runtime_sessions SET provider_thread_id = ?, "
|
|
1007
|
+
"updated_at_ms = ?, metadata_json = ? "
|
|
1008
|
+
"WHERE id = ?",
|
|
1009
|
+
(
|
|
1010
|
+
session.provider_thread_id,
|
|
1011
|
+
session.updated_at_ms,
|
|
1012
|
+
_encode_metadata(session.metadata),
|
|
1013
|
+
session.id,
|
|
1014
|
+
),
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
async def save_runtime_attempt(self, attempt: RuntimeAttempt) -> None:
|
|
1018
|
+
if not isinstance(attempt, RuntimeAttempt):
|
|
1019
|
+
raise TypeError("attempt must be a RuntimeAttempt")
|
|
1020
|
+
if await self.get_runtime_session(attempt.session_id) is None:
|
|
1021
|
+
raise ValueError(f"unknown runtime session: {attempt.session_id}")
|
|
1022
|
+
existing = await self.get_runtime_attempt(attempt.turn_id)
|
|
1023
|
+
if existing is not None:
|
|
1024
|
+
if existing != attempt:
|
|
1025
|
+
raise ValueError("runtime attempt is immutable")
|
|
1026
|
+
return
|
|
1027
|
+
await self.execute(
|
|
1028
|
+
"INSERT INTO runtime_attempts "
|
|
1029
|
+
"(turn_id, session_id, client_user_message_id, started_at_ms) "
|
|
1030
|
+
"VALUES (?, ?, ?, ?)",
|
|
1031
|
+
(
|
|
1032
|
+
attempt.turn_id,
|
|
1033
|
+
attempt.session_id,
|
|
1034
|
+
attempt.client_user_message_id,
|
|
1035
|
+
attempt.started_at_ms,
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
async def _fetch_one_or_conflict(
|
|
1040
|
+
self,
|
|
1041
|
+
statement: str,
|
|
1042
|
+
parameters: Sequence[object],
|
|
1043
|
+
binding_name: str,
|
|
1044
|
+
) -> aiosqlite.Row | None:
|
|
1045
|
+
rows = await self.fetchall(statement, parameters)
|
|
1046
|
+
if len(rows) > 1:
|
|
1047
|
+
raise ValueError(f"multiple rows violate {binding_name}")
|
|
1048
|
+
return rows[0] if rows else None
|
|
1049
|
+
|
|
1050
|
+
def _require_workspace(self, workspace_id: str) -> None:
|
|
1051
|
+
if workspace_id != self._database.workspace_id:
|
|
1052
|
+
raise ValueError(
|
|
1053
|
+
"session workspace does not match the persisted node workspace"
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
def _require_active_connection(self) -> aiosqlite.Connection:
|
|
1057
|
+
if not self._active or self._connection is None:
|
|
1058
|
+
raise RuntimeError("SQLite transaction is not active")
|
|
1059
|
+
return self._connection
|