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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- 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]
|