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