agentconnect-cli 0.1.0a1__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 (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
agentconnect/store.py ADDED
@@ -0,0 +1,3282 @@
1
+ """The daemon's own database, separate from the core's MLS store.
2
+
3
+ Per group it keeps metadata, policy, members (with the local, human-set
4
+ ``verified`` flag), trust state, the adapter binding, peer Agent Cards and an
5
+ ``out_of_sync`` flag. It also holds the durable queues:
6
+
7
+ * **outbox**: everything to send, kept until the relay answers it;
8
+ * **inbox**: every received ``(sender, msg_id)``; a delivery stays
9
+ ``pending`` until the adapter accepts it. This is the dedup table that
10
+ makes delivery at-least-once without double processing.
11
+
12
+ The file is a SQLCipher database (:class:`agentconnect_core.Database`,
13
+ ADR-003) under a key the core derives from the keystore's ``db_key``: every
14
+ page, the journal and the WAL are encrypted and authenticated, and there is
15
+ no unencrypted mode. On top of that, plaintext is kept only while it is
16
+ needed: an outbox row keeps just its ids once the relay has answered it, an
17
+ inbox row drops its delivery once the adapter has it, and a decided
18
+ JoinRequest drops its body. ``secure_delete`` overwrites what is removed.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from collections.abc import Generator, Sequence
25
+ from contextlib import contextmanager
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any, Literal
29
+
30
+ import agentconnect_core as ac
31
+ from agentconnect.keystore import private_file
32
+ from agentconnect.limits import TASK_UPDATE_KINDS
33
+
34
+ SqlParam = None | int | float | str | bytes
35
+ HANDLED_KEEP = 50_000 # ids an in-process agent finished with (Runtime.handled, the MCP host)
36
+ #: The owner's replay ledgers (SPEC 6.2 "Replays", 6.6.3 step 6): table and id column.
37
+ Ledger = Literal["knock", "join"]
38
+ LEDGERS: dict[str, tuple[str, str]] = {
39
+ "knock": ("knock_ledger", "knock_id"),
40
+ "join": ("join_ledger", "request_id"),
41
+ }
42
+
43
+
44
+ class _Cursor:
45
+ """The result of one statement: its rows, or its change count."""
46
+
47
+ def __init__(self, rows: list[tuple[Any, ...]], rowcount: int, lastrowid: int) -> None:
48
+ self._rows, self.rowcount, self.lastrowid = rows, rowcount, lastrowid
49
+
50
+ def fetchone(self) -> tuple[Any, ...] | None:
51
+ return self._rows[0] if self._rows else None
52
+
53
+ def fetchall(self) -> list[tuple[Any, ...]]:
54
+ return self._rows
55
+
56
+ def __iter__(self) -> Any:
57
+ return iter(self._rows)
58
+
59
+
60
+ class _Connection:
61
+ """A small ``sqlite3``-shaped facade over the encrypted database."""
62
+
63
+ def __init__(self, path: Path, key: bytes) -> None:
64
+ self._db = ac.Database(str(path), key)
65
+
66
+ def execute(self, sql: str, params: Sequence[SqlParam] = ()) -> _Cursor:
67
+ values = list(params)
68
+ if sql.lstrip().upper().startswith("SELECT"):
69
+ return _Cursor(self._db.query(sql, values), -1, 0)
70
+ changes, rowid = self._db.execute(sql, values)
71
+ return _Cursor([], changes, rowid)
72
+
73
+ def executescript(self, sql: str) -> None:
74
+ self._db.execute_script(sql)
75
+
76
+ def close(self) -> None:
77
+ self._db.close()
78
+
79
+
80
+ DELIVERED = frozenset({"deliver", "deliver_as_context"})
81
+ # Kinds whose parts reach the agent: what an injection would ride in.
82
+ CONTENT_KINDS = frozenset({"a2a.message"}) | TASK_UPDATE_KINDS
83
+ # Delivered kinds that change the group's or a task's state.
84
+ STATE_KINDS = frozenset(
85
+ {"control.close", "control.pause", "control.resume", "control.floor", "a2a.task_cancel"}
86
+ )
87
+ # What closes a hold: the human's approval, rejection or cancellation, or
88
+ # its expiry.
89
+ HOLD_OUTCOMES = frozenset({"approved", "rejected", "cancelled", "expired"})
90
+ # Refusals that say a sender tried what it may not, rather than sent too
91
+ # much: a human's or the owner's control, a control or card from an agent,
92
+ # content smuggled into a control, card or cancel, a part the group doesn't
93
+ # allow, a card that fails its pinned key, and a message that failed in this
94
+ # daemon's own handling (it fails alone, and may be an attack).
95
+ SECURITY_DROPS = frozenset(
96
+ {
97
+ "not_human",
98
+ "not_owner",
99
+ "not_for_agents",
100
+ "malformed",
101
+ "part_kind",
102
+ "bad_signature",
103
+ "processing_failed",
104
+ }
105
+ )
106
+
107
+
108
+ def audit_class(group_id: str, decision: str, kind: str, reason: str) -> str:
109
+ """The audit row class (:meth:`Store.audit_add`).
110
+
111
+ * ``security``: in a group, content that reached the agent or left it
112
+ (a delivered message or task update, a changed Agent Card), a hold, a
113
+ hold's outcome, and the refusals in :data:`SECURITY_DROPS`. For a
114
+ knock (no group yet), the human's approval or rejection.
115
+ * ``decision``: a delivered control that changes state (close, pause,
116
+ resume, floor) or task cancel, and a knock's hold and expiry.
117
+ * ``routine``: the other deliveries (an unchanged card, a leave notice,
118
+ capabilities).
119
+ * ``drop``: every other refusal.
120
+ """
121
+ if decision == "drop":
122
+ return "security" if group_id and reason in SECURITY_DROPS else "drop"
123
+ if decision in HOLD_OUTCOMES:
124
+ return "security" if group_id or decision != "expired" else "decision"
125
+ if decision == "hold_for_approval":
126
+ return "security" if group_id else "decision"
127
+ if decision in DELIVERED:
128
+ if kind in CONTENT_KINDS or (kind == "a2a.agent_card" and reason == "card_changed"):
129
+ return "security"
130
+ return "decision" if kind in STATE_KINDS else "routine"
131
+ return "decision"
132
+
133
+
134
+ def _water_level(counts: list[int], target: int) -> list[int]:
135
+ """How many rows to take from each of ``counts`` (largest first) so
136
+ that ``target`` go in all, taken from the top: every count above some
137
+ level comes down to it, and the remainder comes from the first ones."""
138
+ level, taken = counts[0], 0
139
+ while taken < target and level > 0:
140
+ level -= 1
141
+ taken = sum(max(0, n - level) for n in counts)
142
+ cut = [max(0, n - level) for n in counts]
143
+ extra = taken - target # the last step took one too many from each of these
144
+ for i in reversed(range(len(cut))):
145
+ if extra <= 0:
146
+ break
147
+ if cut[i] > 0 and counts[i] - cut[i] == level:
148
+ cut[i] -= 1
149
+ extra -= 1
150
+ return cut
151
+
152
+
153
+ def _scrubbed(payload: dict[str, Any]) -> dict[str, Any]:
154
+ """What an answered outbox row keeps: its ids, never message content."""
155
+ out: dict[str, Any] = {}
156
+ env = payload.get("env")
157
+ msg_id = payload.get("msg_id") or (env.get("msg_id") if isinstance(env, dict) else None)
158
+ if msg_id is not None:
159
+ out["msg_id"] = msg_id
160
+ frame = payload.get("frame")
161
+ if isinstance(frame, dict) and "t" in frame:
162
+ out["t"] = frame["t"]
163
+ return out
164
+
165
+
166
+ SCHEMA = """
167
+ CREATE TABLE IF NOT EXISTS groups (
168
+ group_id TEXT PRIMARY KEY,
169
+ name TEXT NOT NULL,
170
+ purpose TEXT NOT NULL,
171
+ policy TEXT NOT NULL DEFAULT '{}',
172
+ adapter TEXT NOT NULL,
173
+ status TEXT NOT NULL DEFAULT 'active',
174
+ out_of_sync INTEGER NOT NULL DEFAULT 0,
175
+ out_of_sync_reason TEXT,
176
+ epoch INTEGER NOT NULL DEFAULT 0,
177
+ created_at INTEGER NOT NULL,
178
+ last_own_commit_at INTEGER NOT NULL,
179
+ msgs_since_commit INTEGER NOT NULL DEFAULT 0,
180
+ hidden INTEGER NOT NULL DEFAULT 0,
181
+ origin TEXT,
182
+ adapters TEXT
183
+ );
184
+ CREATE TABLE IF NOT EXISTS members (
185
+ group_id TEXT NOT NULL,
186
+ identity TEXT NOT NULL,
187
+ display_name TEXT,
188
+ role TEXT NOT NULL DEFAULT 'member',
189
+ verified INTEGER NOT NULL DEFAULT 0,
190
+ instances TEXT NOT NULL DEFAULT '[]',
191
+ verified_epoch INTEGER,
192
+ PRIMARY KEY (group_id, identity)
193
+ );
194
+ CREATE TABLE IF NOT EXISTS agent_cards (
195
+ group_id TEXT NOT NULL,
196
+ identity TEXT NOT NULL,
197
+ card TEXT NOT NULL,
198
+ PRIMARY KEY (group_id, identity)
199
+ );
200
+ CREATE TABLE IF NOT EXISTS invites (
201
+ invite_id TEXT PRIMARY KEY,
202
+ group_id TEXT NOT NULL,
203
+ expires_at INTEGER NOT NULL,
204
+ max_uses INTEGER NOT NULL,
205
+ created_at INTEGER NOT NULL,
206
+ status TEXT NOT NULL DEFAULT 'open',
207
+ replica INTEGER NOT NULL DEFAULT 0,
208
+ instance TEXT
209
+ );
210
+ CREATE TABLE IF NOT EXISTS approvals (
211
+ request_id TEXT PRIMARY KEY,
212
+ invite_id TEXT NOT NULL,
213
+ group_id TEXT NOT NULL,
214
+ request TEXT NOT NULL,
215
+ identity TEXT NOT NULL,
216
+ display_name TEXT NOT NULL,
217
+ mailbox_id TEXT NOT NULL,
218
+ received_at INTEGER NOT NULL,
219
+ status TEXT NOT NULL DEFAULT 'pending',
220
+ kp_ref TEXT,
221
+ instance_id TEXT
222
+ );
223
+ CREATE TABLE IF NOT EXISTS joins (
224
+ invite_id TEXT PRIMARY KEY,
225
+ meta TEXT NOT NULL,
226
+ display_name TEXT NOT NULL,
227
+ created_at INTEGER NOT NULL,
228
+ status TEXT NOT NULL DEFAULT 'pending',
229
+ group_id TEXT
230
+ );
231
+ CREATE TABLE IF NOT EXISTS outbox (
232
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
233
+ kind TEXT NOT NULL,
234
+ group_id TEXT,
235
+ payload TEXT NOT NULL,
236
+ state TEXT NOT NULL DEFAULT 'queued',
237
+ created_at INTEGER NOT NULL
238
+ );
239
+ CREATE TABLE IF NOT EXISTS inbox (
240
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
241
+ sender TEXT NOT NULL,
242
+ msg_id TEXT NOT NULL,
243
+ group_id TEXT,
244
+ delivery TEXT,
245
+ state TEXT NOT NULL,
246
+ received_at INTEGER NOT NULL,
247
+ hop INTEGER,
248
+ unacked INTEGER NOT NULL DEFAULT 0,
249
+ UNIQUE (sender, msg_id)
250
+ );
251
+ CREATE TABLE IF NOT EXISTS inbox_pending_bytes (
252
+ group_id TEXT PRIMARY KEY,
253
+ bytes INTEGER NOT NULL
254
+ );
255
+ CREATE TABLE IF NOT EXISTS sent_via (
256
+ group_id TEXT NOT NULL,
257
+ msg_id TEXT NOT NULL,
258
+ adapter TEXT NOT NULL,
259
+ at INTEGER NOT NULL,
260
+ PRIMARY KEY (group_id, msg_id)
261
+ );
262
+ CREATE TABLE IF NOT EXISTS inbox_acks (
263
+ inbox_seq INTEGER NOT NULL,
264
+ adapter TEXT NOT NULL,
265
+ PRIMARY KEY (inbox_seq, adapter)
266
+ );
267
+ CREATE TABLE IF NOT EXISTS event_acks (
268
+ event_id TEXT NOT NULL,
269
+ adapter TEXT NOT NULL,
270
+ PRIMARY KEY (event_id, adapter)
271
+ );
272
+ CREATE TABLE IF NOT EXISTS events (
273
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
274
+ group_id TEXT,
275
+ type TEXT NOT NULL,
276
+ data TEXT NOT NULL,
277
+ at INTEGER NOT NULL,
278
+ event_id TEXT,
279
+ notified INTEGER NOT NULL DEFAULT 0
280
+ );
281
+ CREATE TABLE IF NOT EXISTS feed (
282
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
283
+ key TEXT NOT NULL UNIQUE,
284
+ group_id TEXT,
285
+ payload TEXT NOT NULL,
286
+ at INTEGER NOT NULL DEFAULT 0
287
+ );
288
+ CREATE TABLE IF NOT EXISTS hops (
289
+ group_id TEXT NOT NULL,
290
+ msg_id TEXT NOT NULL,
291
+ hop INTEGER NOT NULL,
292
+ at INTEGER NOT NULL,
293
+ PRIMARY KEY (group_id, msg_id)
294
+ );
295
+ CREATE TABLE IF NOT EXISTS task_requests (
296
+ group_id TEXT NOT NULL,
297
+ msg_id TEXT NOT NULL,
298
+ task_id TEXT,
299
+ asked TEXT NOT NULL,
300
+ instance TEXT,
301
+ at INTEGER NOT NULL,
302
+ PRIMARY KEY (group_id, msg_id)
303
+ );
304
+ CREATE INDEX IF NOT EXISTS task_requests_at ON task_requests (at);
305
+ CREATE TABLE IF NOT EXISTS request_tasks (
306
+ group_id TEXT NOT NULL,
307
+ msg_id TEXT NOT NULL,
308
+ responder TEXT NOT NULL,
309
+ instance TEXT NOT NULL,
310
+ task_id TEXT NOT NULL,
311
+ triggered TEXT NOT NULL DEFAULT '[]',
312
+ ended INTEGER NOT NULL DEFAULT 0,
313
+ at INTEGER NOT NULL,
314
+ PRIMARY KEY (group_id, msg_id, responder)
315
+ );
316
+ CREATE INDEX IF NOT EXISTS request_tasks_at ON request_tasks (at);
317
+ CREATE INDEX IF NOT EXISTS request_tasks_task ON request_tasks (group_id, task_id);
318
+ CREATE TABLE IF NOT EXISTS answered_tasks (
319
+ group_id TEXT NOT NULL,
320
+ task_id TEXT NOT NULL,
321
+ msg_id TEXT NOT NULL,
322
+ at INTEGER NOT NULL,
323
+ PRIMARY KEY (group_id, task_id)
324
+ );
325
+ CREATE INDEX IF NOT EXISTS answered_tasks_at ON answered_tasks (at);
326
+ CREATE TABLE IF NOT EXISTS asked_requests (
327
+ group_id TEXT NOT NULL,
328
+ msg_id TEXT NOT NULL,
329
+ task_id TEXT,
330
+ sent TEXT NOT NULL DEFAULT '[]',
331
+ ended INTEGER NOT NULL DEFAULT 0,
332
+ at INTEGER NOT NULL,
333
+ PRIMARY KEY (group_id, msg_id)
334
+ );
335
+ CREATE INDEX IF NOT EXISTS asked_requests_at ON asked_requests (at);
336
+ CREATE TABLE IF NOT EXISTS a2a_agent_tasks (
337
+ task_id TEXT PRIMARY KEY,
338
+ group_id TEXT NOT NULL,
339
+ requester TEXT NOT NULL,
340
+ instance TEXT NOT NULL,
341
+ msg_id TEXT NOT NULL,
342
+ at INTEGER NOT NULL
343
+ );
344
+ CREATE INDEX IF NOT EXISTS a2a_agent_tasks_at ON a2a_agent_tasks (at);
345
+ CREATE INDEX IF NOT EXISTS a2a_agent_tasks_peer
346
+ ON a2a_agent_tasks (group_id, requester, instance, at);
347
+ CREATE TABLE IF NOT EXISTS kv (
348
+ key TEXT PRIMARY KEY,
349
+ value TEXT NOT NULL
350
+ );
351
+ CREATE TABLE IF NOT EXISTS attempts (
352
+ key TEXT PRIMARY KEY,
353
+ n INTEGER NOT NULL,
354
+ at INTEGER NOT NULL
355
+ );
356
+ CREATE TABLE IF NOT EXISTS floor_state (
357
+ group_id TEXT PRIMARY KEY,
358
+ holder TEXT,
359
+ state TEXT NOT NULL
360
+ );
361
+ CREATE TABLE IF NOT EXISTS floor_writes (
362
+ group_id TEXT NOT NULL,
363
+ epoch INTEGER NOT NULL,
364
+ counter INTEGER NOT NULL,
365
+ sender TEXT NOT NULL,
366
+ instance TEXT NOT NULL,
367
+ kind TEXT NOT NULL,
368
+ holder TEXT,
369
+ PRIMARY KEY (group_id, epoch, counter, sender, instance)
370
+ );
371
+ CREATE TABLE IF NOT EXISTS group_safety (
372
+ group_id TEXT PRIMARY KEY,
373
+ settings TEXT NOT NULL
374
+ );
375
+ CREATE TABLE IF NOT EXISTS held (
376
+ hold_id TEXT PRIMARY KEY,
377
+ group_id TEXT NOT NULL,
378
+ direction TEXT NOT NULL,
379
+ sender TEXT NOT NULL,
380
+ instance TEXT NOT NULL DEFAULT '',
381
+ msg_id TEXT NOT NULL,
382
+ kind TEXT NOT NULL,
383
+ size INTEGER NOT NULL,
384
+ task_id TEXT,
385
+ inbox_seq INTEGER,
386
+ payload TEXT,
387
+ created_at INTEGER NOT NULL,
388
+ expires_at INTEGER NOT NULL,
389
+ status TEXT NOT NULL DEFAULT 'pending'
390
+ );
391
+ CREATE TABLE IF NOT EXISTS task_decisions (
392
+ group_id TEXT NOT NULL,
393
+ sender TEXT NOT NULL,
394
+ task_id TEXT NOT NULL,
395
+ state TEXT NOT NULL,
396
+ at INTEGER NOT NULL,
397
+ PRIMARY KEY (group_id, sender, task_id)
398
+ );
399
+ CREATE TABLE IF NOT EXISTS audit (
400
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
401
+ group_id TEXT NOT NULL,
402
+ at INTEGER NOT NULL,
403
+ direction TEXT NOT NULL,
404
+ sender TEXT NOT NULL,
405
+ msg_id TEXT NOT NULL,
406
+ kind TEXT NOT NULL,
407
+ size INTEGER NOT NULL,
408
+ decision TEXT NOT NULL,
409
+ reason TEXT NOT NULL,
410
+ class TEXT NOT NULL DEFAULT 'decision'
411
+ );
412
+ CREATE TABLE IF NOT EXISTS audit_suppressed (
413
+ group_id TEXT NOT NULL,
414
+ sender TEXT NOT NULL,
415
+ decision TEXT NOT NULL,
416
+ count INTEGER NOT NULL,
417
+ first_at INTEGER NOT NULL,
418
+ last_at INTEGER NOT NULL,
419
+ PRIMARY KEY (group_id, sender, decision)
420
+ );
421
+ CREATE TABLE IF NOT EXISTS transcript_bytes (
422
+ group_id TEXT PRIMARY KEY,
423
+ bytes INTEGER NOT NULL
424
+ );
425
+ CREATE TABLE IF NOT EXISTS transcript (
426
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
427
+ group_id TEXT NOT NULL,
428
+ at INTEGER NOT NULL,
429
+ direction TEXT NOT NULL,
430
+ sender TEXT NOT NULL,
431
+ msg_id TEXT NOT NULL,
432
+ kind TEXT NOT NULL,
433
+ inner TEXT NOT NULL,
434
+ size INTEGER NOT NULL DEFAULT 0
435
+ );
436
+ CREATE TABLE IF NOT EXISTS adapter_events (
437
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
438
+ adapter TEXT NOT NULL,
439
+ event_id TEXT NOT NULL,
440
+ payload TEXT NOT NULL,
441
+ UNIQUE (adapter, event_id)
442
+ );
443
+ CREATE TABLE IF NOT EXISTS handled (
444
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
445
+ key TEXT NOT NULL UNIQUE
446
+ );
447
+ CREATE TABLE IF NOT EXISTS listings (
448
+ directory TEXT NOT NULL,
449
+ name TEXT NOT NULL,
450
+ state TEXT NOT NULL,
451
+ content TEXT NOT NULL,
452
+ token TEXT NOT NULL,
453
+ token_expires_at INTEGER NOT NULL,
454
+ owner_domain TEXT,
455
+ seq INTEGER NOT NULL DEFAULT 0,
456
+ max_signed_seq INTEGER NOT NULL DEFAULT 0,
457
+ published BLOB,
458
+ published_sha256 TEXT,
459
+ published_at INTEGER,
460
+ pending BLOB,
461
+ pending_door TEXT,
462
+ owner_state TEXT,
463
+ next_renewal_at INTEGER,
464
+ retry_at INTEGER,
465
+ attempts INTEGER NOT NULL DEFAULT 0,
466
+ failing_since INTEGER,
467
+ stale_since INTEGER,
468
+ last_error TEXT,
469
+ token_warned INTEGER NOT NULL DEFAULT 0,
470
+ restore_state TEXT,
471
+ restore_renewal_at INTEGER,
472
+ created_at INTEGER NOT NULL,
473
+ updated_at INTEGER NOT NULL,
474
+ PRIMARY KEY (directory, name)
475
+ );
476
+ CREATE TABLE IF NOT EXISTS doors (
477
+ door_id TEXT PRIMARY KEY,
478
+ directory TEXT NOT NULL,
479
+ name TEXT NOT NULL,
480
+ secret BLOB,
481
+ expires_at INTEGER NOT NULL,
482
+ created_at INTEGER NOT NULL,
483
+ state TEXT NOT NULL
484
+ );
485
+ CREATE TABLE IF NOT EXISTS knock_ledger (
486
+ knock_id TEXT PRIMARY KEY,
487
+ kp_ref TEXT NOT NULL,
488
+ forget_after INTEGER NOT NULL
489
+ );
490
+ CREATE INDEX IF NOT EXISTS knock_ledger_until ON knock_ledger (forget_after);
491
+ CREATE TABLE IF NOT EXISTS join_ledger (
492
+ request_id TEXT PRIMARY KEY,
493
+ invite_id TEXT NOT NULL,
494
+ kp_ref TEXT NOT NULL,
495
+ forget_after INTEGER NOT NULL
496
+ );
497
+ CREATE INDEX IF NOT EXISTS join_ledger_until ON join_ledger (forget_after);
498
+ CREATE INDEX IF NOT EXISTS join_ledger_invite ON join_ledger (invite_id);
499
+ CREATE TABLE IF NOT EXISTS knock_requests (
500
+ request_id TEXT PRIMARY KEY,
501
+ knock_id TEXT NOT NULL UNIQUE,
502
+ door_id TEXT NOT NULL,
503
+ directory TEXT NOT NULL,
504
+ name TEXT NOT NULL,
505
+ identity TEXT NOT NULL,
506
+ mailbox_id TEXT NOT NULL,
507
+ kp_ref TEXT NOT NULL,
508
+ display_name TEXT,
509
+ note TEXT,
510
+ ts INTEGER NOT NULL,
511
+ lapses_at INTEGER NOT NULL,
512
+ received_at INTEGER NOT NULL,
513
+ knock BLOB,
514
+ status TEXT NOT NULL,
515
+ group_id TEXT,
516
+ purpose TEXT,
517
+ decided_at INTEGER
518
+ );
519
+ CREATE INDEX IF NOT EXISTS knock_requests_status ON knock_requests (status, lapses_at);
520
+ CREATE TABLE IF NOT EXISTS knocks (
521
+ knock_id TEXT PRIMARY KEY,
522
+ directory TEXT NOT NULL,
523
+ name TEXT NOT NULL,
524
+ identity TEXT NOT NULL,
525
+ door_id TEXT NOT NULL,
526
+ listing BLOB NOT NULL,
527
+ listing_sha256 TEXT NOT NULL,
528
+ owner_domain TEXT,
529
+ owner_state TEXT NOT NULL,
530
+ display_name TEXT NOT NULL,
531
+ secret BLOB,
532
+ msg_id TEXT NOT NULL,
533
+ ts INTEGER NOT NULL,
534
+ gives_up_at INTEGER NOT NULL,
535
+ created_at INTEGER NOT NULL,
536
+ sent_once INTEGER NOT NULL DEFAULT 0,
537
+ status TEXT NOT NULL,
538
+ reason TEXT,
539
+ group_id TEXT,
540
+ ended_at INTEGER
541
+ );
542
+ CREATE TABLE IF NOT EXISTS listed_peers (
543
+ group_id TEXT PRIMARY KEY,
544
+ identity TEXT NOT NULL,
545
+ directory TEXT NOT NULL,
546
+ name TEXT NOT NULL,
547
+ owner_domain TEXT,
548
+ owner_state TEXT NOT NULL,
549
+ listing_sha256 TEXT NOT NULL,
550
+ recorded_at INTEGER NOT NULL
551
+ );
552
+ CREATE TABLE IF NOT EXISTS listings_seen (
553
+ directory TEXT NOT NULL,
554
+ name TEXT NOT NULL,
555
+ hw_issued_at INTEGER,
556
+ hw_identity TEXT,
557
+ last BLOB,
558
+ last_vouched_at INTEGER,
559
+ last_negative_at INTEGER,
560
+ last_negative TEXT,
561
+ latest_at INTEGER,
562
+ latest TEXT,
563
+ seen_at INTEGER NOT NULL,
564
+ PRIMARY KEY (directory, name)
565
+ );
566
+ CREATE TABLE IF NOT EXISTS sas_answered (
567
+ group_id TEXT NOT NULL,
568
+ epoch INTEGER NOT NULL,
569
+ commitment BLOB NOT NULL,
570
+ PRIMARY KEY (group_id, epoch, commitment)
571
+ );
572
+ CREATE TABLE IF NOT EXISTS sas_sent (
573
+ group_id TEXT NOT NULL,
574
+ epoch INTEGER NOT NULL,
575
+ peer TEXT NOT NULL,
576
+ commitment BLOB NOT NULL,
577
+ at INTEGER NOT NULL,
578
+ PRIMARY KEY (group_id, commitment)
579
+ );
580
+ CREATE TABLE IF NOT EXISTS sas_warnings (
581
+ group_id TEXT NOT NULL,
582
+ peer TEXT NOT NULL,
583
+ count INTEGER NOT NULL,
584
+ committers TEXT NOT NULL DEFAULT '[]',
585
+ PRIMARY KEY (group_id, peer)
586
+ );
587
+ """
588
+
589
+ # The one-time reset of verified flags (SPEC 11.8): flags recorded under the
590
+ # group-wide code rest on a code an attacker in the middle could choose.
591
+ SAS_RUN_MARKER = ("schema/sas_run", "1")
592
+ SAS_RESET_NOTICE = "notice/sas_reset"
593
+ # Set when the `adapters` column is added to an older database (Engine.start).
594
+ GROUP_ADAPTERS_KEY = "schema/group_adapters"
595
+
596
+ GROUP_COLUMNS = (
597
+ "group_id",
598
+ "name",
599
+ "purpose",
600
+ "policy",
601
+ "adapter",
602
+ "status",
603
+ "out_of_sync",
604
+ "out_of_sync_reason",
605
+ "epoch",
606
+ "created_at",
607
+ "last_own_commit_at",
608
+ "msgs_since_commit",
609
+ "hidden",
610
+ "origin",
611
+ "adapters",
612
+ )
613
+
614
+
615
+ LISTING_COLUMNS = (
616
+ "directory",
617
+ "name",
618
+ "state",
619
+ "content",
620
+ "token",
621
+ "token_expires_at",
622
+ "owner_domain",
623
+ "seq",
624
+ "max_signed_seq",
625
+ "published",
626
+ "published_sha256",
627
+ "published_at",
628
+ "pending",
629
+ "pending_door",
630
+ "owner_state",
631
+ "next_renewal_at",
632
+ "retry_at",
633
+ "attempts",
634
+ "failing_since",
635
+ "stale_since",
636
+ "last_error",
637
+ "token_warned",
638
+ "restore_state",
639
+ "restore_renewal_at",
640
+ "created_at",
641
+ "updated_at",
642
+ )
643
+ _LISTING_COUNTERS = frozenset({"seq", "max_signed_seq", "attempts", "token_warned"})
644
+ # Without `secret`: only door_secrets() reads the private keys.
645
+ DOOR_COLUMNS = ("door_id", "directory", "name", "expires_at", "created_at", "state")
646
+ # Without `knock` (the welcome_secret and KeyPackage): only knock_secret() reads it.
647
+ KNOCK_REQUEST_COLUMNS = (
648
+ "request_id",
649
+ "knock_id",
650
+ "door_id",
651
+ "directory",
652
+ "name",
653
+ "identity",
654
+ "mailbox_id",
655
+ "kp_ref",
656
+ "display_name",
657
+ "note",
658
+ "ts",
659
+ "lapses_at",
660
+ "received_at",
661
+ "status",
662
+ "group_id",
663
+ "purpose",
664
+ "decided_at",
665
+ )
666
+ # Without `secret`: only knock_secret() reads it.
667
+ KNOCK_COLUMNS = (
668
+ "knock_id",
669
+ "directory",
670
+ "name",
671
+ "identity",
672
+ "door_id",
673
+ "listing",
674
+ "listing_sha256",
675
+ "owner_domain",
676
+ "owner_state",
677
+ "display_name",
678
+ "msg_id",
679
+ "ts",
680
+ "gives_up_at",
681
+ "created_at",
682
+ "sent_once",
683
+ "status",
684
+ "reason",
685
+ "group_id",
686
+ "ended_at",
687
+ )
688
+ LISTED_PEER_COLUMNS = (
689
+ "group_id",
690
+ "identity",
691
+ "directory",
692
+ "name",
693
+ "owner_domain",
694
+ "owner_state",
695
+ "listing_sha256",
696
+ "recorded_at",
697
+ )
698
+ SEEN_COLUMNS = (
699
+ "directory",
700
+ "name",
701
+ "hw_issued_at",
702
+ "hw_identity",
703
+ "last",
704
+ "last_vouched_at",
705
+ "last_negative_at",
706
+ "last_negative",
707
+ "latest_at",
708
+ "latest",
709
+ "seen_at",
710
+ )
711
+ MAX_LISTINGS_SEEN = 1024
712
+
713
+
714
+ @dataclass
715
+ class GroupRow:
716
+ group_id: str
717
+ name: str
718
+ purpose: str
719
+ policy: dict[str, Any]
720
+ adapter: str
721
+ status: str
722
+ out_of_sync: bool
723
+ out_of_sync_reason: str | None
724
+ epoch: int
725
+ created_at: int
726
+ last_own_commit_at: int
727
+ msgs_since_commit: int
728
+ # Not the agent's to see (an approved knock's group until the knocker is in).
729
+ hidden: bool = False
730
+ # Where the group came from, kept with it: {"kind": "knock", "directory", "name"}.
731
+ origin: dict[str, Any] | None = None
732
+ # The adapters the human pinned it to; None: the config's (daemon.adapters).
733
+ # (``adapter``, the one adapter a group was bound to before, is no longer read.)
734
+ adapters: list[str] | None = None
735
+
736
+
737
+ @dataclass
738
+ class MemberRow:
739
+ identity: str
740
+ display_name: str | None
741
+ role: str
742
+ verified: bool
743
+ instances: list[dict[str, Any]]
744
+ # The epoch whose short code the human compared when marking it verified;
745
+ # None while unverified, and for a member verified by a daemon that didn't
746
+ # record it (unknown: its new instances can't be told).
747
+ verified_epoch: int | None = None
748
+
749
+
750
+ @dataclass
751
+ class OutboxRow:
752
+ seq: int
753
+ kind: str
754
+ group_id: str | None
755
+ payload: dict[str, Any]
756
+ state: str
757
+
758
+
759
+ @dataclass
760
+ class InboxRow:
761
+ seq: int
762
+ sender: str
763
+ msg_id: str
764
+ group_id: str | None
765
+ delivery: dict[str, Any] | None
766
+ state: str
767
+
768
+
769
+ def _payload_group(payload: dict[str, Any]) -> str | None:
770
+ group = payload.get("group")
771
+ return str(group["id"]) if isinstance(group, dict) and "id" in group else None
772
+
773
+
774
+ _QUEUE_ROWS = (
775
+ "SELECT seq, payload FROM adapter_events WHERE adapter = ? "
776
+ "AND (? IS NULL OR group_id = ?) "
777
+ "AND seq NOT IN (SELECT value FROM json_each(?)) "
778
+ "AND (? = 0 OR addressed = 1) "
779
+ "AND (? IS NULL OR seq < ?) "
780
+ )
781
+
782
+
783
+ class Store:
784
+ """Synchronous access to the daemon database (one connection)."""
785
+
786
+ def __init__(self, path: Path, db_key: bytes) -> None:
787
+ """Open (or create, 0600) the encrypted database at ``path``.
788
+
789
+ ``db_key`` is the keystore's 32-byte key; a wrong key, or a file that
790
+ isn't an encrypted database, raises ``AgentConnectError``.
791
+ """
792
+ private_file(path)
793
+ self._db = _Connection(path, db_key)
794
+ # FULL: a committed transaction survives a power loss. With NORMAL in
795
+ # WAL mode the last commits can roll back after their effects (frames
796
+ # sent, acks) went out (ADR-004).
797
+ self._db.executescript("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")
798
+ self._db.executescript(SCHEMA)
799
+ self._depth = 0
800
+ # Columns added after the first schema (development files).
801
+ columns = {r[0] for r in self._all("SELECT name FROM pragma_table_info('events')")}
802
+ for column, decl in (
803
+ ("event_id", "TEXT"),
804
+ ("notified", "INTEGER NOT NULL DEFAULT 0"),
805
+ ("agent_seq", "INTEGER"),
806
+ ):
807
+ if column not in columns:
808
+ self._db.execute(f"ALTER TABLE events ADD COLUMN {column} {decl}")
809
+ self._migrate_delivery()
810
+ member_columns = {r[0] for r in self._all("SELECT name FROM pragma_table_info('members')")}
811
+ if "verified_epoch" not in member_columns:
812
+ self._db.execute("ALTER TABLE members ADD COLUMN verified_epoch INTEGER")
813
+ invite_columns = {r[0] for r in self._all("SELECT name FROM pragma_table_info('invites')")}
814
+ if "replica" not in invite_columns:
815
+ self._db.execute("ALTER TABLE invites ADD COLUMN replica INTEGER NOT NULL DEFAULT 0")
816
+ if "instance" not in invite_columns:
817
+ self._db.execute("ALTER TABLE invites ADD COLUMN instance TEXT")
818
+ approval_columns = {
819
+ r[0] for r in self._all("SELECT name FROM pragma_table_info('approvals')")
820
+ }
821
+ if "instance_id" not in approval_columns:
822
+ self._db.execute("ALTER TABLE approvals ADD COLUMN instance_id TEXT")
823
+ feed_columns = {r[0] for r in self._all("SELECT name FROM pragma_table_info('feed')")}
824
+ if "at" not in feed_columns:
825
+ self._db.execute("ALTER TABLE feed ADD COLUMN at INTEGER NOT NULL DEFAULT 0")
826
+ # Handled-id keys were bare ids before they were namespaced
827
+ # (["msg", group, sender, msg_id] / ["event", event_id]). An old
828
+ # row can't be migrated (it lacks the group and the sender) and,
829
+ # being peer-chosen, could equal a new key and hide a delivery; so
830
+ # old rows are cleared, once. At worst one delivery reaches its
831
+ # handler again, which at-least-once allows.
832
+ if self.kv_get("schema/handled_keys") != "namespaced":
833
+ with self.tx():
834
+ self._db.execute("DELETE FROM handled WHERE key NOT LIKE '[%'")
835
+ self.kv_set("schema/handled_keys", "namespaced")
836
+ self._reset_verified_once()
837
+ # The in-process adapter's event queue is read per group.
838
+ event_columns = {
839
+ r[0] for r in self._all("SELECT name FROM pragma_table_info('adapter_events')")
840
+ }
841
+ if "group_id" not in event_columns:
842
+ self._db.execute("ALTER TABLE adapter_events ADD COLUMN group_id TEXT")
843
+ self._db.execute(
844
+ "UPDATE adapter_events SET group_id = json_extract(payload, '$.group.id')"
845
+ )
846
+ # The delivery queue finds the messages that address this agent.
847
+ if "addressed" not in event_columns:
848
+ self._db.execute(
849
+ "ALTER TABLE adapter_events ADD COLUMN addressed INTEGER NOT NULL DEFAULT 0"
850
+ )
851
+ self._db.execute(
852
+ "UPDATE adapter_events SET addressed = 1 "
853
+ "WHERE json_extract(payload, '$.addressed_to_me') = 1"
854
+ )
855
+ self._db.executescript(
856
+ "CREATE UNIQUE INDEX IF NOT EXISTS events_event_id ON events (event_id);"
857
+ "CREATE UNIQUE INDEX IF NOT EXISTS events_agent_seq ON events (agent_seq);"
858
+ "CREATE INDEX IF NOT EXISTS events_unnotified ON events (notified, group_id, seq);"
859
+ "CREATE INDEX IF NOT EXISTS inbox_pending ON inbox (state, group_id, seq);"
860
+ "CREATE INDEX IF NOT EXISTS inbox_unacked ON inbox (unacked, group_id);"
861
+ "CREATE INDEX IF NOT EXISTS adapter_events_group "
862
+ "ON adapter_events (adapter, group_id, seq);"
863
+ "CREATE INDEX IF NOT EXISTS adapter_events_addressed "
864
+ "ON adapter_events (adapter, addressed, group_id, seq);"
865
+ "CREATE INDEX IF NOT EXISTS audit_class ON audit (class, seq);"
866
+ "CREATE INDEX IF NOT EXISTS audit_group ON audit (group_id, class, seq);"
867
+ "CREATE INDEX IF NOT EXISTS audit_sender ON audit (group_id, sender, class, seq);"
868
+ "CREATE INDEX IF NOT EXISTS audit_security ON audit (group_id, sender, decision, seq) "
869
+ "WHERE class = 'security';"
870
+ "CREATE INDEX IF NOT EXISTS transcript_group ON transcript (group_id, seq);"
871
+ "CREATE INDEX IF NOT EXISTS transcript_sender ON transcript (group_id, sender, seq);"
872
+ )
873
+
874
+ def _migrate_delivery(self) -> None:
875
+ """Columns and totals group mode and per-adapter delivery added
876
+ (development files)."""
877
+ group_columns = {r[0] for r in self._all("SELECT name FROM pragma_table_info('groups')")}
878
+ # `adapters` (a pinned list, NULL: follow the config) replaced the one
879
+ # adapter a group was bound to when it was created.
880
+ for column, decl in (
881
+ ("hidden", "INTEGER NOT NULL DEFAULT 0"),
882
+ ("origin", "TEXT"),
883
+ ("adapters", "TEXT"),
884
+ ):
885
+ if column not in group_columns:
886
+ self._db.execute(f"ALTER TABLE groups ADD COLUMN {column} {decl}")
887
+ if column == "adapters":
888
+ # Engine.start pins the groups whose adapter the config
889
+ # no longer gives them, once it knows the config.
890
+ self.kv_set(GROUP_ADAPTERS_KEY, "pending")
891
+ inbox_columns = {r[0] for r in self._all("SELECT name FROM pragma_table_info('inbox')")}
892
+ if "unacked" not in inbox_columns:
893
+ self._db.execute("ALTER TABLE inbox ADD COLUMN unacked INTEGER NOT NULL DEFAULT 0")
894
+ if self.kv_get("schema/inbox_pending_bytes") != "1":
895
+ with self.tx():
896
+ self._db.execute("DELETE FROM inbox_pending_bytes")
897
+ self._db.execute(
898
+ "INSERT INTO inbox_pending_bytes (group_id, bytes) SELECT group_id, "
899
+ "SUM(LENGTH(CAST(delivery AS BLOB))) FROM inbox WHERE state = 'pending' "
900
+ "AND group_id IS NOT NULL AND delivery IS NOT NULL GROUP BY group_id"
901
+ )
902
+ self.kv_set("schema/inbox_pending_bytes", "1")
903
+
904
+ def close(self) -> None:
905
+ self._db.close()
906
+
907
+ def _reset_verified_once(self) -> None:
908
+ """SPEC 11.8: reset every verified flag once, in one transaction with
909
+ its persisted marker, before anything is delivered."""
910
+ if self.kv_get(SAS_RUN_MARKER[0]) == SAS_RUN_MARKER[1]:
911
+ return
912
+ with self.tx():
913
+ cur = self._db.execute(
914
+ "UPDATE members SET verified = 0, verified_epoch = NULL WHERE verified = 1"
915
+ )
916
+ if cur.rowcount > 0:
917
+ self.kv_set(SAS_RESET_NOTICE, str(cur.rowcount))
918
+ self.kv_set(*SAS_RUN_MARKER)
919
+
920
+ @contextmanager
921
+ def tx(self) -> Generator[_Connection]:
922
+ """One transaction; nested calls are savepoints inside it. Rolled
923
+ back on any exception, a simulated crash included."""
924
+ depth = self._depth
925
+ self._db.execute("BEGIN IMMEDIATE" if depth == 0 else f"SAVEPOINT sp{depth}")
926
+ self._depth += 1
927
+ try:
928
+ yield self._db
929
+ except BaseException:
930
+ self._depth = depth
931
+ if depth == 0:
932
+ self._db.execute("ROLLBACK")
933
+ else:
934
+ self._db.execute(f"ROLLBACK TO sp{depth}")
935
+ self._db.execute(f"RELEASE sp{depth}")
936
+ raise
937
+ self._depth = depth
938
+ self._db.execute("COMMIT" if depth == 0 else f"RELEASE sp{depth}")
939
+
940
+ def _one(self, sql: str, *args: Any) -> tuple[Any, ...] | None:
941
+ return self._db.execute(sql, args).fetchone()
942
+
943
+ def _all(self, sql: str, *args: Any) -> list[tuple[Any, ...]]:
944
+ return self._db.execute(sql, args).fetchall()
945
+
946
+ # -- key/value ----------------------------------------------------------
947
+
948
+ def kv_get(self, key: str) -> str | None:
949
+ row = self._one("SELECT value FROM kv WHERE key = ?", key)
950
+ return None if row is None else str(row[0])
951
+
952
+ def kv_set(self, key: str, value: str) -> None:
953
+ self._db.execute(
954
+ "INSERT INTO kv (key, value) VALUES (?, ?) "
955
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
956
+ (key, value),
957
+ )
958
+
959
+ def next_seq(self) -> int:
960
+ """The next per-instance inner ``seq`` (SPEC §7.2), persisted."""
961
+ with self.tx():
962
+ n = int(self.kv_get("inner_seq") or "0") + 1
963
+ self.kv_set("inner_seq", str(n))
964
+ return n
965
+
966
+ # -- groups -------------------------------------------------------------
967
+
968
+ def add_group(self, row: GroupRow) -> None:
969
+ values = [getattr(row, c) for c in GROUP_COLUMNS]
970
+ values[3] = json.dumps(row.policy)
971
+ values[6] = int(row.out_of_sync)
972
+ values[12] = int(row.hidden)
973
+ values[13] = None if row.origin is None else json.dumps(row.origin)
974
+ values[14] = None if row.adapters is None else json.dumps(row.adapters)
975
+ self._db.execute(
976
+ f"INSERT OR REPLACE INTO groups ({', '.join(GROUP_COLUMNS)}) " # noqa: S608
977
+ f"VALUES ({', '.join('?' * len(GROUP_COLUMNS))})",
978
+ values,
979
+ )
980
+
981
+ @staticmethod
982
+ def _group(row: tuple[Any, ...]) -> GroupRow:
983
+ d = dict(zip(GROUP_COLUMNS, row, strict=True))
984
+ d["policy"] = json.loads(d["policy"])
985
+ d["out_of_sync"] = bool(d["out_of_sync"])
986
+ d["hidden"] = bool(d["hidden"])
987
+ d["origin"] = None if d["origin"] is None else json.loads(d["origin"])
988
+ d["adapters"] = None if d["adapters"] is None else json.loads(d["adapters"])
989
+ return GroupRow(**d)
990
+
991
+ def group(self, group_id: str) -> GroupRow | None:
992
+ row = self._one(
993
+ f"SELECT {', '.join(GROUP_COLUMNS)} FROM groups WHERE group_id = ?", # noqa: S608
994
+ group_id,
995
+ )
996
+ return None if row is None else self._group(row)
997
+
998
+ def groups(self) -> list[GroupRow]:
999
+ rows = self._all(f"SELECT {', '.join(GROUP_COLUMNS)} FROM groups ORDER BY created_at") # noqa: S608
1000
+ return [self._group(r) for r in rows]
1001
+
1002
+ def update_group(self, group_id: str, **fields: Any) -> None:
1003
+ if not fields:
1004
+ return
1005
+ for key in fields:
1006
+ if key not in GROUP_COLUMNS or key == "group_id":
1007
+ msg = f"unknown group column {key}"
1008
+ raise ValueError(msg)
1009
+ values = [
1010
+ json.dumps(v)
1011
+ if k == "policy" or (k in ("origin", "adapters") and v is not None)
1012
+ else int(v)
1013
+ if isinstance(v, bool)
1014
+ else v
1015
+ for k, v in fields.items()
1016
+ ]
1017
+ sets = ", ".join(f"{k} = ?" for k in fields)
1018
+ self._db.execute(
1019
+ f"UPDATE groups SET {sets} WHERE group_id = ?", # noqa: S608
1020
+ (*values, group_id),
1021
+ )
1022
+
1023
+ def delete_group(self, group_id: str) -> None:
1024
+ """Forget a group entirely: its row and its data (for a group nobody
1025
+ but this instance was ever in, such as a failed knock approval's)."""
1026
+ with self.tx():
1027
+ self.delete_group_data(group_id)
1028
+ self._db.execute("DELETE FROM groups WHERE group_id = ?", (group_id,))
1029
+
1030
+ def bump_messages(self, group_id: str) -> None:
1031
+ self._db.execute(
1032
+ "UPDATE groups SET msgs_since_commit = msgs_since_commit + 1 WHERE group_id = ?",
1033
+ (group_id,),
1034
+ )
1035
+
1036
+ # -- members ------------------------------------------------------------
1037
+
1038
+ def members(self, group_id: str) -> list[MemberRow]:
1039
+ rows = self._all(
1040
+ "SELECT identity, display_name, role, verified, instances, verified_epoch "
1041
+ "FROM members WHERE group_id = ? ORDER BY identity",
1042
+ group_id,
1043
+ )
1044
+ return [
1045
+ MemberRow(r[0], r[1], r[2], bool(r[3]), json.loads(r[4]), r[5] if r[3] else None)
1046
+ for r in rows
1047
+ ]
1048
+
1049
+ def set_members(
1050
+ self, group_id: str, members: dict[str, tuple[str, list[dict[str, Any]]]]
1051
+ ) -> tuple[set[str], set[str]]:
1052
+ """Replace the member set: ``{identity: (role, instances)}``.
1053
+
1054
+ Keeps display names and verified flags of members that stay.
1055
+ Returns ``(joined, gone)`` identities.
1056
+ """
1057
+ with self.tx() as db:
1058
+ rows = db.execute("SELECT identity FROM members WHERE group_id = ?", (group_id,))
1059
+ before = {str(r[0]) for r in rows}
1060
+ gone = before - members.keys()
1061
+ for identity in gone:
1062
+ db.execute(
1063
+ "DELETE FROM members WHERE group_id = ? AND identity = ?", (group_id, identity)
1064
+ )
1065
+ for identity, (role, instances) in members.items():
1066
+ db.execute(
1067
+ "INSERT INTO members (group_id, identity, role, instances) VALUES (?, ?, ?, ?) "
1068
+ "ON CONFLICT(group_id, identity) DO UPDATE SET role = excluded.role, "
1069
+ "instances = excluded.instances",
1070
+ (group_id, identity, role, json.dumps(instances)),
1071
+ )
1072
+ return set(members) - before, gone
1073
+
1074
+ def set_display_name(self, group_id: str, identity: str, name: str) -> None:
1075
+ self._db.execute(
1076
+ "UPDATE members SET display_name = ? WHERE group_id = ? AND identity = ?",
1077
+ (name, group_id, identity),
1078
+ )
1079
+
1080
+ def set_verified(
1081
+ self, group_id: str, identity: str, verified: bool, epoch: int | None = None
1082
+ ) -> bool:
1083
+ """Set a member's verified flag. ``epoch`` is the epoch the human
1084
+ verified at: the member's instances that join after it are new
1085
+ since the verification."""
1086
+ cur = self._db.execute(
1087
+ "UPDATE members SET verified = ?, verified_epoch = ? "
1088
+ "WHERE group_id = ? AND identity = ?",
1089
+ (int(verified), epoch if verified else None, group_id, identity),
1090
+ )
1091
+ return cur.rowcount == 1
1092
+
1093
+ # -- SAS runs (agentconnect.runtime.sas) ------------------------------------
1094
+
1095
+ def sas_answered(self, group_id: str, epoch: int, commitment: bytes) -> bool:
1096
+ """Whether this instance answered ``commitment`` in ``epoch`` (SPEC 11.3)."""
1097
+ row = self._one(
1098
+ "SELECT 1 FROM sas_answered WHERE group_id = ? AND epoch = ? AND commitment = ?",
1099
+ group_id,
1100
+ epoch,
1101
+ commitment,
1102
+ )
1103
+ return row is not None
1104
+
1105
+ def add_sas_answered(self, group_id: str, epoch: int, commitment: bytes) -> None:
1106
+ self._db.execute(
1107
+ "INSERT OR IGNORE INTO sas_answered (group_id, epoch, commitment) VALUES (?, ?, ?)",
1108
+ (group_id, epoch, commitment),
1109
+ )
1110
+
1111
+ def forget_sas_answered(self, group_id: str, epoch: int) -> None:
1112
+ """Answered commitments are kept for the rest of their epoch only."""
1113
+ self._db.execute(
1114
+ "DELETE FROM sas_answered WHERE group_id = ? AND epoch < ?", (group_id, epoch)
1115
+ )
1116
+
1117
+ def add_sas_sent(
1118
+ self, group_id: str, epoch: int, peer: str, commitment: bytes, at: int
1119
+ ) -> None:
1120
+ """This instance's commitment, unrevealed so far: a restart cancels
1121
+ it (SPEC 11.3)."""
1122
+ self._db.execute(
1123
+ "INSERT OR REPLACE INTO sas_sent (group_id, epoch, peer, commitment, at) "
1124
+ "VALUES (?, ?, ?, ?, ?)",
1125
+ (group_id, epoch, peer, commitment, at),
1126
+ )
1127
+
1128
+ def forget_sas_sent(self, group_id: str, commitment: bytes) -> None:
1129
+ self._db.execute(
1130
+ "DELETE FROM sas_sent WHERE group_id = ? AND commitment = ?", (group_id, commitment)
1131
+ )
1132
+
1133
+ def forget_sas_sent_before(self, group_id: str, epoch: int) -> None:
1134
+ self._db.execute("DELETE FROM sas_sent WHERE group_id = ? AND epoch < ?", (group_id, epoch))
1135
+
1136
+ def forget_all_sas_sent(self) -> None:
1137
+ self._db.execute("DELETE FROM sas_sent")
1138
+
1139
+ def sas_sent(self) -> list[dict[str, Any]]:
1140
+ rows = self._all("SELECT group_id, epoch, peer, commitment, at FROM sas_sent")
1141
+ return [
1142
+ {"group_id": r[0], "epoch": r[1], "peer": r[2], "commitment": bytes(r[3]), "at": r[4]}
1143
+ for r in rows
1144
+ ]
1145
+
1146
+ def sas_warnings(self, group_id: str, peer: str) -> tuple[int, list[str]]:
1147
+ """The warning count with ``peer`` and the members whose commits
1148
+ ended those runs (SPEC 11.3)."""
1149
+ row = self._one(
1150
+ "SELECT count, committers FROM sas_warnings WHERE group_id = ? AND peer = ?",
1151
+ group_id,
1152
+ peer,
1153
+ )
1154
+ if row is None:
1155
+ return 0, []
1156
+ return int(row[0]), [str(c) for c in json.loads(row[1])]
1157
+
1158
+ def set_sas_warnings(self, group_id: str, peer: str, count: int, committers: list[str]) -> None:
1159
+ if count <= 0:
1160
+ self._db.execute(
1161
+ "DELETE FROM sas_warnings WHERE group_id = ? AND peer = ?", (group_id, peer)
1162
+ )
1163
+ return
1164
+ self._db.execute(
1165
+ "INSERT INTO sas_warnings (group_id, peer, count, committers) VALUES (?, ?, ?, ?) "
1166
+ "ON CONFLICT(group_id, peer) DO UPDATE SET count = excluded.count, "
1167
+ "committers = excluded.committers",
1168
+ (group_id, peer, count, json.dumps(committers[-16:])),
1169
+ )
1170
+
1171
+ def drop_queued_sas(self) -> int:
1172
+ """Queued ceremony messages of runs a restart ended: never sent."""
1173
+ rows = self._all(
1174
+ "SELECT seq FROM outbox WHERE state = 'queued' AND kind = 'app' "
1175
+ "AND json_extract(payload, '$.sas') IS NOT NULL"
1176
+ )
1177
+ for (seq,) in rows:
1178
+ self.update_outbox(int(seq), state="failed")
1179
+ return len(rows)
1180
+
1181
+ def put_card(self, group_id: str, identity: str, card: dict[str, Any]) -> None:
1182
+ self._db.execute(
1183
+ "INSERT OR REPLACE INTO agent_cards (group_id, identity, card) VALUES (?, ?, ?)",
1184
+ (group_id, identity, json.dumps(card)),
1185
+ )
1186
+
1187
+ def cards(self, group_id: str) -> dict[str, dict[str, Any]]:
1188
+ rows = self._all("SELECT identity, card FROM agent_cards WHERE group_id = ?", group_id)
1189
+ return {r[0]: json.loads(r[1]) for r in rows}
1190
+
1191
+ # -- floor control (agentconnect.runtime.floor) --------------------------------
1192
+
1193
+ def floor(self, group_id: str) -> tuple[str | None, dict[str, Any]] | None:
1194
+ """The group's floor holder and register state (``None``: no row)."""
1195
+ row = self._one("SELECT holder, state FROM floor_state WHERE group_id = ?", group_id)
1196
+ return None if row is None else (row[0], dict(json.loads(row[1])))
1197
+
1198
+ def set_floor(self, group_id: str, holder: str | None, state: dict[str, Any]) -> None:
1199
+ self._db.execute(
1200
+ "INSERT INTO floor_state (group_id, holder, state) VALUES (?, ?, ?) "
1201
+ "ON CONFLICT(group_id) DO UPDATE SET holder = excluded.holder, state = excluded.state",
1202
+ (group_id, holder, json.dumps(state, sort_keys=True)),
1203
+ )
1204
+
1205
+ def floor_writes(self, group_id: str) -> list[tuple[int, int, str, str, str, str | None]]:
1206
+ """The writes kept for the group's floor register, in key order:
1207
+ ``(epoch, counter, sender, instance, kind, holder)``."""
1208
+ rows = self._all(
1209
+ "SELECT epoch, counter, sender, instance, kind, holder FROM floor_writes "
1210
+ "WHERE group_id = ?",
1211
+ group_id,
1212
+ )
1213
+ return sorted((int(r[0]), int(r[1]), str(r[2]), str(r[3]), str(r[4]), r[5]) for r in rows)
1214
+
1215
+ def add_floor_write(
1216
+ self,
1217
+ group_id: str,
1218
+ key: tuple[int, int, str, str],
1219
+ kind: str,
1220
+ holder: str | None,
1221
+ ) -> bool:
1222
+ """Keep a write; ``False`` if one with that key is kept already."""
1223
+ cur = self._db.execute(
1224
+ "INSERT OR IGNORE INTO floor_writes "
1225
+ "(group_id, epoch, counter, sender, instance, kind, holder) "
1226
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
1227
+ (group_id, *key, kind, holder),
1228
+ )
1229
+ return cur.rowcount == 1
1230
+
1231
+ def delete_floor_write(self, group_id: str, key: tuple[int, int, str, str]) -> None:
1232
+ self._db.execute(
1233
+ "DELETE FROM floor_writes WHERE group_id = ? AND epoch = ? AND counter = ? "
1234
+ "AND sender = ? AND instance = ?",
1235
+ (group_id, *key),
1236
+ )
1237
+
1238
+ def drop_floor_writes(self, group_id: str, before_epoch: int) -> None:
1239
+ self._db.execute(
1240
+ "DELETE FROM floor_writes WHERE group_id = ? AND epoch < ?", (group_id, before_epoch)
1241
+ )
1242
+
1243
+ def delete_group_data(self, group_id: str) -> None:
1244
+ """Forget a group's members, cards and safety settings; its held
1245
+ messages are dropped. Audit rows and transcript entries stay until
1246
+ their retention ends."""
1247
+ with self.tx() as db:
1248
+ for table in (
1249
+ "members",
1250
+ "agent_cards",
1251
+ "group_safety",
1252
+ "task_decisions",
1253
+ "listed_peers",
1254
+ "floor_state",
1255
+ "floor_writes",
1256
+ "task_requests",
1257
+ "request_tasks",
1258
+ "answered_tasks",
1259
+ "asked_requests",
1260
+ "a2a_agent_tasks",
1261
+ "sas_answered",
1262
+ "sas_sent",
1263
+ "sas_warnings",
1264
+ ):
1265
+ db.execute(f"DELETE FROM {table} WHERE group_id = ?", (group_id,)) # noqa: S608
1266
+ db.execute(
1267
+ "UPDATE inbox SET state = 'dropped', delivery = NULL "
1268
+ "WHERE group_id = ? AND state = 'held'",
1269
+ (group_id,),
1270
+ )
1271
+ db.execute(
1272
+ "UPDATE held SET status = 'dropped', payload = NULL "
1273
+ "WHERE group_id = ? AND status = 'pending'",
1274
+ (group_id,),
1275
+ )
1276
+
1277
+ # -- safety policy (agentconnect.policy) ----------------------------------
1278
+
1279
+ def safety_overrides(self, group_id: str) -> dict[str, Any]:
1280
+ """A group's policy overrides (``{}`` when it has none)."""
1281
+ row = self._one("SELECT settings FROM group_safety WHERE group_id = ?", group_id)
1282
+ return {} if row is None else dict(json.loads(row[0]))
1283
+
1284
+ def set_safety_overrides(self, group_id: str, overrides: dict[str, Any]) -> None:
1285
+ if not overrides:
1286
+ self._db.execute("DELETE FROM group_safety WHERE group_id = ?", (group_id,))
1287
+ return
1288
+ self._db.execute(
1289
+ "INSERT INTO group_safety (group_id, settings) VALUES (?, ?) "
1290
+ "ON CONFLICT(group_id) DO UPDATE SET settings = excluded.settings",
1291
+ (group_id, json.dumps(overrides, sort_keys=True)),
1292
+ )
1293
+
1294
+ def add_held(self, row: dict[str, Any]) -> None:
1295
+ """A message waiting for the human's approval. ``payload`` (outbound
1296
+ only) is the queued send; an inbound one waits in its inbox row."""
1297
+ payload = row.get("payload")
1298
+ self._db.execute(
1299
+ "INSERT OR IGNORE INTO held (hold_id, group_id, direction, sender, instance, msg_id, "
1300
+ "kind, size, task_id, inbox_seq, payload, created_at, expires_at) "
1301
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1302
+ (
1303
+ row["hold_id"],
1304
+ row["group_id"],
1305
+ row["direction"],
1306
+ row["sender"],
1307
+ row.get("instance", ""),
1308
+ row["msg_id"],
1309
+ row["kind"],
1310
+ row["size"],
1311
+ row.get("task_id"),
1312
+ row.get("inbox_seq"),
1313
+ None if payload is None else json.dumps(payload),
1314
+ row["created_at"],
1315
+ row["expires_at"],
1316
+ ),
1317
+ )
1318
+
1319
+ _HELD_COLUMNS = (
1320
+ "hold_id",
1321
+ "group_id",
1322
+ "direction",
1323
+ "sender",
1324
+ "instance",
1325
+ "msg_id",
1326
+ "kind",
1327
+ "size",
1328
+ "task_id",
1329
+ "inbox_seq",
1330
+ "payload",
1331
+ "created_at",
1332
+ "expires_at",
1333
+ "status",
1334
+ )
1335
+
1336
+ def _held(self, row: tuple[Any, ...]) -> dict[str, Any]:
1337
+ d = dict(zip(self._HELD_COLUMNS, row, strict=True))
1338
+ d["payload"] = None if d["payload"] is None else json.loads(d["payload"])
1339
+ return d
1340
+
1341
+ def held(self, hold_id: str) -> dict[str, Any] | None:
1342
+ row = self._one(
1343
+ f"SELECT {', '.join(self._HELD_COLUMNS)} FROM held WHERE hold_id = ?", # noqa: S608
1344
+ hold_id,
1345
+ )
1346
+ return None if row is None else self._held(row)
1347
+
1348
+ def held_items(self, status: str = "pending") -> list[dict[str, Any]]:
1349
+ rows = self._all(
1350
+ f"SELECT {', '.join(self._HELD_COLUMNS)} FROM held " # noqa: S608
1351
+ "WHERE status = ? ORDER BY created_at, hold_id",
1352
+ status,
1353
+ )
1354
+ return [self._held(r) for r in rows]
1355
+
1356
+ def set_held_status(self, hold_id: str, status: str) -> bool:
1357
+ """Decide a pending held item; its payload (plaintext) is no longer
1358
+ needed. False if it was already decided (by a concurrent caller)."""
1359
+ cur = self._db.execute(
1360
+ "UPDATE held SET status = ?, payload = NULL WHERE hold_id = ? AND status = 'pending'",
1361
+ (status, hold_id),
1362
+ )
1363
+ return cur.rowcount == 1
1364
+
1365
+ def held_load(
1366
+ self, group_id: str, direction: str, instance: str, identity: str
1367
+ ) -> tuple[int, int, int, int, int, int]:
1368
+ """Pending holds of a group in one direction: how many, how many from
1369
+ ``instance``, how many from ``identity`` (all its instances), their
1370
+ bytes, how many identities have one, and ``identity``'s bytes."""
1371
+ row = self._one(
1372
+ "SELECT COUNT(*), COALESCE(SUM(instance = ?), 0), COALESCE(SUM(sender = ?), 0), "
1373
+ "COALESCE(SUM(size), 0), COUNT(DISTINCT sender), "
1374
+ "COALESCE(SUM(CASE WHEN sender = ? THEN size ELSE 0 END), 0) FROM held "
1375
+ "WHERE group_id = ? AND direction = ? AND status = 'pending'",
1376
+ instance,
1377
+ identity,
1378
+ identity,
1379
+ group_id,
1380
+ direction,
1381
+ )
1382
+ if row is None:
1383
+ return 0, 0, 0, 0, 0, 0
1384
+ return int(row[0]), int(row[1]), int(row[2]), int(row[3]), int(row[4]), int(row[5])
1385
+
1386
+ def task_decision(self, group_id: str, sender: str, task_id: str) -> tuple[str, int] | None:
1387
+ """A task's state (``pending``, ``approved``, ``rejected``) and when it
1388
+ was set, or None."""
1389
+ row = self._one(
1390
+ "SELECT state, at FROM task_decisions WHERE group_id = ? AND sender = ? "
1391
+ "AND task_id = ?",
1392
+ group_id,
1393
+ sender,
1394
+ task_id,
1395
+ )
1396
+ return None if row is None else (str(row[0]), int(row[1]))
1397
+
1398
+ def forget_task(self, group_id: str, sender: str, task_id: str) -> None:
1399
+ self._db.execute(
1400
+ "DELETE FROM task_decisions WHERE group_id = ? AND sender = ? AND task_id = ?",
1401
+ (group_id, sender, task_id),
1402
+ )
1403
+
1404
+ def task_state(self, group_id: str, sender: str, task_id: str) -> str | None:
1405
+ """``pending``, ``approved`` or ``rejected``: the human's decision on a
1406
+ task's first update (held with require_approval), or None."""
1407
+ row = self._one(
1408
+ "SELECT state FROM task_decisions WHERE group_id = ? AND sender = ? AND task_id = ?",
1409
+ group_id,
1410
+ sender,
1411
+ task_id,
1412
+ )
1413
+ return None if row is None else str(row[0])
1414
+
1415
+ def set_task_state(
1416
+ self, group_id: str, sender: str, task_id: str, state: str, now: int
1417
+ ) -> None:
1418
+ self._db.execute(
1419
+ "INSERT INTO task_decisions (group_id, sender, task_id, state, at) "
1420
+ "VALUES (?, ?, ?, ?, ?) ON CONFLICT(group_id, sender, task_id) "
1421
+ "DO UPDATE SET state = excluded.state, at = excluded.at",
1422
+ (group_id, sender, task_id, state, now),
1423
+ )
1424
+
1425
+ def task_states_purge(self, before: int) -> None:
1426
+ """Forget decided tasks not touched since ``before``."""
1427
+ self._db.execute(
1428
+ "DELETE FROM task_decisions WHERE at < ? AND state != 'pending'", (before,)
1429
+ )
1430
+
1431
+ def audit_add( # noqa: PLR0913
1432
+ self,
1433
+ group_id: str,
1434
+ *,
1435
+ at: int,
1436
+ direction: str,
1437
+ sender: str,
1438
+ msg_id: str,
1439
+ kind: str,
1440
+ size: int,
1441
+ decision: str,
1442
+ reason: str,
1443
+ keep: int | None = None,
1444
+ keep_group: int | None = None,
1445
+ keep_sender: int | None = None,
1446
+ ) -> None:
1447
+ """One audit row, in one of four classes that never evict each other
1448
+ (:func:`audit_class`).
1449
+
1450
+ * ``security`` rows are trimmed by age (:meth:`audit_purge`), and
1451
+ only a sender's own rows trim its own: of a decision it keeps
1452
+ ``keep_sender`` per group, the oldest half and a rolling newest
1453
+ half (:meth:`_trim_security`), and counts the rows between in
1454
+ ``audit_suppressed``. So no flood erases a sender's earliest
1455
+ record, nobody else's rows push out any of its own, and its
1456
+ latest rows stay visible.
1457
+ * ``drop`` and ``routine`` rows keep the newest ``keep_sender`` of
1458
+ their class from a sender in a group.
1459
+ * ``drop``, ``routine`` and ``decision`` rows each keep
1460
+ ``keep_group`` in a group (:meth:`_trim_audit_fairly`) and ``keep``
1461
+ in all.
1462
+ """
1463
+ row_class = audit_class(group_id, decision, kind, reason)
1464
+ self._db.execute(
1465
+ "INSERT INTO audit (group_id, at, direction, sender, msg_id, kind, size, decision, "
1466
+ "reason, class) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1467
+ (group_id, at, direction, sender, msg_id, kind, size, decision, reason, row_class),
1468
+ )
1469
+ if row_class == "security":
1470
+ if keep_sender is not None:
1471
+ self._trim_security(group_id, sender, decision, keep_sender)
1472
+ return
1473
+ if keep_sender is not None and row_class != "decision":
1474
+ self._trim_audit(
1475
+ "group_id = ? AND sender = ? AND class = ?",
1476
+ (group_id, sender, row_class),
1477
+ keep_sender,
1478
+ )
1479
+ if keep_group is not None:
1480
+ self._trim_audit_fairly(group_id, row_class, keep_group)
1481
+ if keep is not None:
1482
+ self._trim_audit("class = ?", (row_class,), keep)
1483
+
1484
+ def _trim_security(self, group_id: str, sender: str, decision: str, keep: int) -> None:
1485
+ """Past ``keep`` security rows of one decision from a sender in a
1486
+ group, delete the oldest row after its oldest half (which stays until
1487
+ it ages out) and count it in ``audit_suppressed``, so the newest
1488
+ half rolls. Two indexed walks of at most ``keep`` rows."""
1489
+ where = "group_id = ? AND sender = ? AND decision = ? AND class = 'security'"
1490
+ over = self._one(
1491
+ f"SELECT 1 FROM audit WHERE {where} LIMIT 1 OFFSET ?", # noqa: S608
1492
+ group_id,
1493
+ sender,
1494
+ decision,
1495
+ keep,
1496
+ )
1497
+ if over is None:
1498
+ return
1499
+ gap = self._one(
1500
+ f"SELECT seq, at FROM audit WHERE {where} ORDER BY seq LIMIT 1 OFFSET ?", # noqa: S608
1501
+ group_id,
1502
+ sender,
1503
+ decision,
1504
+ (keep + 1) // 2,
1505
+ )
1506
+ if gap is None:
1507
+ return
1508
+ self._db.execute("DELETE FROM audit WHERE seq = ?", (gap[0],))
1509
+ self._db.execute(
1510
+ "INSERT INTO audit_suppressed (group_id, sender, decision, count, first_at, "
1511
+ "last_at) VALUES (?, ?, ?, 1, ?, ?) "
1512
+ "ON CONFLICT(group_id, sender, decision) "
1513
+ "DO UPDATE SET count = count + 1, first_at = MIN(first_at, excluded.first_at), "
1514
+ "last_at = MAX(last_at, excluded.last_at)",
1515
+ (group_id, sender, decision, gap[1], gap[1]),
1516
+ )
1517
+
1518
+ def _trim_audit_fairly(self, group_id: str, row_class: str, keep: int) -> None:
1519
+ """Keep at most ``keep`` of a group's rows of a class. Past that, the
1520
+ rows go from the top: the senders holding the most give way down to
1521
+ a common level, oldest first, and at a tie the sender whose oldest
1522
+ row is oldest goes first. It trims 1% below ``keep`` at once, so the
1523
+ senders are counted once per hundred rows at the cap, not per row."""
1524
+ row = self._one(
1525
+ "SELECT COUNT(*) FROM audit WHERE group_id = ? AND class = ?", group_id, row_class
1526
+ )
1527
+ total = 0 if row is None else int(row[0])
1528
+ if total <= keep:
1529
+ return
1530
+ target = total - keep + keep // 100
1531
+ counts = [
1532
+ (str(r[0]), int(r[1]))
1533
+ for r in self._all(
1534
+ "SELECT sender, COUNT(*) AS n FROM audit WHERE group_id = ? AND class = ? "
1535
+ "GROUP BY sender ORDER BY n DESC, MIN(seq)",
1536
+ group_id,
1537
+ row_class,
1538
+ )
1539
+ ]
1540
+ cut = _water_level([n for _, n in counts], target)
1541
+ for (sender, _), n in zip(counts, cut, strict=True):
1542
+ if n > 0:
1543
+ self._db.execute(
1544
+ "DELETE FROM audit WHERE seq IN (SELECT seq FROM audit WHERE group_id = ? "
1545
+ "AND sender = ? AND class = ? ORDER BY seq LIMIT ?)",
1546
+ (group_id, sender, row_class, n),
1547
+ )
1548
+
1549
+ def _trim_audit(self, where: str, args: tuple[str, ...], keep: int) -> None:
1550
+ """Delete the audit rows matching ``where`` beyond the newest ``keep``
1551
+ (an indexed walk, not a table scan)."""
1552
+ edge = self._one(
1553
+ f"SELECT seq FROM audit WHERE {where} ORDER BY seq DESC LIMIT 1 OFFSET ?", # noqa: S608
1554
+ *args,
1555
+ keep,
1556
+ )
1557
+ if edge is not None:
1558
+ self._db.execute(f"DELETE FROM audit WHERE {where} AND seq <= ?", (*args, edge[0])) # noqa: S608
1559
+
1560
+ _AUDIT_COLUMNS = (
1561
+ "seq",
1562
+ "group_id",
1563
+ "at",
1564
+ "direction",
1565
+ "sender",
1566
+ "msg_id",
1567
+ "kind",
1568
+ "size",
1569
+ "decision",
1570
+ "reason",
1571
+ )
1572
+
1573
+ def audit(
1574
+ self, group_id: str | None = None, after: int = 0, limit: int = 200
1575
+ ) -> list[dict[str, Any]]:
1576
+ rows = self._all(
1577
+ f"SELECT {', '.join(self._AUDIT_COLUMNS)} FROM audit WHERE seq > ? " # noqa: S608
1578
+ "AND (? IS NULL OR group_id = ?) ORDER BY seq LIMIT ?",
1579
+ after,
1580
+ group_id,
1581
+ group_id,
1582
+ limit,
1583
+ )
1584
+ return [dict(zip(self._AUDIT_COLUMNS, r, strict=True)) for r in rows]
1585
+
1586
+ def audit_suppressed(self, group_id: str | None = None) -> list[dict[str, Any]]:
1587
+ """Security rows that fell between a sender's oldest and newest half
1588
+ of its share (:meth:`_trim_security`), counted instead of kept: per
1589
+ group, sender and decision."""
1590
+ rows = self._all(
1591
+ "SELECT group_id, sender, decision, count, first_at, last_at FROM audit_suppressed "
1592
+ "WHERE (? IS NULL OR group_id = ?) ORDER BY group_id, sender, decision",
1593
+ group_id,
1594
+ group_id,
1595
+ )
1596
+ keys = ("group_id", "sender", "decision", "count", "first_at", "last_at")
1597
+ return [dict(zip(keys, r, strict=True)) for r in rows]
1598
+
1599
+ def audit_purge(self, before: int, keep: int) -> None:
1600
+ """Delete audit rows and suppressed counts older than ``before`` and,
1601
+ in each class but ``security``, all but the newest ``keep`` (one
1602
+ class never pushes out another, and volume never trims security
1603
+ rows)."""
1604
+ self._db.execute("DELETE FROM audit WHERE at < ?", (before,))
1605
+ self._db.execute("DELETE FROM audit_suppressed WHERE last_at < ?", (before,))
1606
+ for (row_class,) in self._all("SELECT DISTINCT class FROM audit"):
1607
+ if row_class != "security":
1608
+ self._trim_audit("class = ?", (str(row_class),), keep)
1609
+
1610
+ def transcript_add( # noqa: PLR0913
1611
+ self,
1612
+ group_id: str,
1613
+ *,
1614
+ at: int,
1615
+ direction: str,
1616
+ sender: str,
1617
+ msg_id: str,
1618
+ kind: str,
1619
+ inner: dict[str, Any],
1620
+ max_rows: int,
1621
+ max_sender_rows: int,
1622
+ max_group_bytes: int,
1623
+ max_bytes: int,
1624
+ ) -> None:
1625
+ """One transcript entry. Its sender keeps its newest ``max_sender_rows``
1626
+ in the group, the group its newest ``max_rows`` and ``max_group_bytes``,
1627
+ and over ``max_bytes`` for all groups the group holding the most gives
1628
+ way first, oldest entries first: one sender can't flush the record of
1629
+ the others. Every trim walks an index, and the byte totals are kept
1630
+ as they change (``transcript_bytes``), never summed over the table."""
1631
+ text = json.dumps(inner, sort_keys=True)
1632
+ self._db.execute(
1633
+ "INSERT INTO transcript (group_id, at, direction, sender, msg_id, kind, inner, size) "
1634
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1635
+ (group_id, at, direction, sender, msg_id, kind, text, len(text)),
1636
+ )
1637
+ self._add_transcript_bytes(group_id, len(text))
1638
+ self._trim_transcript(
1639
+ "group_id = ? AND sender = ?", (group_id, sender), max_sender_rows, group_id
1640
+ )
1641
+ self._trim_transcript("group_id = ?", (group_id,), max_rows, group_id)
1642
+ while self.transcript_bytes(group_id) > max_group_bytes:
1643
+ self._drop_oldest_transcript(group_id)
1644
+ while self.transcript_bytes() > max_bytes:
1645
+ biggest = self._one(
1646
+ "SELECT group_id FROM transcript_bytes ORDER BY bytes DESC, group_id LIMIT 1"
1647
+ )
1648
+ if biggest is None:
1649
+ break
1650
+ self._drop_oldest_transcript(str(biggest[0]))
1651
+
1652
+ def _add_transcript_bytes(self, group_id: str, delta: int) -> None:
1653
+ self._db.execute(
1654
+ "INSERT INTO transcript_bytes (group_id, bytes) VALUES (?, ?) "
1655
+ "ON CONFLICT(group_id) DO UPDATE SET bytes = bytes + excluded.bytes",
1656
+ (group_id, delta),
1657
+ )
1658
+ self._db.execute("DELETE FROM transcript_bytes WHERE bytes <= 0")
1659
+
1660
+ def _trim_transcript(self, where: str, args: tuple[str, ...], keep: int, group_id: str) -> None:
1661
+ """Delete the rows matching ``where`` beyond the newest ``keep``."""
1662
+ edge = self._one(
1663
+ f"SELECT seq FROM transcript WHERE {where} ORDER BY seq DESC LIMIT 1 OFFSET ?", # noqa: S608
1664
+ *args,
1665
+ keep,
1666
+ )
1667
+ if edge is None:
1668
+ return
1669
+ cond = f"{where} AND seq <= ?"
1670
+ total = f"SELECT COALESCE(SUM(size), 0) FROM transcript WHERE {cond}" # noqa: S608
1671
+ gone = self._one(total, *args, edge[0])
1672
+ self._db.execute(f"DELETE FROM transcript WHERE {cond}", (*args, edge[0])) # noqa: S608
1673
+ self._add_transcript_bytes(group_id, -int(gone[0]) if gone is not None else 0)
1674
+
1675
+ def _drop_oldest_transcript(self, group_id: str) -> None:
1676
+ row = self._one(
1677
+ "SELECT seq, size FROM transcript WHERE group_id = ? ORDER BY seq LIMIT 1", group_id
1678
+ )
1679
+ if row is None:
1680
+ self._db.execute("DELETE FROM transcript_bytes WHERE group_id = ?", (group_id,))
1681
+ return
1682
+ self._db.execute("DELETE FROM transcript WHERE seq = ?", (row[0],))
1683
+ self._add_transcript_bytes(group_id, -int(row[1]))
1684
+
1685
+ def transcript_bytes(self, group_id: str | None = None) -> int:
1686
+ """Bytes kept in one group's transcript, or in all of them."""
1687
+ if group_id is None:
1688
+ row = self._one("SELECT COALESCE(SUM(bytes), 0) FROM transcript_bytes")
1689
+ else:
1690
+ row = self._one("SELECT bytes FROM transcript_bytes WHERE group_id = ?", group_id)
1691
+ return 0 if row is None else int(row[0])
1692
+
1693
+ def transcript(self, group_id: str, after: int = 0, limit: int = 200) -> list[dict[str, Any]]:
1694
+ rows = self._all(
1695
+ "SELECT seq, group_id, at, direction, sender, msg_id, kind, inner FROM transcript "
1696
+ "WHERE group_id = ? AND seq > ? ORDER BY seq LIMIT ?",
1697
+ group_id,
1698
+ after,
1699
+ limit,
1700
+ )
1701
+ keys = ("seq", "group_id", "at", "direction", "sender", "msg_id", "kind")
1702
+ return [dict(zip(keys, r[:7], strict=True)) | {"inner": json.loads(r[7])} for r in rows]
1703
+
1704
+ def transcript_groups(self) -> list[str]:
1705
+ return [str(r[0]) for r in self._all("SELECT DISTINCT group_id FROM transcript")]
1706
+
1707
+ def transcript_purge(self, group_id: str, before: int) -> None:
1708
+ gone = self._one(
1709
+ "SELECT COALESCE(SUM(size), 0) FROM transcript WHERE group_id = ? AND at < ?",
1710
+ group_id,
1711
+ before,
1712
+ )
1713
+ self._db.execute("DELETE FROM transcript WHERE group_id = ? AND at < ?", (group_id, before))
1714
+ self._add_transcript_bytes(group_id, -int(gone[0]) if gone is not None else 0)
1715
+
1716
+ # -- listings and doors (the owner's side of discovery) -----------------
1717
+
1718
+ def listing(self, directory: str, name: str) -> dict[str, Any] | None:
1719
+ row = self._one(
1720
+ f"SELECT {', '.join(LISTING_COLUMNS)} FROM listings " # noqa: S608
1721
+ "WHERE directory = ? AND name = ?",
1722
+ directory,
1723
+ name,
1724
+ )
1725
+ return None if row is None else dict(zip(LISTING_COLUMNS, row, strict=True))
1726
+
1727
+ def listings(self) -> list[dict[str, Any]]:
1728
+ """Every listing row, the most recently changed first. Rows are
1729
+ never deleted: ``max_signed_seq`` must outlive a withdrawal."""
1730
+ rows = self._all(
1731
+ f"SELECT {', '.join(LISTING_COLUMNS)} FROM listings " # noqa: S608
1732
+ "ORDER BY updated_at DESC, created_at DESC"
1733
+ )
1734
+ return [dict(zip(LISTING_COLUMNS, r, strict=True)) for r in rows]
1735
+
1736
+ def put_listing(self, row: dict[str, Any]) -> None:
1737
+ """Insert or replace a whole listing row (missing counters are 0)."""
1738
+ self._db.execute(
1739
+ f"INSERT OR REPLACE INTO listings ({', '.join(LISTING_COLUMNS)}) " # noqa: S608
1740
+ f"VALUES ({', '.join('?' for _ in LISTING_COLUMNS)})",
1741
+ [row.get(c, 0 if c in _LISTING_COUNTERS else None) for c in LISTING_COLUMNS],
1742
+ )
1743
+
1744
+ def update_listing(self, directory: str, name: str, **fields: Any) -> None:
1745
+ for key in fields:
1746
+ if key not in LISTING_COLUMNS or key in ("directory", "name"):
1747
+ msg = f"unknown listing column {key}"
1748
+ raise ValueError(msg)
1749
+ sets = ", ".join(f"{k} = ?" for k in fields)
1750
+ self._db.execute(
1751
+ f"UPDATE listings SET {sets} WHERE directory = ? AND name = ?", # noqa: S608
1752
+ (*fields.values(), directory, name),
1753
+ )
1754
+
1755
+ def add_door( # noqa: PLR0913
1756
+ self,
1757
+ *,
1758
+ door_id: str,
1759
+ directory: str,
1760
+ name: str,
1761
+ secret: bytes,
1762
+ expires_at: int,
1763
+ now: int,
1764
+ ) -> None:
1765
+ """A new door, ``registering`` until the relay has it."""
1766
+ self._db.execute(
1767
+ "INSERT INTO doors (door_id, directory, name, secret, expires_at, created_at, state) "
1768
+ "VALUES (?, ?, ?, ?, ?, ?, 'registering')",
1769
+ (door_id, directory, name, secret, expires_at, now),
1770
+ )
1771
+
1772
+ def doors(self, directory: str | None = None, name: str | None = None) -> list[dict[str, Any]]:
1773
+ """Doors (of one listing, or all), the oldest first, without their
1774
+ private keys (see :meth:`door_secrets`)."""
1775
+ rows = self._all(
1776
+ f"SELECT {', '.join(DOOR_COLUMNS)} FROM doors " # noqa: S608
1777
+ "WHERE (? IS NULL OR directory = ?) AND (? IS NULL OR name = ?) "
1778
+ "ORDER BY created_at, door_id",
1779
+ directory,
1780
+ directory,
1781
+ name,
1782
+ name,
1783
+ )
1784
+ return [dict(zip(DOOR_COLUMNS, r, strict=True)) for r in rows]
1785
+
1786
+ def door_secrets(self) -> list[tuple[str, bytes]]:
1787
+ """Every door's private key that is still kept (encrypted at rest
1788
+ with the rest of this database), for loading them once at start."""
1789
+ rows = self._all("SELECT door_id, secret FROM doors WHERE secret IS NOT NULL")
1790
+ return [(str(r[0]), bytes(r[1])) for r in rows]
1791
+
1792
+ def drop_door_secret(self, door_id: str, state: str) -> None:
1793
+ """Delete a door's private key and move it to ``state``."""
1794
+ self._db.execute(
1795
+ "UPDATE doors SET secret = NULL, state = ? WHERE door_id = ?", (state, door_id)
1796
+ )
1797
+
1798
+ def update_door(self, door_id: str, **fields: Any) -> None:
1799
+ for key in fields:
1800
+ if key not in DOOR_COLUMNS or key == "door_id":
1801
+ msg = f"unknown door column {key}"
1802
+ raise ValueError(msg)
1803
+ sets = ", ".join(f"{k} = ?" for k in fields)
1804
+ self._db.execute(
1805
+ f"UPDATE doors SET {sets} WHERE door_id = ?", # noqa: S608
1806
+ (*fields.values(), door_id),
1807
+ )
1808
+
1809
+ def delete_door(self, door_id: str) -> None:
1810
+ self._db.execute("DELETE FROM doors WHERE door_id = ?", (door_id,))
1811
+
1812
+ # -- knocks: the owner's ledger and approvals, the knocker's knocks -------
1813
+
1814
+ def ledger_add(
1815
+ self,
1816
+ entry_id: str,
1817
+ kp_ref: str,
1818
+ forget_after: int,
1819
+ *,
1820
+ ledger: Ledger = "knock",
1821
+ invite_id: str | None = None,
1822
+ ) -> None:
1823
+ """Remember a request that passed the owner's checks (SPEC 6.2
1824
+ "Replays" for joins, with its ``invite_id``; 6.6.3 step 6 for
1825
+ knocks), in the transaction that records what happened to it. A row
1826
+ for the same id can only be one the ledger already forgot (a live one
1827
+ makes the request a replay), so it is replaced whole."""
1828
+ if ledger == "join":
1829
+ self._db.execute(
1830
+ "INSERT INTO join_ledger (request_id, invite_id, kp_ref, forget_after) "
1831
+ "VALUES (?, ?, ?, ?) ON CONFLICT(request_id) DO UPDATE SET "
1832
+ "invite_id = excluded.invite_id, kp_ref = excluded.kp_ref, "
1833
+ "forget_after = excluded.forget_after",
1834
+ (entry_id, invite_id or "", kp_ref, forget_after),
1835
+ )
1836
+ return
1837
+ self._db.execute(
1838
+ "INSERT INTO knock_ledger (knock_id, kp_ref, forget_after) VALUES (?, ?, ?) "
1839
+ "ON CONFLICT(knock_id) DO UPDATE SET "
1840
+ "kp_ref = excluded.kp_ref, forget_after = excluded.forget_after",
1841
+ (entry_id, kp_ref, forget_after),
1842
+ )
1843
+
1844
+ def join_ledger_count(self, invite_id: str) -> int:
1845
+ """How many join requests of this invite the ledger remembers that
1846
+ are not being added or added: those are in the approvals table, and
1847
+ their KeyPackages are tracked once merged, so they don't count
1848
+ against the invite's share."""
1849
+ row = self._one(
1850
+ "SELECT count(*) FROM join_ledger WHERE invite_id = ? AND request_id NOT IN "
1851
+ "(SELECT request_id FROM approvals WHERE status IN ('approving', 'approved'))",
1852
+ invite_id,
1853
+ )
1854
+ return 0 if row is None else int(row[0])
1855
+
1856
+ def join_ledger_forget(self, invite_id: str) -> int:
1857
+ """Forget an invite's join requests: it was revoked or has expired,
1858
+ both final, so step 1 refuses any copy (SPEC 6.2 "Replays")."""
1859
+ return self._db.execute(
1860
+ "DELETE FROM join_ledger WHERE invite_id = ?", (invite_id,)
1861
+ ).rowcount
1862
+
1863
+ def ledger_rows(self, now: int, *, ledger: Ledger = "knock") -> list[tuple[str, str, int]]:
1864
+ """``(id, kp_ref, forget_after)`` of every request still remembered at ``now``."""
1865
+ table, key = LEDGERS[ledger]
1866
+ rows = self._all(
1867
+ f"SELECT {key}, kp_ref, forget_after FROM {table} WHERE forget_after >= ? " # noqa: S608
1868
+ f"ORDER BY forget_after, {key}",
1869
+ now,
1870
+ )
1871
+ return [(str(r[0]), str(r[1]), int(r[2])) for r in rows]
1872
+
1873
+ def ledger_purge(self, now: int, *, ledger: Ledger = "knock") -> int:
1874
+ """Forget requests whose ``forget_after`` is before ``now``."""
1875
+ table, _ = LEDGERS[ledger]
1876
+ return self._db.execute(
1877
+ f"DELETE FROM {table} WHERE forget_after < ?", # noqa: S608
1878
+ (now,),
1879
+ ).rowcount
1880
+
1881
+ def add_knock_request(self, row: dict[str, Any], knock: bytes) -> None:
1882
+ """A knock waiting for the human (``pending``), with its secret."""
1883
+ columns = [c for c in KNOCK_REQUEST_COLUMNS if c in row]
1884
+ self._db.execute(
1885
+ f"INSERT INTO knock_requests ({', '.join(columns)}, knock) " # noqa: S608
1886
+ f"VALUES ({', '.join('?' for _ in columns)}, ?)",
1887
+ [*(row[c] for c in columns), knock],
1888
+ )
1889
+
1890
+ def knock_request(self, request_id: str) -> dict[str, Any] | None:
1891
+ row = self._one(
1892
+ f"SELECT {', '.join(KNOCK_REQUEST_COLUMNS)} FROM knock_requests " # noqa: S608
1893
+ "WHERE request_id = ?",
1894
+ request_id,
1895
+ )
1896
+ return None if row is None else dict(zip(KNOCK_REQUEST_COLUMNS, row, strict=True))
1897
+
1898
+ def knock_requests(self, *statuses: str) -> list[dict[str, Any]]:
1899
+ """Knock requests in these states (all, without any), oldest first."""
1900
+ rows = self._all(
1901
+ f"SELECT {', '.join(KNOCK_REQUEST_COLUMNS)} FROM knock_requests " # noqa: S608
1902
+ "WHERE ? = '[]' OR status IN (SELECT value FROM json_each(?)) "
1903
+ "ORDER BY received_at, request_id",
1904
+ json.dumps(list(statuses)),
1905
+ json.dumps(list(statuses)),
1906
+ )
1907
+ return [dict(zip(KNOCK_REQUEST_COLUMNS, r, strict=True)) for r in rows]
1908
+
1909
+ def knock_request_secret(self, request_id: str) -> bytes | None:
1910
+ """The verified knock (welcome_secret and KeyPackage) of a request, while kept."""
1911
+ row = self._one("SELECT knock FROM knock_requests WHERE request_id = ?", request_id)
1912
+ return None if row is None or row[0] is None else bytes(row[0])
1913
+
1914
+ def knock_request_for_group(self, group_id: str) -> dict[str, Any] | None:
1915
+ row = self._one(
1916
+ f"SELECT {', '.join(KNOCK_REQUEST_COLUMNS)} FROM knock_requests " # noqa: S608
1917
+ "WHERE group_id = ?",
1918
+ group_id,
1919
+ )
1920
+ return None if row is None else dict(zip(KNOCK_REQUEST_COLUMNS, row, strict=True))
1921
+
1922
+ def open_knock_counts(
1923
+ self, door_id: str, directory: str, name: str, identity: str
1924
+ ) -> tuple[int, int]:
1925
+ """Knocks waiting for the human (``pending``) on ``door_id``, and
1926
+ those of ``identity`` on any door of the listing."""
1927
+ row = self._one(
1928
+ "SELECT COALESCE(SUM(door_id = ?), 0), "
1929
+ "COALESCE(SUM(identity = ? AND directory = ? AND name = ?), 0) "
1930
+ "FROM knock_requests WHERE status = 'pending'",
1931
+ door_id,
1932
+ identity,
1933
+ directory,
1934
+ name,
1935
+ )
1936
+ return (0, 0) if row is None else (int(row[0]), int(row[1]))
1937
+
1938
+ def decide_knock_request( # noqa: PLR0913
1939
+ self,
1940
+ request_id: str,
1941
+ status: str,
1942
+ now: int,
1943
+ *,
1944
+ expect: tuple[str, ...] = ("pending",),
1945
+ keep_secret: bool = False,
1946
+ keep_name: bool = False,
1947
+ group_id: str | None = None,
1948
+ purpose: str | None = None,
1949
+ ) -> bool:
1950
+ """Move a request from one of ``expect`` to ``status``. Unless kept,
1951
+ the knock's secret goes, and so do the knocker's name and note (the
1952
+ note always goes once a decision is made). False if it was in
1953
+ another state (a concurrent decision won)."""
1954
+ cur = self._db.execute(
1955
+ "UPDATE knock_requests SET status = ?, decided_at = ?, "
1956
+ "knock = CASE WHEN ? THEN knock ELSE NULL END, "
1957
+ "display_name = CASE WHEN ? THEN display_name ELSE NULL END, note = NULL, "
1958
+ "group_id = COALESCE(?, group_id), purpose = COALESCE(?, purpose) "
1959
+ "WHERE request_id = ? AND status IN (SELECT value FROM json_each(?))",
1960
+ (
1961
+ status,
1962
+ now,
1963
+ int(keep_secret),
1964
+ int(keep_name),
1965
+ group_id,
1966
+ purpose,
1967
+ request_id,
1968
+ json.dumps(list(expect)),
1969
+ ),
1970
+ )
1971
+ return cur.rowcount == 1
1972
+
1973
+ def purge_knock_requests(self, before: int) -> None:
1974
+ """Forget decided requests decided before ``before``."""
1975
+ self._db.execute(
1976
+ "DELETE FROM knock_requests WHERE status NOT IN ('pending', 'approving') "
1977
+ "AND decided_at < ?",
1978
+ (before,),
1979
+ )
1980
+
1981
+ def add_knock(self, row: dict[str, Any], secret: bytes) -> None:
1982
+ """A knock this runtime sent (``queued``), with its secret."""
1983
+ columns = [c for c in KNOCK_COLUMNS if c in row]
1984
+ self._db.execute(
1985
+ f"INSERT INTO knocks ({', '.join(columns)}, secret) " # noqa: S608
1986
+ f"VALUES ({', '.join('?' for _ in columns)}, ?)",
1987
+ [*(row[c] for c in columns), secret],
1988
+ )
1989
+
1990
+ def knock(self, knock_id: str) -> dict[str, Any] | None:
1991
+ row = self._one(
1992
+ f"SELECT {', '.join(KNOCK_COLUMNS)} FROM knocks WHERE knock_id = ?", # noqa: S608
1993
+ knock_id,
1994
+ )
1995
+ return None if row is None else dict(zip(KNOCK_COLUMNS, row, strict=True))
1996
+
1997
+ def knocks(self, *statuses: str) -> list[dict[str, Any]]:
1998
+ """This runtime's knocks in these states (all, without any), oldest first."""
1999
+ rows = self._all(
2000
+ f"SELECT {', '.join(KNOCK_COLUMNS)} FROM knocks " # noqa: S608
2001
+ "WHERE ? = '[]' OR status IN (SELECT value FROM json_each(?)) "
2002
+ "ORDER BY created_at, knock_id",
2003
+ json.dumps(list(statuses)),
2004
+ json.dumps(list(statuses)),
2005
+ )
2006
+ return [dict(zip(KNOCK_COLUMNS, r, strict=True)) for r in rows]
2007
+
2008
+ def knock_secret(self, knock_id: str) -> bytes | None:
2009
+ """A sent knock's secret (welcome_secret and KeyPackage), while kept."""
2010
+ row = self._one("SELECT secret FROM knocks WHERE knock_id = ?", knock_id)
2011
+ return None if row is None or row[0] is None else bytes(row[0])
2012
+
2013
+ def knocks_since(self, since: int, door_id: str | None = None) -> int:
2014
+ """Knocks created at or after ``since`` (on ``door_id``, or any)."""
2015
+ row = self._one(
2016
+ "SELECT COUNT(*) FROM knocks WHERE created_at >= ? AND (? IS NULL OR door_id = ?)",
2017
+ since,
2018
+ door_id,
2019
+ door_id,
2020
+ )
2021
+ return 0 if row is None else int(row[0])
2022
+
2023
+ def update_knock(self, knock_id: str, **fields: Any) -> None:
2024
+ for key in fields:
2025
+ if key not in KNOCK_COLUMNS or key == "knock_id":
2026
+ msg = f"unknown knock column {key}"
2027
+ raise ValueError(msg)
2028
+ sets = ", ".join(f"{k} = ?" for k in fields)
2029
+ self._db.execute(
2030
+ f"UPDATE knocks SET {sets} WHERE knock_id = ?", # noqa: S608
2031
+ (*fields.values(), knock_id),
2032
+ )
2033
+
2034
+ def end_knock(
2035
+ self,
2036
+ knock_id: str,
2037
+ status: str,
2038
+ now: int,
2039
+ *,
2040
+ reason: str | None = None,
2041
+ group_id: str | None = None,
2042
+ ) -> bool:
2043
+ """A knock is over: joined, lapsed or failed. Its secret goes. False
2044
+ if it had ended already."""
2045
+ cur = self._db.execute(
2046
+ "UPDATE knocks SET status = ?, secret = NULL, reason = ?, ended_at = ?, "
2047
+ "group_id = COALESCE(?, group_id) "
2048
+ "WHERE knock_id = ? AND status IN ('queued', 'sent')",
2049
+ (status, reason, now, group_id, knock_id),
2050
+ )
2051
+ return cur.rowcount == 1
2052
+
2053
+ def knock_joined(self, knock_id: str, group_id: str, now: int) -> None:
2054
+ """The knock's Welcome was joined: its secret goes. Recorded whatever
2055
+ the knock's state was (a journalled join is finished even if a lapse
2056
+ was recorded meanwhile)."""
2057
+ self._db.execute(
2058
+ "UPDATE knocks SET status = 'joined', secret = NULL, reason = NULL, "
2059
+ "group_id = ?, ended_at = ? WHERE knock_id = ?",
2060
+ (group_id, now, knock_id),
2061
+ )
2062
+
2063
+ def purge_knocks(self, before: int) -> None:
2064
+ """Forget knocks that ended before ``before``."""
2065
+ self._db.execute("DELETE FROM knocks WHERE ended_at < ?", (before,))
2066
+
2067
+ def add_listed_peer(self, row: dict[str, Any]) -> None:
2068
+ self._db.execute(
2069
+ f"INSERT OR REPLACE INTO listed_peers ({', '.join(LISTED_PEER_COLUMNS)}) " # noqa: S608
2070
+ f"VALUES ({', '.join('?' for _ in LISTED_PEER_COLUMNS)})",
2071
+ [row[c] for c in LISTED_PEER_COLUMNS],
2072
+ )
2073
+
2074
+ def listed_peer(self, group_id: str) -> dict[str, Any] | None:
2075
+ row = self._one(
2076
+ f"SELECT {', '.join(LISTED_PEER_COLUMNS)} FROM listed_peers " # noqa: S608
2077
+ "WHERE group_id = ?",
2078
+ group_id,
2079
+ )
2080
+ return None if row is None else dict(zip(LISTED_PEER_COLUMNS, row, strict=True))
2081
+
2082
+ def listing_seen(self, directory: str, name: str) -> dict[str, Any] | None:
2083
+ row = self._one(
2084
+ f"SELECT {', '.join(SEEN_COLUMNS)} FROM listings_seen " # noqa: S608
2085
+ "WHERE directory = ? AND name = ?",
2086
+ directory,
2087
+ name,
2088
+ )
2089
+ return None if row is None else dict(zip(SEEN_COLUMNS, row, strict=True))
2090
+
2091
+ def put_listing_seen(self, row: dict[str, Any], *, keep_since: int) -> None:
2092
+ """Record a listing this runtime saw. The listings it knocked on (a
2093
+ knock row is kept a week after it ends) or is in a group with are
2094
+ kept; of the others (inspected only) the :data:`MAX_LISTINGS_SEEN`
2095
+ most recent, none seen before ``keep_since``. So inspecting many
2096
+ listings can't push out the rollback mark of one that matters."""
2097
+ unpinned = (
2098
+ "NOT EXISTS (SELECT 1 FROM knocks k WHERE k.directory = listings_seen.directory "
2099
+ "AND k.name = listings_seen.name) AND NOT EXISTS (SELECT 1 FROM listed_peers p "
2100
+ "WHERE p.directory = listings_seen.directory AND p.name = listings_seen.name)"
2101
+ )
2102
+ with self.tx() as db:
2103
+ db.execute(
2104
+ f"INSERT OR REPLACE INTO listings_seen ({', '.join(SEEN_COLUMNS)}) " # noqa: S608
2105
+ f"VALUES ({', '.join('?' for _ in SEEN_COLUMNS)})",
2106
+ [row.get(c) for c in SEEN_COLUMNS],
2107
+ )
2108
+ db.execute(
2109
+ f"DELETE FROM listings_seen WHERE seen_at < ? AND {unpinned}", # noqa: S608
2110
+ (keep_since,),
2111
+ )
2112
+ db.execute(
2113
+ f"DELETE FROM listings_seen WHERE {unpinned} AND rowid NOT IN " # noqa: S608
2114
+ f"(SELECT rowid FROM listings_seen WHERE {unpinned} "
2115
+ "ORDER BY seen_at DESC, rowid DESC LIMIT ?)",
2116
+ (MAX_LISTINGS_SEEN,),
2117
+ )
2118
+
2119
+ # -- invites and approvals ---------------------------------------------
2120
+
2121
+ def add_invite( # noqa: PLR0913
2122
+ self,
2123
+ invite_id: str,
2124
+ group_id: str,
2125
+ expires_at: int,
2126
+ max_uses: int,
2127
+ now: int,
2128
+ *,
2129
+ status: str = "open",
2130
+ replica: bool = False,
2131
+ instance: str | None = None,
2132
+ ) -> None:
2133
+ self._db.execute(
2134
+ "INSERT INTO invites (invite_id, group_id, expires_at, max_uses, created_at, status, "
2135
+ "replica, instance) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
2136
+ (invite_id, group_id, expires_at, max_uses, now, status, int(replica), instance),
2137
+ )
2138
+
2139
+ def delete_invite(self, invite_id: str) -> None:
2140
+ self._db.execute("DELETE FROM invites WHERE invite_id = ?", (invite_id,))
2141
+
2142
+ def invite(self, invite_id: str) -> dict[str, Any] | None:
2143
+ row = self._one(
2144
+ "SELECT invite_id, group_id, expires_at, max_uses, status, replica, instance "
2145
+ "FROM invites WHERE invite_id = ?",
2146
+ invite_id,
2147
+ )
2148
+ return None if row is None else self._invite_row(row)
2149
+
2150
+ def invites(self, status: str) -> list[dict[str, Any]]:
2151
+ rows = self._all(
2152
+ "SELECT invite_id, group_id, expires_at, max_uses, status, replica, instance "
2153
+ "FROM invites WHERE status = ? ORDER BY created_at",
2154
+ status,
2155
+ )
2156
+ return [self._invite_row(r) for r in rows]
2157
+
2158
+ @staticmethod
2159
+ def _invite_row(row: tuple[Any, ...]) -> dict[str, Any]:
2160
+ keys = ("invite_id", "group_id", "expires_at", "max_uses", "status")
2161
+ out = dict(zip(keys, row[:5], strict=True))
2162
+ out["replica"] = bool(row[5])
2163
+ out["instance"] = row[6]
2164
+ return out
2165
+
2166
+ def set_invite_status(self, invite_id: str, status: str) -> None:
2167
+ self._db.execute("UPDATE invites SET status = ? WHERE invite_id = ?", (status, invite_id))
2168
+
2169
+ def add_approval(self, row: dict[str, Any]) -> bool:
2170
+ cur = self._db.execute(
2171
+ "INSERT OR IGNORE INTO approvals (request_id, invite_id, group_id, request, identity, "
2172
+ "display_name, mailbox_id, received_at, kp_ref, instance_id) "
2173
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
2174
+ [
2175
+ *(row[k] for k in self._APPROVAL_KEYS[:8]),
2176
+ row.get("kp_ref"),
2177
+ row.get("instance_id"),
2178
+ ],
2179
+ )
2180
+ return cur.rowcount == 1
2181
+
2182
+ _APPROVAL_KEYS = (
2183
+ "request_id",
2184
+ "invite_id",
2185
+ "group_id",
2186
+ "request",
2187
+ "identity",
2188
+ "display_name",
2189
+ "mailbox_id",
2190
+ "received_at",
2191
+ "status",
2192
+ "instance_id",
2193
+ )
2194
+
2195
+ def approval(self, request_id: str) -> dict[str, Any] | None:
2196
+ row = self._one(
2197
+ f"SELECT {', '.join(self._APPROVAL_KEYS)} FROM approvals WHERE request_id = ?", # noqa: S608
2198
+ request_id,
2199
+ )
2200
+ return None if row is None else dict(zip(self._APPROVAL_KEYS, row, strict=True))
2201
+
2202
+ def approvals(self, status: str = "pending") -> list[dict[str, Any]]:
2203
+ rows = self._all(
2204
+ f"SELECT {', '.join(self._APPROVAL_KEYS)} FROM approvals " # noqa: S608
2205
+ "WHERE status = ? ORDER BY received_at",
2206
+ status,
2207
+ )
2208
+ return [dict(zip(self._APPROVAL_KEYS, r, strict=True)) for r in rows]
2209
+
2210
+ def open_approval_for(self, kp_ref: str) -> str | None:
2211
+ """A pending or approving request with this KeyPackageRef, if any."""
2212
+ row = self._one(
2213
+ "SELECT request_id FROM approvals WHERE kp_ref = ? "
2214
+ "AND status IN ('pending', 'approving')",
2215
+ kp_ref,
2216
+ )
2217
+ return None if row is None else str(row[0])
2218
+
2219
+ def pending_approvals_for(self, invite_id: str, since: int) -> int:
2220
+ """Join requests of this invite waiting for the human, received at
2221
+ or after ``since`` (older ones have lapsed)."""
2222
+ row = self._one(
2223
+ "SELECT count(*) FROM approvals WHERE invite_id = ? AND status = 'pending' "
2224
+ "AND received_at >= ?",
2225
+ invite_id,
2226
+ since,
2227
+ )
2228
+ return 0 if row is None else int(row[0])
2229
+
2230
+ def pending_approvals_of(self, invite_id: str, identity: str, since: int) -> int:
2231
+ """Join requests of this invite that claim ``identity`` and wait for
2232
+ the human, received at or after ``since`` (older ones have lapsed)."""
2233
+ row = self._one(
2234
+ "SELECT count(*) FROM approvals WHERE invite_id = ? AND identity = ? "
2235
+ "AND status = 'pending' AND received_at >= ?",
2236
+ invite_id,
2237
+ identity,
2238
+ since,
2239
+ )
2240
+ return 0 if row is None else int(row[0])
2241
+
2242
+ def adding_to(self, group_id: str) -> list[str]:
2243
+ """The joiners' mailboxes of the approved joins of this group whose
2244
+ Add is under way (``approving`` until the Welcome went out)."""
2245
+ rows = self._all(
2246
+ "SELECT mailbox_id FROM approvals WHERE group_id = ? AND status = 'approving'",
2247
+ group_id,
2248
+ )
2249
+ return [str(r[0]) for r in rows]
2250
+
2251
+ def uses_taken(self, invite_id: str) -> int:
2252
+ """Approvals that hold one of the invite's uses (approving or done)."""
2253
+ row = self._one(
2254
+ "SELECT count(*) FROM approvals WHERE invite_id = ? "
2255
+ "AND status IN ('approving', 'approved')",
2256
+ invite_id,
2257
+ )
2258
+ return 0 if row is None else int(row[0])
2259
+
2260
+ def set_approval_status(self, request_id: str, status: str) -> None:
2261
+ # A decided request no longer needs its body (key package, name).
2262
+ keep = status in ("pending", "approving")
2263
+ self._db.execute(
2264
+ "UPDATE approvals SET status = ?, request = CASE WHEN ? THEN request ELSE '' END "
2265
+ "WHERE request_id = ?",
2266
+ (status, int(keep), request_id),
2267
+ )
2268
+
2269
+ # -- joins (joiner side) -----------------------------------------------
2270
+
2271
+ def add_join(self, invite_id: str, meta: dict[str, Any], display_name: str, now: int) -> None:
2272
+ self._db.execute(
2273
+ "INSERT OR REPLACE INTO joins (invite_id, meta, display_name, created_at) "
2274
+ "VALUES (?, ?, ?, ?)",
2275
+ (invite_id, json.dumps(meta), display_name, now),
2276
+ )
2277
+
2278
+ def pending_joins(self) -> list[dict[str, Any]]:
2279
+ rows = self._all(
2280
+ "SELECT invite_id, meta, display_name FROM joins WHERE status = 'pending' "
2281
+ "ORDER BY created_at"
2282
+ )
2283
+ return [{"invite_id": r[0], "meta": json.loads(r[1]), "display_name": r[2]} for r in rows]
2284
+
2285
+ def finish_join(self, invite_id: str, status: str, group_id: str | None = None) -> None:
2286
+ self._db.execute(
2287
+ "UPDATE joins SET status = ?, group_id = ? WHERE invite_id = ?",
2288
+ (status, group_id, invite_id),
2289
+ )
2290
+
2291
+ def joined_invites(self) -> list[str]:
2292
+ """Invite ids of joins that ended (joined, failed or expired)."""
2293
+ rows = self._all("SELECT invite_id FROM joins WHERE status != 'pending'")
2294
+ return [str(r[0]) for r in rows]
2295
+
2296
+ def join_name(self, invite_id: str) -> str | None:
2297
+ """The display name a join (in any state) was requested under."""
2298
+ row = self._one("SELECT display_name FROM joins WHERE invite_id = ?", invite_id)
2299
+ return None if row is None else str(row[0])
2300
+
2301
+ def join_status(self, invite_id: str) -> tuple[str, str | None] | None:
2302
+ row = self._one("SELECT status, group_id FROM joins WHERE invite_id = ?", invite_id)
2303
+ return None if row is None else (str(row[0]), None if row[1] is None else str(row[1]))
2304
+
2305
+ # -- outbox -------------------------------------------------------------
2306
+
2307
+ def enqueue(self, kind: str, group_id: str | None, payload: dict[str, Any], now: int) -> int:
2308
+ cur = self._db.execute(
2309
+ "INSERT INTO outbox (kind, group_id, payload, created_at) VALUES (?, ?, ?, ?)",
2310
+ (kind, group_id, json.dumps(payload), now),
2311
+ )
2312
+ return int(cur.lastrowid or 0)
2313
+
2314
+ def next_outbox(self) -> OutboxRow | None:
2315
+ row = self._one(
2316
+ "SELECT seq, kind, group_id, payload, state FROM outbox "
2317
+ "WHERE state = 'queued' ORDER BY seq LIMIT 1"
2318
+ )
2319
+ if row is None:
2320
+ return None
2321
+ return OutboxRow(row[0], row[1], row[2], json.loads(row[3]), row[4])
2322
+
2323
+ def outbox_lanes(self) -> list[tuple[int, str | None]]:
2324
+ """Every queued row's ``seq``, in order, with its lane: the group it
2325
+ sends to (a frame's own group id when it names one, such as
2326
+ ``group.create`` or a proposal), or ``None`` for a frame of no group
2327
+ (a join, a knock, an invite's consumption)."""
2328
+ rows = self._all(
2329
+ "SELECT seq, COALESCE(group_id, json_extract(payload, '$.frame.group_id'), "
2330
+ "json_extract(payload, '$.frame.env.group_id')) "
2331
+ "FROM outbox WHERE state = 'queued' ORDER BY seq"
2332
+ )
2333
+ return [(int(r[0]), None if r[1] is None else str(r[1])) for r in rows]
2334
+
2335
+ def outbox_row(self, seq: int) -> OutboxRow | None:
2336
+ row = self._one("SELECT seq, kind, group_id, payload, state FROM outbox WHERE seq = ?", seq)
2337
+ return (
2338
+ None if row is None else OutboxRow(row[0], row[1], row[2], json.loads(row[3]), row[4])
2339
+ )
2340
+
2341
+ def outbox(self, state: str = "queued") -> list[OutboxRow]:
2342
+ rows = self._all(
2343
+ "SELECT seq, kind, group_id, payload, state FROM outbox WHERE state = ? ORDER BY seq",
2344
+ state,
2345
+ )
2346
+ return [OutboxRow(r[0], r[1], r[2], json.loads(r[3]), r[4]) for r in rows]
2347
+
2348
+ def outbox_state_of(self, msg_id: str) -> str | None:
2349
+ """The state of the outbox row that sends ``msg_id``, if one is kept."""
2350
+ row = self._one(
2351
+ "SELECT state FROM outbox WHERE json_extract(payload, '$.msg_id') = ? "
2352
+ "ORDER BY seq DESC LIMIT 1",
2353
+ msg_id,
2354
+ )
2355
+ return None if row is None else str(row[0])
2356
+
2357
+ def update_outbox(
2358
+ self, seq: int, payload: dict[str, Any] | None = None, state: str | None = None
2359
+ ) -> None:
2360
+ if state is not None and state != "queued":
2361
+ # Answered (or given up): keep the ids, drop inner plaintext,
2362
+ # envelopes and frames.
2363
+ if payload is None:
2364
+ row = self.outbox_row(seq)
2365
+ payload = {} if row is None else row.payload
2366
+ payload = _scrubbed(payload)
2367
+ if payload is not None:
2368
+ self._db.execute(
2369
+ "UPDATE outbox SET payload = ? WHERE seq = ?", (json.dumps(payload), seq)
2370
+ )
2371
+ if state is not None:
2372
+ self._db.execute("UPDATE outbox SET state = ? WHERE seq = ?", (state, seq))
2373
+
2374
+ # -- inbox --------------------------------------------------------------
2375
+
2376
+ def inbox_seq(self, sender: str, msg_id: str) -> int | None:
2377
+ row = self._one("SELECT seq FROM inbox WHERE sender = ? AND msg_id = ?", sender, msg_id)
2378
+ return None if row is None else int(row[0])
2379
+
2380
+ def inbox_row(self, seq: int) -> InboxRow | None:
2381
+ row = self._one(
2382
+ "SELECT seq, sender, msg_id, group_id, delivery, state FROM inbox WHERE seq = ?", seq
2383
+ )
2384
+ if row is None:
2385
+ return None
2386
+ delivery = None if row[4] is None else json.loads(row[4])
2387
+ return InboxRow(row[0], row[1], row[2], row[3], delivery, row[5])
2388
+
2389
+ def inbox_state(self, sender: str, msg_id: str) -> str | None:
2390
+ row = self._one("SELECT state FROM inbox WHERE sender = ? AND msg_id = ?", sender, msg_id)
2391
+ return None if row is None else str(row[0])
2392
+
2393
+ def record_inbox( # noqa: PLR0913
2394
+ self,
2395
+ sender: str,
2396
+ msg_id: str,
2397
+ group_id: str | None,
2398
+ state: str,
2399
+ now: int,
2400
+ *,
2401
+ delivery: dict[str, Any] | None = None,
2402
+ hop: int | None = None,
2403
+ ) -> None:
2404
+ text = None if delivery is None else json.dumps(delivery)
2405
+ cur = self._db.execute(
2406
+ "INSERT OR IGNORE INTO inbox (sender, msg_id, group_id, delivery, state, received_at, "
2407
+ "hop) VALUES (?, ?, ?, ?, ?, ?, ?)",
2408
+ (sender, msg_id, group_id, text, state, now, hop),
2409
+ )
2410
+ if cur.rowcount == 1 and state == "pending" and text is not None and group_id is not None:
2411
+ self._pending_add(group_id, len(text.encode()))
2412
+
2413
+ def _pending_add(self, group_id: str, n: int) -> None:
2414
+ self._db.execute(
2415
+ "INSERT INTO inbox_pending_bytes (group_id, bytes) VALUES (?, ?) "
2416
+ "ON CONFLICT (group_id) DO UPDATE SET bytes = MAX(bytes + excluded.bytes, 0)",
2417
+ (group_id, n),
2418
+ )
2419
+
2420
+ def pending_load(self, group_id: str) -> tuple[int, int | None]:
2421
+ """The group's pending plaintext: its bytes, and when its oldest
2422
+ pending message was received (``None``: nothing pending). Kept as rows
2423
+ change, never summed over the table."""
2424
+ row = self._one("SELECT bytes FROM inbox_pending_bytes WHERE group_id = ?", group_id)
2425
+ oldest = self._one(
2426
+ "SELECT received_at FROM inbox WHERE state = 'pending' AND group_id = ? "
2427
+ "AND delivery IS NOT NULL ORDER BY seq LIMIT 1",
2428
+ group_id,
2429
+ )
2430
+ return (0 if row is None else int(row[0]), None if oldest is None else int(oldest[0]))
2431
+
2432
+ def mark_unacked(self, sender: str, msg_id: str) -> None:
2433
+ """The relay keeps this message's copy until it is acked later
2434
+ (the group's pending messages are past their limits)."""
2435
+ self._db.execute(
2436
+ "UPDATE inbox SET unacked = 1 WHERE sender = ? AND msg_id = ?", (sender, msg_id)
2437
+ )
2438
+
2439
+ def take_unacked(self, group_id: str, *, pending_too: bool = False) -> list[str]:
2440
+ """The ``msg_id``s whose relay copy is to be acked now: the group's
2441
+ rows that are no longer pending (with ``pending_too``, all of them);
2442
+ they are no longer marked."""
2443
+ with self.tx():
2444
+ rows = self._all(
2445
+ "SELECT seq, msg_id FROM inbox WHERE unacked = 1 AND group_id = ? "
2446
+ "AND (? OR state != 'pending')",
2447
+ group_id,
2448
+ int(pending_too),
2449
+ )
2450
+ for seq, _ in rows:
2451
+ self._db.execute("UPDATE inbox SET unacked = 0 WHERE seq = ?", (seq,))
2452
+ return [str(r[1]) for r in rows]
2453
+
2454
+ def inbox_row_state(self, seq: int) -> str | None:
2455
+ row = self._one("SELECT state FROM inbox WHERE seq = ?", seq)
2456
+ return None if row is None else str(row[0])
2457
+
2458
+ def record_sent_via(self, group_id: str, msg_id: str, adapter: str, now: int) -> None:
2459
+ """An adapter sent this message (so a reply to it triggers that adapter)."""
2460
+ self._db.execute(
2461
+ "INSERT OR IGNORE INTO sent_via (group_id, msg_id, adapter, at) VALUES (?, ?, ?, ?)",
2462
+ (group_id, msg_id, adapter, now),
2463
+ )
2464
+
2465
+ def sent_via(self, group_id: str, msg_id: str) -> str | None:
2466
+ row = self._one(
2467
+ "SELECT adapter FROM sent_via WHERE group_id = ? AND msg_id = ?", group_id, msg_id
2468
+ )
2469
+ return None if row is None else str(row[0])
2470
+
2471
+ def record_hop(self, group_id: str, msg_id: str, hop: int, now: int) -> None:
2472
+ """Remember the SPEC 10.3 ``hop`` of a message sent or received."""
2473
+ self._db.execute(
2474
+ "INSERT OR IGNORE INTO hops (group_id, msg_id, hop, at) VALUES (?, ?, ?, ?)",
2475
+ (group_id, msg_id, hop, now),
2476
+ )
2477
+
2478
+ def inbox_hops(self, group_id: str, msg_ids: Sequence[str]) -> list[int]:
2479
+ """The known ``hop`` of messages (sent or received) in a group."""
2480
+ out: list[int] = []
2481
+ for msg_id in msg_ids:
2482
+ row = self._one(
2483
+ "SELECT hop FROM hops WHERE group_id = ? AND msg_id = ?", group_id, msg_id
2484
+ )
2485
+ if row is not None:
2486
+ out.append(int(row[0]))
2487
+ return out
2488
+
2489
+ def forget_hops(self, before: int) -> None:
2490
+ self._db.execute("DELETE FROM hops WHERE at < ?", (before,))
2491
+ self._db.execute("DELETE FROM sent_via WHERE at < ?", (before,))
2492
+ self._db.execute("DELETE FROM task_requests WHERE at < ?", (before,))
2493
+ self._db.execute("DELETE FROM answered_tasks WHERE at < ?", (before,))
2494
+ self._db.execute("DELETE FROM asked_requests WHERE at < ?", (before,))
2495
+ self._db.execute("DELETE FROM request_tasks WHERE at < ?", (before,))
2496
+ self._db.execute("DELETE FROM a2a_agent_tasks WHERE at < ?", (before,))
2497
+
2498
+ def record_request( # noqa: PLR0913
2499
+ self,
2500
+ group_id: str,
2501
+ msg_id: str,
2502
+ *,
2503
+ task_id: str | None,
2504
+ asked: Sequence[str],
2505
+ instance: str | None,
2506
+ now: int,
2507
+ ) -> None:
2508
+ """Remember a message this instance sent that a peer may answer with
2509
+ a task (SPEC 9.3): the task it names, if any, the identities it asked
2510
+ and, with an instance hint, the one instance."""
2511
+ self._db.execute(
2512
+ "INSERT OR IGNORE INTO task_requests "
2513
+ "(group_id, msg_id, task_id, asked, instance, at) VALUES (?, ?, ?, ?, ?, ?)",
2514
+ (group_id, msg_id, task_id, json.dumps(sorted(set(asked))), instance, now),
2515
+ )
2516
+
2517
+ def request_asks(self, group_id: str, msg_id: str, identity: str, instance: str) -> bool:
2518
+ """Whether the request ``msg_id`` asked that instance of ``identity``."""
2519
+ row = self._one(
2520
+ "SELECT asked, instance FROM task_requests WHERE group_id = ? AND msg_id = ?",
2521
+ group_id,
2522
+ msg_id,
2523
+ )
2524
+ return row is not None and identity in json.loads(row[0]) and row[1] in (None, instance)
2525
+
2526
+ def task_owner(self, group_id: str, task_id: str) -> tuple[str, str] | None:
2527
+ """The member running ``task_id`` for this instance (identity and
2528
+ instance), if exactly one is known (SPEC 9.3)."""
2529
+ rows = self._all(
2530
+ "SELECT DISTINCT responder, instance FROM request_tasks "
2531
+ "WHERE group_id = ? AND task_id = ? LIMIT 2",
2532
+ group_id,
2533
+ task_id,
2534
+ )
2535
+ return (str(rows[0][0]), str(rows[0][1])) if len(rows) == 1 else None
2536
+
2537
+ def grant_task( # noqa: PLR0913
2538
+ self,
2539
+ group_id: str,
2540
+ refs: Sequence[str],
2541
+ *,
2542
+ responder: str,
2543
+ instance: str,
2544
+ task_id: str,
2545
+ now: int,
2546
+ ) -> str | None:
2547
+ """The request among ``refs`` that grants a task update from
2548
+ ``responder`` (``instance``) about ``task_id`` (SPEC 9.3), or
2549
+ ``None``. A request asked the responder; it grants one task per asked
2550
+ member: the one it named, else the first the member answers it with,
2551
+ bound to the instance that answered (recorded here)."""
2552
+ for ref in refs:
2553
+ row = self._one(
2554
+ "SELECT task_id, asked, instance FROM task_requests "
2555
+ "WHERE group_id = ? AND msg_id = ?",
2556
+ group_id,
2557
+ ref,
2558
+ )
2559
+ if row is None:
2560
+ continue
2561
+ named, asked, hint = row
2562
+ if responder not in json.loads(asked) or hint not in (None, instance):
2563
+ continue
2564
+ grant = self._one(
2565
+ "SELECT task_id, instance FROM request_tasks "
2566
+ "WHERE group_id = ? AND msg_id = ? AND responder = ?",
2567
+ group_id,
2568
+ ref,
2569
+ responder,
2570
+ )
2571
+ if grant is None:
2572
+ if named not in (None, task_id):
2573
+ continue
2574
+ self._db.execute(
2575
+ "INSERT INTO request_tasks "
2576
+ "(group_id, msg_id, responder, instance, task_id, at) "
2577
+ "VALUES (?, ?, ?, ?, ?, ?)",
2578
+ (group_id, ref, responder, instance, task_id, now),
2579
+ )
2580
+ return ref
2581
+ if (grant[0], grant[1]) == (task_id, instance):
2582
+ return ref
2583
+ return None
2584
+
2585
+ def task_triggers(self, group_id: str, request: str, responder: str, state: str) -> bool:
2586
+ """Whether entering ``state`` (one the requester acts on) triggers:
2587
+ the granted task hasn't ended and hasn't triggered on ``state``
2588
+ before (SPEC 9.3)."""
2589
+ row = self._one(
2590
+ "SELECT triggered, ended FROM request_tasks "
2591
+ "WHERE group_id = ? AND msg_id = ? AND responder = ?",
2592
+ group_id,
2593
+ request,
2594
+ responder,
2595
+ )
2596
+ return row is not None and not row[1] and state not in json.loads(row[0])
2597
+
2598
+ def task_entered(
2599
+ self, group_id: str, request: str, responder: str, state: str, *, terminal: bool
2600
+ ) -> None:
2601
+ """The granted task entered ``state`` (and the receiver took the
2602
+ update): it triggers on it at most once, and a terminal state ends
2603
+ the task."""
2604
+ row = self._one(
2605
+ "SELECT triggered FROM request_tasks "
2606
+ "WHERE group_id = ? AND msg_id = ? AND responder = ?",
2607
+ group_id,
2608
+ request,
2609
+ responder,
2610
+ )
2611
+ if row is None:
2612
+ return
2613
+ states = sorted({*json.loads(row[0]), state})
2614
+ self._db.execute(
2615
+ "UPDATE request_tasks SET triggered = ?, ended = MAX(ended, ?) "
2616
+ "WHERE group_id = ? AND msg_id = ? AND responder = ?",
2617
+ (json.dumps(states), int(terminal), group_id, request, responder),
2618
+ )
2619
+
2620
+ def granting_request(
2621
+ self, group_id: str, refs: Sequence[str], responder: str, task_id: str
2622
+ ) -> str | None:
2623
+ """The request among ``refs`` that granted ``task_id`` to ``responder``."""
2624
+ for ref in refs:
2625
+ row = self._one(
2626
+ "SELECT 1 FROM request_tasks WHERE group_id = ? AND msg_id = ? "
2627
+ "AND responder = ? AND task_id = ?",
2628
+ group_id,
2629
+ ref,
2630
+ responder,
2631
+ task_id,
2632
+ )
2633
+ if row is not None:
2634
+ return ref
2635
+ return None
2636
+
2637
+ def record_answered(self, group_id: str, task_id: str, msg_id: str, now: int) -> None:
2638
+ """This instance runs ``task_id`` for the request ``msg_id`` (the
2639
+ first task update it sent names it)."""
2640
+ self._db.execute(
2641
+ "INSERT OR IGNORE INTO answered_tasks (group_id, task_id, msg_id, at) "
2642
+ "VALUES (?, ?, ?, ?)",
2643
+ (group_id, task_id, msg_id, now),
2644
+ )
2645
+
2646
+ def record_asked(self, group_id: str, msg_id: str, *, task_id: str | None, now: int) -> None:
2647
+ """Remember a request this instance received that asked it (SPEC
2648
+ 9.3), and the task it names, if any: this instance's answer to it may
2649
+ trigger its requester."""
2650
+ self._db.execute(
2651
+ "INSERT OR IGNORE INTO asked_requests (group_id, msg_id, task_id, at) "
2652
+ "VALUES (?, ?, ?, ?)",
2653
+ (group_id, msg_id, task_id, now),
2654
+ )
2655
+
2656
+ def answer_triggers(self, group_id: str, msg_id: str, task_id: str, state: str) -> bool:
2657
+ """Whether this instance's update of ``task_id`` entering ``state``
2658
+ (one the requester acts on) answers the received request ``msg_id``
2659
+ as the requester's grant takes it (SPEC 9.3): the request asked this
2660
+ instance, ``task_id`` is its task (the one it named, else the first
2661
+ this instance answered it with, or none yet), which hasn't ended or
2662
+ entered ``state`` before."""
2663
+ row = self._one(
2664
+ "SELECT task_id, sent, ended FROM asked_requests WHERE group_id = ? AND msg_id = ?",
2665
+ group_id,
2666
+ msg_id,
2667
+ )
2668
+ return (
2669
+ row is not None
2670
+ and row[0] in (None, task_id)
2671
+ and not row[2]
2672
+ and state not in json.loads(row[1])
2673
+ )
2674
+
2675
+ def answered_asked(
2676
+ self, group_id: str, msg_id: str, task_id: str, state: str | None, *, terminal: bool
2677
+ ) -> None:
2678
+ """This instance sent an update of ``task_id`` answering the
2679
+ received request ``msg_id``: the first task answering it becomes its
2680
+ task, and a ``state`` that triggers the requester
2681
+ (:meth:`answer_triggers`) is used; a terminal one ends the task."""
2682
+ self._db.execute(
2683
+ "UPDATE asked_requests SET task_id = ? "
2684
+ "WHERE group_id = ? AND msg_id = ? AND task_id IS NULL",
2685
+ (task_id, group_id, msg_id),
2686
+ )
2687
+ if state is not None:
2688
+ self._db.execute(
2689
+ "UPDATE asked_requests SET sent = json_insert(sent, '$[#]', ?), "
2690
+ "ended = MAX(ended, ?) WHERE group_id = ? AND msg_id = ? AND task_id = ?",
2691
+ (state, int(terminal), group_id, msg_id, task_id),
2692
+ )
2693
+
2694
+ def answered_request(self, group_id: str, task_id: str) -> str | None:
2695
+ row = self._one(
2696
+ "SELECT msg_id FROM answered_tasks WHERE group_id = ? AND task_id = ?",
2697
+ group_id,
2698
+ task_id,
2699
+ )
2700
+ return None if row is None else str(row[0])
2701
+
2702
+ def a2a_task_put( # noqa: PLR0913
2703
+ self,
2704
+ task_id: str,
2705
+ *,
2706
+ group_id: str,
2707
+ requester: str,
2708
+ instance: str,
2709
+ msg_id: str,
2710
+ now: int,
2711
+ keep: int,
2712
+ ) -> None:
2713
+ """The A2A adapter's map of its agent's tasks (who asked, where),
2714
+ kept so follow-ups continue a task after a restart. Each requesting
2715
+ instance keeps its newest ``keep``, so no peer can push another's
2716
+ out."""
2717
+ self._db.execute(
2718
+ "INSERT OR REPLACE INTO a2a_agent_tasks "
2719
+ "(task_id, group_id, requester, instance, msg_id, at) VALUES (?, ?, ?, ?, ?, ?)",
2720
+ (task_id, group_id, requester, instance, msg_id, now),
2721
+ )
2722
+ self._db.execute(
2723
+ "DELETE FROM a2a_agent_tasks WHERE group_id = ? AND requester = ? AND instance = ? "
2724
+ "AND task_id NOT IN (SELECT task_id FROM a2a_agent_tasks WHERE group_id = ? "
2725
+ "AND requester = ? AND instance = ? ORDER BY at DESC, rowid DESC LIMIT ?)",
2726
+ (group_id, requester, instance, group_id, requester, instance, keep),
2727
+ )
2728
+
2729
+ def a2a_task(self, task_id: str) -> tuple[str, str, str, str] | None:
2730
+ row = self._one(
2731
+ "SELECT group_id, requester, instance, msg_id FROM a2a_agent_tasks WHERE task_id = ?",
2732
+ task_id,
2733
+ )
2734
+ return None if row is None else (str(row[0]), str(row[1]), str(row[2]), str(row[3]))
2735
+
2736
+ def pending_deliveries(
2737
+ self,
2738
+ group_id: str | None = None,
2739
+ limit: int = -1,
2740
+ *,
2741
+ adapter: str | None = None,
2742
+ after: int = 0,
2743
+ ) -> list[InboxRow]:
2744
+ """Pending inbox rows, oldest first; with ``group_id`` only that
2745
+ group's that can be handed over, at most ``limit`` of them (-1: all),
2746
+ with ``adapter`` only those that adapter hasn't taken yet, and only
2747
+ those after ``after`` (a ``seq``: where that adapter got to)."""
2748
+ rows = self._all(
2749
+ "SELECT seq, sender, msg_id, group_id, delivery, state FROM inbox "
2750
+ "WHERE state = 'pending' AND (? IS NULL OR (group_id = ? AND delivery IS NOT NULL)) "
2751
+ "AND seq > ? AND (? IS NULL OR NOT EXISTS (SELECT 1 FROM inbox_acks a "
2752
+ "WHERE a.inbox_seq = inbox.seq AND a.adapter = ?)) "
2753
+ "ORDER BY seq LIMIT ?",
2754
+ group_id,
2755
+ group_id,
2756
+ after,
2757
+ adapter,
2758
+ adapter,
2759
+ limit,
2760
+ )
2761
+ return [
2762
+ InboxRow(r[0], r[1], r[2], r[3], None if r[4] is None else json.loads(r[4]), r[5])
2763
+ for r in rows
2764
+ ]
2765
+
2766
+ def pending_groups(self) -> list[str]:
2767
+ """The groups with pending deliveries that can be handed over."""
2768
+ rows = self._all(
2769
+ "SELECT group_id FROM inbox WHERE state = 'pending' AND group_id IS NOT NULL "
2770
+ "AND delivery IS NOT NULL GROUP BY group_id ORDER BY MIN(seq)"
2771
+ )
2772
+ return [str(r[0]) for r in rows]
2773
+
2774
+ def mark_delivered(self, seq: int, adapter: str, targets: Sequence[str]) -> bool:
2775
+ """``adapter`` accepted inbox row ``seq``. Once every adapter of
2776
+ ``targets`` has, the row is done (and its plaintext goes); True then."""
2777
+ with self.tx():
2778
+ self._db.execute(
2779
+ "INSERT OR IGNORE INTO inbox_acks (inbox_seq, adapter) VALUES (?, ?)",
2780
+ (seq, adapter),
2781
+ )
2782
+ if self._missing_acks(seq, targets):
2783
+ return False
2784
+ self.set_inbox_state(seq, "done")
2785
+ return True
2786
+
2787
+ def delivered_to(self, seq: int, adapter: str) -> bool:
2788
+ """Whether ``adapter`` took inbox row ``seq``."""
2789
+ return not self._missing_acks(seq, [adapter])
2790
+
2791
+ def _missing_acks(self, seq: int, targets: Sequence[str]) -> bool:
2792
+ row = self._one(
2793
+ "SELECT COUNT(*) FROM json_each(?) j WHERE NOT EXISTS (SELECT 1 FROM inbox_acks a "
2794
+ "WHERE a.inbox_seq = ? AND a.adapter = j.value)",
2795
+ json.dumps(list(targets)),
2796
+ seq,
2797
+ )
2798
+ return row is not None and int(row[0]) > 0
2799
+
2800
+ def settle_deliveries(self, group_id: str, targets: Sequence[str]) -> int:
2801
+ """Mark done the group's pending rows every one of ``targets`` has
2802
+ taken (the group's adapters changed since). None with no target."""
2803
+ if not targets:
2804
+ return 0
2805
+ done = 0
2806
+ with self.tx():
2807
+ for row in self._all(
2808
+ "SELECT DISTINCT a.inbox_seq FROM inbox_acks a JOIN inbox i ON i.seq = a.inbox_seq "
2809
+ "WHERE i.group_id = ? AND i.state = 'pending'",
2810
+ group_id,
2811
+ ):
2812
+ if not self._missing_acks(int(row[0]), targets):
2813
+ self.set_inbox_state(int(row[0]), "done")
2814
+ done += 1
2815
+ return done
2816
+
2817
+ def set_inbox_state(self, seq: int, state: str) -> None:
2818
+ old = self._one(
2819
+ "SELECT state, group_id, LENGTH(CAST(delivery AS BLOB)) FROM inbox WHERE seq = ?", seq
2820
+ )
2821
+ if old is not None and old[1] is not None and old[2] is not None:
2822
+ if old[0] == "pending" and state != "pending":
2823
+ self._pending_add(str(old[1]), -int(old[2]))
2824
+ elif old[0] != "pending" and state == "pending":
2825
+ self._pending_add(str(old[1]), int(old[2]))
2826
+ # A delivered row no longer needs its plaintext, nor its adapters'
2827
+ # markers.
2828
+ if state in ("done", "dropped"):
2829
+ self._db.execute(
2830
+ "UPDATE inbox SET state = ?, delivery = NULL WHERE seq = ?", (state, seq)
2831
+ )
2832
+ self._db.execute("DELETE FROM inbox_acks WHERE inbox_seq = ?", (seq,))
2833
+ else:
2834
+ self._db.execute("UPDATE inbox SET state = ? WHERE seq = ?", (state, seq))
2835
+
2836
+ # -- events -------------------------------------------------------------
2837
+
2838
+ def add_event( # noqa: PLR0913
2839
+ self,
2840
+ group_id: str | None,
2841
+ kind: str,
2842
+ data: dict[str, Any],
2843
+ now: int,
2844
+ event_id: str,
2845
+ *,
2846
+ agent: bool = True,
2847
+ ) -> bool:
2848
+ """Record an event, not yet notified. ``False`` if an event with this
2849
+ id is already recorded (the same cause, regenerated after a crash).
2850
+ An event the agent may see (``agent``) also gets the next number of
2851
+ the agent's own sequence, so the events it may not see leave no gap
2852
+ in what it reads."""
2853
+ cur = self._db.execute(
2854
+ "INSERT OR IGNORE INTO events (group_id, type, data, at, event_id, agent_seq, "
2855
+ "notified) VALUES (?, ?, ?, ?, ?, CASE WHEN ? THEN "
2856
+ "(SELECT COALESCE(MAX(agent_seq), 0) + 1 FROM events) END, ?)",
2857
+ # An event the agent may not see is never for an adapter: it is
2858
+ # notified as it is recorded (SPEC 10.2), whatever crashes next.
2859
+ (group_id, kind, json.dumps(data), now, event_id, int(agent), int(not agent)),
2860
+ )
2861
+ return cur.rowcount == 1
2862
+
2863
+ def number_agent_events(self, hidden: frozenset[str]) -> None:
2864
+ """Once, for a database from before the agent's own sequence: number
2865
+ the events the agent may see (every type but ``hidden``) in order."""
2866
+ if self.kv_get("schema/agent_seq") == "1":
2867
+ return
2868
+ with self.tx():
2869
+ rows = self._all(
2870
+ "SELECT seq FROM events WHERE agent_seq IS NULL "
2871
+ "AND type NOT IN (SELECT value FROM json_each(?)) ORDER BY seq",
2872
+ json.dumps(sorted(hidden)),
2873
+ )
2874
+ start = self._one("SELECT COALESCE(MAX(agent_seq), 0) FROM events")
2875
+ n = int(start[0]) if start is not None else 0
2876
+ for (seq,) in rows:
2877
+ n += 1
2878
+ self._db.execute("UPDATE events SET agent_seq = ? WHERE seq = ?", (n, seq))
2879
+ self.kv_set("schema/agent_seq", "1")
2880
+
2881
+ def release_agent_events(self, group_id: str, hidden: frozenset[str]) -> list[dict[str, Any]]:
2882
+ """A group became the agent's to see: its events so far that the agent
2883
+ may see (every type but ``hidden``) get the next numbers of the
2884
+ agent's sequence, in order, and are to be told to the adapter again.
2885
+ Returns them."""
2886
+ rows = self._all(
2887
+ "SELECT seq FROM events WHERE group_id = ? AND agent_seq IS NULL "
2888
+ "AND type NOT IN (SELECT value FROM json_each(?)) ORDER BY seq",
2889
+ group_id,
2890
+ json.dumps(sorted(hidden)),
2891
+ )
2892
+ for (seq,) in rows:
2893
+ self._db.execute(
2894
+ "UPDATE events SET notified = 0, agent_seq = "
2895
+ "(SELECT COALESCE(MAX(agent_seq), 0) + 1 FROM events) WHERE seq = ?",
2896
+ (seq,),
2897
+ )
2898
+ self._db.execute(
2899
+ "DELETE FROM event_acks WHERE event_id = (SELECT event_id FROM events "
2900
+ "WHERE seq = ?)",
2901
+ (seq,),
2902
+ )
2903
+ out = self._all(
2904
+ "SELECT seq, group_id, type, data, at, event_id FROM events "
2905
+ "WHERE seq IN (SELECT value FROM json_each(?)) ORDER BY seq",
2906
+ json.dumps([int(r[0]) for r in rows]),
2907
+ )
2908
+ return [self._event(r) for r in out]
2909
+
2910
+ def agent_visible(self, event_id: str) -> bool:
2911
+ """Whether the agent may see this event (it has an agent sequence number)."""
2912
+ row = self._one("SELECT agent_seq FROM events WHERE event_id = ?", event_id)
2913
+ return row is not None and row[0] is not None
2914
+
2915
+ # -- SSE feed -----------------------------------------------------------
2916
+
2917
+ def feed_add(
2918
+ self,
2919
+ key: str,
2920
+ group_id: str | None,
2921
+ payload: dict[str, Any],
2922
+ now: int,
2923
+ keep: int = 10_000,
2924
+ ) -> int | None:
2925
+ """Append to the SSE feed; its ``seq`` is the SSE event id. ``None``
2926
+ if ``key`` (a ``msg_id`` or ``event_id``) is already there. Only the
2927
+ newest ``keep`` items are retained; ``now`` stamps the row for
2928
+ :meth:`feed_purge`."""
2929
+ cur = self._db.execute(
2930
+ "INSERT OR IGNORE INTO feed (key, group_id, payload, at) VALUES (?, ?, ?, ?)",
2931
+ (key, group_id, json.dumps(payload, sort_keys=True), now),
2932
+ )
2933
+ if cur.rowcount != 1:
2934
+ return None
2935
+ seq = int(cur.lastrowid or 0)
2936
+ self._feed_drop_through(seq - keep)
2937
+ return seq
2938
+
2939
+ def _feed_drop_through(self, seq: int) -> None:
2940
+ """Delete feed rows up to ``seq`` and remember it: a consumer resuming
2941
+ from before that point has missed items (:meth:`feed_purged`)."""
2942
+ row = self._one("SELECT MAX(seq) FROM feed WHERE seq <= ?", seq)
2943
+ if row is None or row[0] is None:
2944
+ return
2945
+ gone = int(row[0])
2946
+ self._db.execute("DELETE FROM feed WHERE seq <= ?", (gone,))
2947
+ if gone > self.feed_purged():
2948
+ self.kv_set("feed_purged", str(gone))
2949
+
2950
+ def feed_purge(self, before: int) -> int:
2951
+ """Delete every feed item stamped before ``before`` (Unix seconds).
2952
+ Returns how many went."""
2953
+ row = self._one("SELECT MAX(seq), COUNT(*) FROM feed WHERE at < ?", before)
2954
+ if row is None or row[0] is None:
2955
+ return 0
2956
+ self._feed_drop_through(int(row[0]))
2957
+ return int(row[1])
2958
+
2959
+ def feed_purged(self) -> int:
2960
+ """The highest ``seq`` ever removed from the feed (0: none)."""
2961
+ return int(self.kv_get("feed_purged") or "0")
2962
+
2963
+ def feed_since(
2964
+ self, after: int, group_id: str | None = None, limit: int = 10_000
2965
+ ) -> list[tuple[int, str | None, dict[str, Any]]]:
2966
+ rows = self._all(
2967
+ "SELECT seq, group_id, payload FROM feed WHERE seq > ? "
2968
+ "AND (? IS NULL OR group_id = ?) ORDER BY seq LIMIT ?",
2969
+ after,
2970
+ group_id,
2971
+ group_id,
2972
+ limit,
2973
+ )
2974
+ return [(int(r[0]), r[1], json.loads(r[2])) for r in rows]
2975
+
2976
+ def feed_last(self) -> int:
2977
+ row = self._one("SELECT seq FROM sqlite_sequence WHERE name = 'feed'")
2978
+ return 0 if row is None else int(row[0])
2979
+
2980
+ # -- in-process adapters (agentconnect.callback) --------------------------
2981
+
2982
+ def adapter_event_add(
2983
+ self,
2984
+ adapter: str,
2985
+ event_id: str,
2986
+ payload: dict[str, Any],
2987
+ keep: int = 10_000,
2988
+ *,
2989
+ addressed: bool = False,
2990
+ ) -> bool:
2991
+ """Queue an event for an in-process adapter; ``False`` if it is
2992
+ queued already. Only the newest ``keep`` per adapter are retained.
2993
+ ``addressed`` marks a message that addresses this agent (the
2994
+ delivery queue finds those directly)."""
2995
+ with self.tx() as db:
2996
+ cur = db.execute(
2997
+ "INSERT OR IGNORE INTO adapter_events "
2998
+ "(adapter, event_id, payload, group_id, addressed) VALUES (?, ?, ?, ?, ?)",
2999
+ (
3000
+ adapter,
3001
+ event_id,
3002
+ json.dumps(payload, sort_keys=True),
3003
+ _payload_group(payload),
3004
+ int(addressed),
3005
+ ),
3006
+ )
3007
+ db.execute(
3008
+ "DELETE FROM adapter_events WHERE adapter = ? AND seq NOT IN "
3009
+ "(SELECT seq FROM adapter_events WHERE adapter = ? ORDER BY seq DESC LIMIT ?)",
3010
+ (adapter, adapter, keep),
3011
+ )
3012
+ return cur.rowcount == 1
3013
+
3014
+ def adapter_events(
3015
+ self, adapter: str, group_id: str | None = None, limit: int = 100
3016
+ ) -> list[tuple[int, dict[str, Any]]]:
3017
+ """Queued events of ``adapter`` for ``group_id`` (``None``: events
3018
+ about no group), oldest first, at most ``limit``."""
3019
+ rows = self._all(
3020
+ "SELECT seq, payload FROM adapter_events WHERE adapter = ? AND group_id IS ? "
3021
+ "ORDER BY seq LIMIT ?",
3022
+ adapter,
3023
+ group_id,
3024
+ limit,
3025
+ )
3026
+ return [(int(r[0]), json.loads(r[1])) for r in rows]
3027
+
3028
+ def adapter_event_groups(self, adapter: str) -> list[str | None]:
3029
+ """The groups (``None``: no group) with queued events for ``adapter``."""
3030
+ rows = self._all(
3031
+ "SELECT group_id FROM adapter_events WHERE adapter = ? "
3032
+ "GROUP BY group_id ORDER BY MIN(seq)",
3033
+ adapter,
3034
+ )
3035
+ return [None if r[0] is None else str(r[0]) for r in rows]
3036
+
3037
+ def adapter_event_done(self, seq: int) -> None:
3038
+ self._db.execute("DELETE FROM adapter_events WHERE seq = ?", (seq,))
3039
+
3040
+ def queue_rows( # noqa: PLR0913
3041
+ self,
3042
+ adapter: str,
3043
+ *,
3044
+ group_id: str | None,
3045
+ skip: Sequence[int],
3046
+ limit: int,
3047
+ addressed: bool = False,
3048
+ before: int | None = None,
3049
+ ) -> list[tuple[int, dict[str, Any]]]:
3050
+ """Queued items of ``adapter`` (the delivery queue's), oldest first,
3051
+ without the ``skip`` seqs: of ``group_id`` (``None``: every group),
3052
+ only addressed ones with ``addressed``. With ``before``, the newest
3053
+ ``limit`` older than that seq instead, still oldest first."""
3054
+ args = (adapter, group_id, group_id, json.dumps(list(skip)), int(addressed))
3055
+ if before is None:
3056
+ rows = self._all(_QUEUE_ROWS + "ORDER BY seq LIMIT ?", *args, None, None, limit)
3057
+ else:
3058
+ rows = self._all(
3059
+ _QUEUE_ROWS + "ORDER BY seq DESC LIMIT ?", *args, before, before, limit
3060
+ )
3061
+ rows.reverse()
3062
+ return [(int(r[0]), json.loads(r[1])) for r in rows]
3063
+
3064
+ def adapter_event_finish(self, adapter: str, seq: int) -> bool:
3065
+ """Take one queued item of ``adapter`` off the queue and remember its
3066
+ id as handled, in one transaction; ``False`` if it isn't queued."""
3067
+ with self.tx() as db:
3068
+ row = db.execute(
3069
+ "SELECT event_id FROM adapter_events WHERE seq = ? AND adapter = ?", (seq, adapter)
3070
+ ).fetchone()
3071
+ if row is None:
3072
+ return False
3073
+ db.execute("DELETE FROM adapter_events WHERE seq = ?", (seq,))
3074
+ self.handled_add(str(row[0]), HANDLED_KEEP)
3075
+ return True
3076
+
3077
+ # -- ids an in-process agent's handlers finished with ---------------------
3078
+
3079
+ def handled_has(self, key: str) -> bool:
3080
+ return self._one("SELECT 1 FROM handled WHERE key = ?", key) is not None
3081
+
3082
+ def handled_add(self, key: str, keep: int) -> None:
3083
+ """Remember ``key``; only the newest ``keep`` keys are retained."""
3084
+ with self.tx() as db:
3085
+ cur = db.execute("INSERT OR IGNORE INTO handled (key) VALUES (?)", (key,))
3086
+ if cur.rowcount == 1:
3087
+ db.execute("DELETE FROM handled WHERE seq <= ?", (int(cur.lastrowid or 0) - keep,))
3088
+
3089
+ def unnotified_events(self) -> list[dict[str, Any]]:
3090
+ """Recorded events the adapter has not accepted yet, oldest first."""
3091
+ rows = self._all(
3092
+ "SELECT seq, group_id, type, data, at, event_id FROM events "
3093
+ "WHERE notified = 0 ORDER BY seq"
3094
+ )
3095
+ return [self._event(r) for r in rows]
3096
+
3097
+ def event_notified(self, event_id: str) -> bool:
3098
+ row = self._one("SELECT notified FROM events WHERE event_id = ?", event_id)
3099
+ return row is not None and bool(row[0])
3100
+
3101
+ def mark_notified(self, event_id: str) -> None:
3102
+ """Every adapter has the event: its per-adapter markers go."""
3103
+ with self.tx():
3104
+ self._db.execute("UPDATE events SET notified = 1 WHERE event_id = ?", (event_id,))
3105
+ self._db.execute("DELETE FROM event_acks WHERE event_id = ?", (event_id,))
3106
+
3107
+ def events_purge(self, before: int, keep: int) -> None:
3108
+ """Retention: events stamped before ``before`` that every adapter
3109
+ has, human-only ones (no agent number) only once the human read them
3110
+ (:meth:`events_read`), and any beyond the newest ``keep``, read or
3111
+ not. The event holding the agent's highest number stays, so the
3112
+ agent's sequence never runs backwards. The highest number gone, in
3113
+ each sequence, is kept (:meth:`events_purged`)."""
3114
+ gone = (
3115
+ "((at < ? AND notified = 1 AND (agent_seq IS NOT NULL OR seq <= ?)) OR seq <= "
3116
+ "(SELECT seq FROM events ORDER BY seq DESC LIMIT 1 OFFSET ?)) "
3117
+ "AND (agent_seq IS NULL OR agent_seq < (SELECT MAX(agent_seq) FROM events))"
3118
+ )
3119
+ args = (before, self._human_read(), keep)
3120
+ with self.tx():
3121
+ row = self._one(f"SELECT MAX(seq), MAX(agent_seq) FROM events WHERE {gone}", *args) # noqa: S608 - constant
3122
+ if row is None or row[0] is None:
3123
+ return
3124
+ seq, agent_seq = self.events_purged()
3125
+ self.kv_set("events/purged", str(max(seq, int(row[0]))))
3126
+ if row[1] is not None:
3127
+ self.kv_set("events/purged_agent", str(max(agent_seq, int(row[1]))))
3128
+ self._db.execute(f"DELETE FROM events WHERE {gone}", args) # noqa: S608 - constant
3129
+ self._db.execute(
3130
+ "DELETE FROM event_acks WHERE event_id NOT IN "
3131
+ "(SELECT event_id FROM events WHERE event_id IS NOT NULL)"
3132
+ )
3133
+
3134
+ def events_purged(self) -> tuple[int, int]:
3135
+ """The highest ``seq`` and the highest agent number retention removed
3136
+ (0: none): a reader asking for events after less than that missed
3137
+ some."""
3138
+ return (
3139
+ int(self.kv_get("events/purged") or "0"),
3140
+ int(self.kv_get("events/purged_agent") or "0"),
3141
+ )
3142
+
3143
+ def events_read(self, seq: int) -> None:
3144
+ """The human has read every event up to ``seq`` (it never moves back)."""
3145
+ if seq > self._human_read():
3146
+ self.kv_set("events/human_read", str(seq))
3147
+
3148
+ def _human_read(self) -> int:
3149
+ return int(self.kv_get("events/human_read") or "0")
3150
+
3151
+ # -- attempts -------------------------------------------------------------
3152
+
3153
+ def attempt(self, key: str, now: int) -> int:
3154
+ """Count one more handling of the message ``key``; returns the
3155
+ count, which survives restarts."""
3156
+ with self.tx():
3157
+ self._db.execute(
3158
+ "INSERT INTO attempts (key, n, at) VALUES (?, 1, ?) "
3159
+ "ON CONFLICT(key) DO UPDATE SET n = n + 1, at = excluded.at",
3160
+ (key, now),
3161
+ )
3162
+ row = self._one("SELECT n FROM attempts WHERE key = ?", key)
3163
+ return 0 if row is None else int(row[0])
3164
+
3165
+ def attempt_count(self, key: str) -> int:
3166
+ """The give-ups counted for the message ``key`` (0: none)."""
3167
+ row = self._one("SELECT n FROM attempts WHERE key = ?", key)
3168
+ return 0 if row is None else int(row[0])
3169
+
3170
+ def attempt_clear(self, key: str) -> None:
3171
+ """The message ``key`` is handled: its count goes."""
3172
+ with self.tx():
3173
+ self._db.execute("DELETE FROM attempts WHERE key = ?", (key,))
3174
+
3175
+ def attempts_purge(self, before: int) -> None:
3176
+ """Forget the counts last bumped before ``before``."""
3177
+ with self.tx():
3178
+ self._db.execute("DELETE FROM attempts WHERE at < ?", (before,))
3179
+
3180
+ def unnotified_groups(self) -> list[str | None]:
3181
+ """The groups (``None``: events of no group) with events not every
3182
+ adapter has yet."""
3183
+ return [
3184
+ r[0]
3185
+ for r in self._all(
3186
+ "SELECT DISTINCT group_id FROM events WHERE notified = 0 AND agent_seq IS NOT NULL"
3187
+ )
3188
+ ]
3189
+
3190
+ def settle_unseen_events(self) -> int:
3191
+ """Mark notified the events the agent may not see that an older
3192
+ database left unnotified: no adapter is ever told of them."""
3193
+ cur = self._db.execute(
3194
+ "UPDATE events SET notified = 1 WHERE notified = 0 AND agent_seq IS NULL"
3195
+ )
3196
+ return int(cur.rowcount)
3197
+
3198
+ def unnotified_for(
3199
+ self, group_id: str | None, adapter: str, limit: int = 100
3200
+ ) -> list[dict[str, Any]]:
3201
+ """The group's events ``adapter`` hasn't accepted yet, oldest first."""
3202
+ rows = self._all(
3203
+ "SELECT seq, group_id, type, data, at, event_id FROM events e WHERE notified = 0 "
3204
+ "AND agent_seq IS NOT NULL AND group_id IS ? "
3205
+ "AND NOT EXISTS (SELECT 1 FROM event_acks a "
3206
+ "WHERE a.event_id = e.event_id AND a.adapter = ?) ORDER BY seq LIMIT ?",
3207
+ group_id,
3208
+ adapter,
3209
+ limit,
3210
+ )
3211
+ return [self._event(r) for r in rows]
3212
+
3213
+ def settle_events(self, group_id: str | None, targets: Sequence[str]) -> int:
3214
+ """Mark notified the group's events every one of ``targets`` has
3215
+ (with no target: all of them, as there is nobody to tell)."""
3216
+ n = 0
3217
+ with self.tx():
3218
+ for (event_id,) in self._all(
3219
+ "SELECT event_id FROM events WHERE notified = 0 AND group_id IS ?", group_id
3220
+ ):
3221
+ if all(self.event_marked(str(event_id), t) for t in targets):
3222
+ self.mark_notified(str(event_id))
3223
+ n += 1
3224
+ return n
3225
+
3226
+ def event_marked(self, event_id: str, adapter: str) -> bool:
3227
+ """Whether ``adapter`` accepted this event already."""
3228
+ row = self._one(
3229
+ "SELECT 1 FROM event_acks WHERE event_id = ? AND adapter = ?", event_id, adapter
3230
+ )
3231
+ return row is not None
3232
+
3233
+ def mark_event(self, event_id: str, adapter: str) -> None:
3234
+ self._db.execute(
3235
+ "INSERT OR IGNORE INTO event_acks (event_id, adapter) VALUES (?, ?)",
3236
+ (event_id, adapter),
3237
+ )
3238
+
3239
+ def set_group_adapters(self, group_id: str, adapters: list[str] | None) -> None:
3240
+ """Pin the group's adapters (``None``: follow the config)."""
3241
+ self.update_group(group_id, adapters=adapters)
3242
+
3243
+ @staticmethod
3244
+ def _event(r: tuple[Any, ...]) -> dict[str, Any]:
3245
+ return {
3246
+ "seq": r[0],
3247
+ "group_id": r[1],
3248
+ "type": r[2],
3249
+ "data": json.loads(r[3]),
3250
+ "at": r[4],
3251
+ "event_id": r[5],
3252
+ }
3253
+
3254
+ def events(
3255
+ self, after: int = 0, limit: int = 200, group_id: str | None = None
3256
+ ) -> list[dict[str, Any]]:
3257
+ """Every event after ``after`` (the human's view), oldest first."""
3258
+ rows = self._all(
3259
+ "SELECT seq, group_id, type, data, at, event_id FROM events WHERE seq > ? "
3260
+ "AND (? IS NULL OR group_id = ?) ORDER BY seq LIMIT ?",
3261
+ after,
3262
+ group_id,
3263
+ group_id,
3264
+ limit,
3265
+ )
3266
+ return [self._event(r) for r in rows]
3267
+
3268
+ def agent_events(
3269
+ self, after: int = 0, limit: int = 200, group_id: str | None = None
3270
+ ) -> list[dict[str, Any]]:
3271
+ """The events the agent may see, by the agent's own sequence: ``seq``
3272
+ is that number, gap-free, so nothing tells it how many events it
3273
+ doesn't see."""
3274
+ rows = self._all(
3275
+ "SELECT agent_seq, group_id, type, data, at, event_id FROM events "
3276
+ "WHERE agent_seq > ? AND (? IS NULL OR group_id = ?) ORDER BY agent_seq LIMIT ?",
3277
+ after,
3278
+ group_id,
3279
+ group_id,
3280
+ limit,
3281
+ )
3282
+ return [self._event(r) for r in rows]