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
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Floor control in ``moderated`` groups (SPEC 7.3, 9.3; docs/groups.md).
|
|
2
|
+
|
|
3
|
+
In a ``moderated`` group the instances of the **floor holder** and of the
|
|
4
|
+
**effective moderator** are triggered (:func:`agentconnect.runtime.engine.
|
|
5
|
+
addressed_to_me`). The effective moderator is ``floor_moderator`` while it is
|
|
6
|
+
a member, otherwise the owner.
|
|
7
|
+
|
|
8
|
+
**A register every member computes the same.** The holder is the result of
|
|
9
|
+
folding the valid writes in key order ``(epoch, counter, sender identity,
|
|
10
|
+
sender instance)``. Members that have seen the same writes have the same
|
|
11
|
+
holder, whatever order the writes reached them in:
|
|
12
|
+
|
|
13
|
+
* **Grants.** ``control.floor{holder, counter}``; ``counter`` orders the
|
|
14
|
+
grants of one epoch and starts again at each epoch (at most
|
|
15
|
+
:data:`~agentconnect.schemas.MAX_FLOOR_COUNTER`). The grant's epoch is the
|
|
16
|
+
MLS epoch it was sent in, and it is judged against the **floor facts of
|
|
17
|
+
that epoch** (the mode, the owner, the effective moderator, the member
|
|
18
|
+
identities), never against the receiver's current view. Each member keeps
|
|
19
|
+
the facts of the epochs whose messages it can still decrypt (the current
|
|
20
|
+
one and :data:`PAST_EPOCHS` before it); a grant from an epoch with no
|
|
21
|
+
facts is ignored, the same way everywhere. A valid grant is a write
|
|
22
|
+
whatever this member's own delivery policy decides for the message
|
|
23
|
+
(:meth:`FloorService.accept`). The sender takes its own grant as it seals
|
|
24
|
+
it (:meth:`FloorService.seal`), in the transaction that stores the
|
|
25
|
+
envelope, and keeps that epoch until the relay has taken it.
|
|
26
|
+
* **Resets.** A commit that changes ``floor`` or ``floor_moderator`` writes
|
|
27
|
+
nobody at ``(new epoch, 0)``; a commit after which an identity has no
|
|
28
|
+
instance writes "drop it" at ``(new epoch, 0)``, which clears the holder if
|
|
29
|
+
it names that identity at that point of the order. A grant from before the
|
|
30
|
+
commit loses to them however late it arrives.
|
|
31
|
+
* **Copies.** After a commit that adds instances, one instance (the copier)
|
|
32
|
+
sends the newcomers the current value with the key of the write that set
|
|
33
|
+
it (``copy``, numbered by ``seq`` for that join), and sends it again
|
|
34
|
+
whenever a write from before their epoch, which they can't decrypt,
|
|
35
|
+
changes it, nobody included. A copy is a snapshot: the instances that
|
|
36
|
+
joined in its epoch fold their own writes over the latest one, and
|
|
37
|
+
everyone else ignores it.
|
|
38
|
+
|
|
39
|
+
Any other grant is dropped silently. Writes older than the decryptable
|
|
40
|
+
epochs are folded into a base value, and of one epoch's grants only the
|
|
41
|
+
greatest is kept. Each change is reported as ``floor_changed``.
|
|
42
|
+
|
|
43
|
+
Members still disagree when the relay drops a grant (SPEC 12); that changes
|
|
44
|
+
only who is triggered.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import json
|
|
50
|
+
from collections.abc import Iterable
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from agentconnect import log
|
|
54
|
+
from agentconnect.relay import unb64
|
|
55
|
+
from agentconnect.runtime.engine import DaemonError, Engine
|
|
56
|
+
from agentconnect.schemas import MAX_FLOOR_COUNTER, normalize
|
|
57
|
+
|
|
58
|
+
LOG = log.get("agentconnect.floor")
|
|
59
|
+
|
|
60
|
+
FLOOR_KIND = "control.floor"
|
|
61
|
+
# Past epochs whose application messages members can still decrypt (the
|
|
62
|
+
# core's MAX_PAST_EPOCHS, the relay's one-epoch grace window, SPEC 8.3).
|
|
63
|
+
PAST_EPOCHS = 1
|
|
64
|
+
|
|
65
|
+
Key = tuple[int, int, str, str] # (epoch, counter, sender identity, sender instance)
|
|
66
|
+
Write = tuple[int, int, str, str, str, str | None] # key, kind, holder
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def effective_moderator(policy: dict[str, Any], identities: Iterable[str]) -> str | None:
|
|
70
|
+
"""``floor_moderator`` while it is a member, otherwise the owner (SPEC 9.3)."""
|
|
71
|
+
moderator = policy.get("floor_moderator")
|
|
72
|
+
if isinstance(moderator, str) and moderator in set(identities):
|
|
73
|
+
return moderator
|
|
74
|
+
owner = policy.get("owner")
|
|
75
|
+
return owner if isinstance(owner, str) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def facts_of(policy: dict[str, Any], identities: Iterable[str]) -> dict[str, Any]:
|
|
79
|
+
"""The floor facts of an epoch: what a grant sent in it is judged on."""
|
|
80
|
+
members = sorted(set(identities))
|
|
81
|
+
return {
|
|
82
|
+
"mode": policy.get("floor"),
|
|
83
|
+
"moderator": policy.get("floor_moderator"),
|
|
84
|
+
"owner": policy.get("owner"),
|
|
85
|
+
"effective": effective_moderator(policy, members),
|
|
86
|
+
"identities": members,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def may_grant(facts: dict[str, Any], identity: str) -> bool:
|
|
91
|
+
"""Whether ``identity`` may grant under ``facts``: the floor is
|
|
92
|
+
``moderated`` and it is the effective moderator or the owner (SPEC 7.3)."""
|
|
93
|
+
return bool(
|
|
94
|
+
facts.get("mode") == "moderated" and identity in (facts["effective"], facts["owner"])
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def floor_write(
|
|
99
|
+
facts: dict[str, Any] | None, epoch: int, sender: str, instance: str, body: dict[str, Any]
|
|
100
|
+
) -> tuple[Key, str | None] | None:
|
|
101
|
+
"""The register write a ``control.floor`` sent in ``epoch`` makes, judged
|
|
102
|
+
on that epoch's ``facts``: ``(key, holder)``, or ``None`` if it is not
|
|
103
|
+
valid. ``body`` is in its schema's shape."""
|
|
104
|
+
if facts is None or not may_grant(facts, sender):
|
|
105
|
+
return None
|
|
106
|
+
holder = _opt_str(body.get("holder"))
|
|
107
|
+
if holder is not None and holder not in facts["identities"]:
|
|
108
|
+
return None
|
|
109
|
+
counter = int(body["counter"])
|
|
110
|
+
copy = body.get("copy")
|
|
111
|
+
if copy is None:
|
|
112
|
+
return (epoch, counter, sender, instance), holder
|
|
113
|
+
if int(copy["epoch"]) > epoch:
|
|
114
|
+
return None
|
|
115
|
+
return (int(copy["epoch"]), counter, str(copy["sender"]), str(copy["instance"])), holder
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fold(
|
|
119
|
+
holder: str | None, source: Key | None, writes: Iterable[Write]
|
|
120
|
+
) -> tuple[str | None, Key | None]:
|
|
121
|
+
"""The register after ``writes`` (in key order) over a base value: the
|
|
122
|
+
holder and the key of the write that last set it. A write at or below the
|
|
123
|
+
base's key is part of the base already."""
|
|
124
|
+
for epoch, counter, sender, instance, kind, value in writes:
|
|
125
|
+
if source is not None and (epoch, counter, sender, instance) <= source:
|
|
126
|
+
continue
|
|
127
|
+
if kind in {"grant", "reset"} or (kind == "drop" and holder == value):
|
|
128
|
+
holder = value if kind == "grant" else None
|
|
129
|
+
source = (epoch, counter, sender, instance)
|
|
130
|
+
return holder, source
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def floor_event(facts: dict[str, Any], holder: str | None, by: str, reason: str) -> dict[str, Any]:
|
|
134
|
+
"""The data of a ``floor_changed`` event."""
|
|
135
|
+
return {
|
|
136
|
+
"mode": facts["mode"],
|
|
137
|
+
"moderator": facts["moderator"],
|
|
138
|
+
"effective_moderator": facts["effective"],
|
|
139
|
+
"holder": holder,
|
|
140
|
+
"by": by,
|
|
141
|
+
"reason": reason,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _opt_str(value: Any) -> str | None:
|
|
146
|
+
return value if isinstance(value, str) else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _key(raw: Any) -> Key | None:
|
|
150
|
+
return None if raw is None else (int(raw[0]), int(raw[1]), str(raw[2]), str(raw[3]))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class FloorService:
|
|
154
|
+
"""The floor register of each group of one :class:`Engine`."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, engine: Engine) -> None:
|
|
157
|
+
self._engine = engine
|
|
158
|
+
|
|
159
|
+
# -- state ----------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _now_facts(self, group_id: str) -> dict[str, Any]:
|
|
162
|
+
client = self._engine.client
|
|
163
|
+
gid = unb64(group_id)
|
|
164
|
+
policy = dict(json.loads(client.policy(gid)))
|
|
165
|
+
return facts_of(policy, (str(m["identity"]) for m in client.members(gid)))
|
|
166
|
+
|
|
167
|
+
def _epoch(self, group_id: str) -> int:
|
|
168
|
+
return int(self._engine.client.epoch(unb64(group_id)))
|
|
169
|
+
|
|
170
|
+
def _load(self, group_id: str) -> tuple[str | None, dict[str, Any]]:
|
|
171
|
+
"""The holder and the register state; a group without a row (just
|
|
172
|
+
created or joined, or from before floor control) starts with nobody,
|
|
173
|
+
and the facts of its current epoch."""
|
|
174
|
+
row = self._engine.store.floor(group_id)
|
|
175
|
+
if row is not None:
|
|
176
|
+
holder, state = row
|
|
177
|
+
for field in ("counters", "pending", "joins"):
|
|
178
|
+
state.setdefault(field, {})
|
|
179
|
+
return holder, state
|
|
180
|
+
facts = self._now_facts(group_id)
|
|
181
|
+
state = {
|
|
182
|
+
"counters": {},
|
|
183
|
+
"pending": {},
|
|
184
|
+
"joins": {},
|
|
185
|
+
"base": None,
|
|
186
|
+
"bound": None,
|
|
187
|
+
"source": None,
|
|
188
|
+
"facts": {str(self._epoch(group_id)): facts},
|
|
189
|
+
"reported": {
|
|
190
|
+
"mode": facts["mode"],
|
|
191
|
+
"moderator": facts["moderator"],
|
|
192
|
+
"effective": facts["effective"],
|
|
193
|
+
"holder": None,
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
return None, state
|
|
197
|
+
|
|
198
|
+
def _facts(self, group_id: str, state: dict[str, Any], epoch: int) -> dict[str, Any] | None:
|
|
199
|
+
"""The facts of ``epoch``, if kept. The current epoch's are taken
|
|
200
|
+
from the group when missing (a group from before floor control)."""
|
|
201
|
+
facts = state["facts"].get(str(epoch))
|
|
202
|
+
if facts is None and epoch == self._epoch(group_id):
|
|
203
|
+
facts = state["facts"][str(epoch)] = self._now_facts(group_id)
|
|
204
|
+
return facts
|
|
205
|
+
|
|
206
|
+
def ensure(self, group_id: str) -> None:
|
|
207
|
+
"""Give a group that has no floor row one, with nobody holding the
|
|
208
|
+
floor: a group this runtime created or joined starts so."""
|
|
209
|
+
if self._engine.store.floor(group_id) is None:
|
|
210
|
+
holder, state = self._load(group_id)
|
|
211
|
+
self._engine.store.set_floor(group_id, holder, state)
|
|
212
|
+
|
|
213
|
+
def holder(self, group_id: str) -> str | None:
|
|
214
|
+
row = self._engine.store.floor(group_id)
|
|
215
|
+
return None if row is None else row[0]
|
|
216
|
+
|
|
217
|
+
def view(self, group_id: str, policy: dict[str, Any], active: bool) -> dict[str, Any]:
|
|
218
|
+
"""The group view's ``floor``: the policy's mode, pin and moderator,
|
|
219
|
+
the effective moderator and the holder (both ``None`` for a group
|
|
220
|
+
that isn't active)."""
|
|
221
|
+
return {
|
|
222
|
+
"mode": policy.get("floor"),
|
|
223
|
+
"pinned": bool(policy.get("floor_pinned")),
|
|
224
|
+
"moderator": policy.get("floor_moderator"),
|
|
225
|
+
"effective_moderator": self._now_facts(group_id)["effective"] if active else None,
|
|
226
|
+
"holder": self.holder(group_id) if active else None,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def triggers(self, group_id: str, policy: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
230
|
+
"""``(effective moderator, holder)``: whom ``moderated`` triggers."""
|
|
231
|
+
if policy.get("floor") != "moderated":
|
|
232
|
+
return None, None
|
|
233
|
+
return _opt_str(self._now_facts(group_id)["effective"]), self.holder(group_id)
|
|
234
|
+
|
|
235
|
+
def _fold(self, group_id: str, state: dict[str, Any]) -> str | None:
|
|
236
|
+
base = state["base"] or {"holder": None, "source": None}
|
|
237
|
+
holder, source = fold(
|
|
238
|
+
base["holder"], _key(base["source"]), self._engine.store.floor_writes(group_id)
|
|
239
|
+
)
|
|
240
|
+
state["source"] = None if source is None else list(source)
|
|
241
|
+
return holder
|
|
242
|
+
|
|
243
|
+
def _settled(self, group_id: str, state: dict[str, Any]) -> tuple[str | None, Key | None]:
|
|
244
|
+
"""The register without this instance's own pending grants, which may
|
|
245
|
+
still be taken back: what a copy carries, so a copy never spreads a
|
|
246
|
+
grant that never went out."""
|
|
247
|
+
pending = {tuple(k) for k in state["pending"].values()}
|
|
248
|
+
base = state["base"] or {"holder": None, "source": None}
|
|
249
|
+
writes = [w for w in self._engine.store.floor_writes(group_id) if w[:4] not in pending]
|
|
250
|
+
return fold(base["holder"], _key(base["source"]), writes)
|
|
251
|
+
|
|
252
|
+
def _merge(
|
|
253
|
+
self, group_id: str, state: dict[str, Any], key: Key, kind: str, value: str | None
|
|
254
|
+
) -> str | None:
|
|
255
|
+
"""Keep a write and return the register's holder after it. A write
|
|
256
|
+
older than what was folded away is not kept. Of the grants of one
|
|
257
|
+
epoch only the greatest is kept: it alone decides the fold, so every
|
|
258
|
+
member keeps the same set whatever order the grants came in."""
|
|
259
|
+
store = self._engine.store
|
|
260
|
+
bound = _key(state["bound"])
|
|
261
|
+
if bound is not None and key <= bound:
|
|
262
|
+
return self._fold(group_id, state)
|
|
263
|
+
store.add_floor_write(group_id, key, kind, value)
|
|
264
|
+
counters = state["counters"]
|
|
265
|
+
counters[str(key[0])] = max(int(counters.get(str(key[0]), 0)), key[1])
|
|
266
|
+
if kind == "grant":
|
|
267
|
+
self._prune(group_id, state, key[0])
|
|
268
|
+
return self._fold(group_id, state)
|
|
269
|
+
|
|
270
|
+
def _prune(self, group_id: str, state: dict[str, Any], epoch: int) -> None:
|
|
271
|
+
"""Keep, of an epoch's grants, the greatest, the greatest that isn't
|
|
272
|
+
this instance's own pending one (which may still be taken back) and
|
|
273
|
+
the pending ones: the rest can never decide the fold again."""
|
|
274
|
+
store = self._engine.store
|
|
275
|
+
pending = {tuple(k) for k in state["pending"].values()}
|
|
276
|
+
grants = [w[:4] for w in store.floor_writes(group_id) if w[0] == epoch and w[4] == "grant"]
|
|
277
|
+
keep = set(pending)
|
|
278
|
+
if grants:
|
|
279
|
+
keep.add(max(grants))
|
|
280
|
+
final = [g for g in grants if g not in pending]
|
|
281
|
+
if final:
|
|
282
|
+
keep.add(max(final))
|
|
283
|
+
for g in grants:
|
|
284
|
+
if g not in keep:
|
|
285
|
+
store.delete_floor_write(group_id, g)
|
|
286
|
+
|
|
287
|
+
def _report(
|
|
288
|
+
self, state: dict[str, Any], facts: dict[str, Any], holder: str | None, by: str, reason: str
|
|
289
|
+
) -> dict[str, Any] | None:
|
|
290
|
+
"""``floor_changed`` data when the holder, mode, moderator or effective
|
|
291
|
+
moderator differs from what was last reported."""
|
|
292
|
+
now = {**{k: facts[k] for k in ("mode", "moderator", "effective")}, "holder": holder}
|
|
293
|
+
if state["reported"] == now:
|
|
294
|
+
return None
|
|
295
|
+
state["reported"] = now
|
|
296
|
+
return floor_event(facts, holder, by, reason)
|
|
297
|
+
|
|
298
|
+
# -- grants -----------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def judge(
|
|
301
|
+
self, group_id: str, epoch: int, sender: str, instance: str, inner: dict[str, Any]
|
|
302
|
+
) -> tuple[Key, str | None] | None:
|
|
303
|
+
"""The write a ``control.floor`` sent in ``epoch`` makes, or ``None``
|
|
304
|
+
when it isn't valid (see the module docs)."""
|
|
305
|
+
_, state = self._load(group_id)
|
|
306
|
+
return self._judge(group_id, state, epoch, sender, instance, inner)
|
|
307
|
+
|
|
308
|
+
def _judge( # noqa: PLR0913, PLR0917
|
|
309
|
+
self,
|
|
310
|
+
group_id: str,
|
|
311
|
+
state: dict[str, Any],
|
|
312
|
+
epoch: int,
|
|
313
|
+
sender: str,
|
|
314
|
+
instance: str,
|
|
315
|
+
inner: dict[str, Any],
|
|
316
|
+
) -> tuple[Key, str | None] | None:
|
|
317
|
+
body = inner.get("body")
|
|
318
|
+
shaped = normalize(FLOOR_KIND, body) if isinstance(body, dict) else None
|
|
319
|
+
if shaped is None:
|
|
320
|
+
return None
|
|
321
|
+
return floor_write(self._facts(group_id, state, epoch), epoch, sender, instance, shaped)
|
|
322
|
+
|
|
323
|
+
def _recopy(
|
|
324
|
+
self,
|
|
325
|
+
group_id: str,
|
|
326
|
+
state: dict[str, Any],
|
|
327
|
+
settled: tuple[str | None, Key | None],
|
|
328
|
+
key: Key,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""After a write at ``key`` that was ``settled`` before: if the
|
|
331
|
+
settled value changed, tell the instances that joined in a later
|
|
332
|
+
epoch, which can't decrypt the write, if this instance is the one
|
|
333
|
+
that copies to them (SPEC 9.3)."""
|
|
334
|
+
now = self._settled(group_id, state)
|
|
335
|
+
if now == settled:
|
|
336
|
+
return
|
|
337
|
+
for epoch, join in sorted(state["joins"].items()):
|
|
338
|
+
if key[0] < int(epoch) and join["copier"]:
|
|
339
|
+
self._send_copy(group_id, state, now, int(epoch))
|
|
340
|
+
|
|
341
|
+
def _changed(
|
|
342
|
+
self, group_id: str, state: dict[str, Any], holder: str | None, key: Key
|
|
343
|
+
) -> dict[str, Any] | None:
|
|
344
|
+
"""The ``floor_changed`` data after a grant at ``key``."""
|
|
345
|
+
by = key[2] or self._engine.me
|
|
346
|
+
return self._report(state, self._now_facts(group_id), holder, by, "grant")
|
|
347
|
+
|
|
348
|
+
async def accept(
|
|
349
|
+
self, group_id: str, epoch: int, sender: str, instance: str, inner: dict[str, Any]
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Take a received grant (inside its message's transaction), whatever
|
|
352
|
+
the local delivery policy decided for the message: a rate or a size
|
|
353
|
+
limit is the receiver's own, and the register must not depend on it."""
|
|
354
|
+
_, state = self._load(group_id)
|
|
355
|
+
write = self._judge(group_id, state, epoch, sender, instance, inner)
|
|
356
|
+
if write is None:
|
|
357
|
+
return
|
|
358
|
+
key, value = write
|
|
359
|
+
copy = (normalize(FLOOR_KIND, inner["body"]) or {}).get("copy")
|
|
360
|
+
if copy is not None:
|
|
361
|
+
await self._snapshot(group_id, state, copy, (key, value), (sender, instance))
|
|
362
|
+
return
|
|
363
|
+
settled = self._settled(group_id, state)
|
|
364
|
+
holder = self._merge(group_id, state, key, "grant", value)
|
|
365
|
+
self._recopy(group_id, state, settled, key)
|
|
366
|
+
data = self._changed(group_id, state, holder, key)
|
|
367
|
+
self._engine.store.set_floor(group_id, holder, state)
|
|
368
|
+
if data is not None:
|
|
369
|
+
LOG.info("floor_changed", group=log.hid(group_id), reason="grant")
|
|
370
|
+
await self._engine.emit(group_id, "floor_changed", **data)
|
|
371
|
+
|
|
372
|
+
def _join_epoch(self, group_id: str) -> int | None:
|
|
373
|
+
"""The epoch this instance joined the group in (first seen, G8)."""
|
|
374
|
+
mailbox = self._engine.mailbox
|
|
375
|
+
for m in self._engine.store.members(group_id):
|
|
376
|
+
for i in m.instances:
|
|
377
|
+
if i.get("mailbox_id") == mailbox:
|
|
378
|
+
return int(i.get("joined_epoch", 0))
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
async def _snapshot(
|
|
382
|
+
self,
|
|
383
|
+
group_id: str,
|
|
384
|
+
state: dict[str, Any],
|
|
385
|
+
copy: dict[str, Any],
|
|
386
|
+
write: tuple[Key, str | None],
|
|
387
|
+
sender: tuple[str, str],
|
|
388
|
+
) -> None:
|
|
389
|
+
"""A copy (SPEC 9.3) is a snapshot for the instances that joined in
|
|
390
|
+
its ``join`` epoch, which can't decrypt what came before: the latest
|
|
391
|
+
one (by ``seq``, then sender) replaces the base the register folds
|
|
392
|
+
from. Everyone else holds the real writes and ignores it. Once this
|
|
393
|
+
instance has folded writes of its own away, its base is its own."""
|
|
394
|
+
if int(copy["join"]) != self._join_epoch(group_id) or state["bound"] is not None:
|
|
395
|
+
return
|
|
396
|
+
rank = [int(copy["seq"]), *sender]
|
|
397
|
+
last = state.get("snapshot")
|
|
398
|
+
if last is not None and rank <= last:
|
|
399
|
+
return
|
|
400
|
+
key, value = write
|
|
401
|
+
state["snapshot"] = rank
|
|
402
|
+
state["base"] = {"holder": value, "source": list(key)}
|
|
403
|
+
holder = self._fold(group_id, state)
|
|
404
|
+
data = self._changed(group_id, state, holder, key)
|
|
405
|
+
self._engine.store.set_floor(group_id, holder, state)
|
|
406
|
+
if data is not None:
|
|
407
|
+
LOG.info("floor_changed", group=log.hid(group_id), reason="grant")
|
|
408
|
+
await self._engine.emit(group_id, "floor_changed", **data)
|
|
409
|
+
|
|
410
|
+
def seal(self, group_id: str, payload: dict[str, Any], epoch: int) -> Any:
|
|
411
|
+
"""This instance seals its own ``control.floor`` in ``epoch`` (inside
|
|
412
|
+
the transaction that stores the sealed envelope): a grant gets the
|
|
413
|
+
epoch's next counter, and the write is taken now, by the receivers'
|
|
414
|
+
rule, so a crash after the relay took it loses nothing. It stays
|
|
415
|
+
pending, with its epoch's facts kept, until the relay confirms it
|
|
416
|
+
(:meth:`confirmed`); a reseal in a later epoch replaces it, and a send
|
|
417
|
+
that fails takes it back (:meth:`unsent`). Returns the event to tell."""
|
|
418
|
+
e = self._engine
|
|
419
|
+
body = payload["inner"]["body"]
|
|
420
|
+
if "copy" in body:
|
|
421
|
+
return None # a copy of what this instance holds: nothing to take
|
|
422
|
+
_, state = self._load(group_id)
|
|
423
|
+
msg_id = str(payload["msg_id"])
|
|
424
|
+
self._take_back(group_id, state, msg_id)
|
|
425
|
+
body["counter"] = min(int(state["counters"].get(str(epoch), 0)) + 1, MAX_FLOOR_COUNTER)
|
|
426
|
+
me, instance = e.me, e.instance.instance_id_hex
|
|
427
|
+
write = self._judge(group_id, state, epoch, me, instance, payload["inner"])
|
|
428
|
+
key: Key = (epoch, 0, me, instance)
|
|
429
|
+
if write is not None:
|
|
430
|
+
key, value = write
|
|
431
|
+
state["pending"][msg_id] = list(key)
|
|
432
|
+
holder = self._merge(group_id, state, key, "grant", value)
|
|
433
|
+
else:
|
|
434
|
+
holder = self._fold(group_id, state) # a reseal may have taken one back
|
|
435
|
+
data = self._changed(group_id, state, holder, key)
|
|
436
|
+
e.store.set_floor(group_id, holder, state)
|
|
437
|
+
if data is None:
|
|
438
|
+
return None
|
|
439
|
+
return e._record(group_id, "floor_changed", f"floor_sent/{msg_id}/{epoch}", data)
|
|
440
|
+
|
|
441
|
+
def _take_back(self, group_id: str, state: dict[str, Any], msg_id: str) -> None:
|
|
442
|
+
key = _key(state["pending"].pop(msg_id, None))
|
|
443
|
+
if key is not None:
|
|
444
|
+
self._engine.store.delete_floor_write(group_id, key)
|
|
445
|
+
|
|
446
|
+
def confirmed(self, group_id: str, msg_id: str) -> None:
|
|
447
|
+
"""The relay took this instance's ``control.floor``: it is final, and
|
|
448
|
+
the instances that joined since it was sealed, which can't decrypt
|
|
449
|
+
it, get a copy if it changes the settled value."""
|
|
450
|
+
e = self._engine
|
|
451
|
+
if e.store.floor(group_id) is None:
|
|
452
|
+
return
|
|
453
|
+
with e.store.tx():
|
|
454
|
+
holder, state = self._load(group_id)
|
|
455
|
+
key = _key(state["pending"].get(msg_id))
|
|
456
|
+
if key is None:
|
|
457
|
+
return
|
|
458
|
+
settled = self._settled(group_id, state)
|
|
459
|
+
state["pending"].pop(msg_id)
|
|
460
|
+
self._prune(group_id, state, key[0])
|
|
461
|
+
self._recopy(group_id, state, settled, key)
|
|
462
|
+
e.store.set_floor(group_id, holder, state)
|
|
463
|
+
|
|
464
|
+
def unsent(self, group_id: str, msg_id: str) -> Any:
|
|
465
|
+
"""A sealed ``control.floor`` of this instance will never go out: take
|
|
466
|
+
its write back. Returns the event to tell."""
|
|
467
|
+
e = self._engine
|
|
468
|
+
if e.store.group(group_id) is None:
|
|
469
|
+
return None
|
|
470
|
+
_, state = self._load(group_id)
|
|
471
|
+
key = _key(state["pending"].get(msg_id))
|
|
472
|
+
if key is None:
|
|
473
|
+
return None
|
|
474
|
+
self._take_back(group_id, state, msg_id) # copies never carried it
|
|
475
|
+
holder = self._fold(group_id, state)
|
|
476
|
+
data = self._changed(group_id, state, holder, key)
|
|
477
|
+
e.store.set_floor(group_id, holder, state)
|
|
478
|
+
if data is None:
|
|
479
|
+
return None
|
|
480
|
+
return e._record(group_id, "floor_changed", f"floor_unsent/{msg_id}", data)
|
|
481
|
+
|
|
482
|
+
def reconcile(self) -> list[Any]:
|
|
483
|
+
"""At start: a pending own grant whose outbox row failed, or is gone,
|
|
484
|
+
never went out, so take it back; one whose row is done went out.
|
|
485
|
+
Returns the events to tell."""
|
|
486
|
+
e = self._engine
|
|
487
|
+
events = []
|
|
488
|
+
for group in e.store.groups():
|
|
489
|
+
row = e.store.floor(group.group_id)
|
|
490
|
+
if group.status != "active" or row is None:
|
|
491
|
+
continue
|
|
492
|
+
for msg_id in list(row[1].get("pending", {})):
|
|
493
|
+
state = e.store.outbox_state_of(msg_id)
|
|
494
|
+
if state == "done":
|
|
495
|
+
self.confirmed(group.group_id, msg_id)
|
|
496
|
+
elif state in (None, "failed"):
|
|
497
|
+
with e.store.tx():
|
|
498
|
+
event = self.unsent(group.group_id, msg_id)
|
|
499
|
+
if event is not None:
|
|
500
|
+
events.append(event)
|
|
501
|
+
return events
|
|
502
|
+
|
|
503
|
+
def grant(self, group_id: str, holder: str | None, *, origin: str) -> dict[str, Any]:
|
|
504
|
+
"""Grant the floor to ``holder``, or release it (``None``): send
|
|
505
|
+
``control.floor`` to the group. Only the effective moderator or the
|
|
506
|
+
owner of a ``moderated`` group may; the grant is taken when it is
|
|
507
|
+
sealed (:meth:`seal`). ``origin`` is ``human`` for the human token,
|
|
508
|
+
else ``agent``, whose grants count against its send rate like its
|
|
509
|
+
messages."""
|
|
510
|
+
e = self._engine
|
|
511
|
+
group_id = e._group_or_raise(group_id).group_id
|
|
512
|
+
facts = self._now_facts(group_id)
|
|
513
|
+
if facts["mode"] != "moderated":
|
|
514
|
+
raise DaemonError("not_moderated", "the group's floor isn't moderated")
|
|
515
|
+
if not may_grant(facts, e.me):
|
|
516
|
+
msg = "only the effective moderator or the owner grants the floor (SPEC 9.3)"
|
|
517
|
+
raise DaemonError("not_permitted", msg)
|
|
518
|
+
if holder is not None and holder not in facts["identities"]:
|
|
519
|
+
raise DaemonError("not_found", "no such member")
|
|
520
|
+
# The counter is the sealing epoch's, set when it is sealed.
|
|
521
|
+
msg_id, _ = e.queue_message(
|
|
522
|
+
group_id,
|
|
523
|
+
{"holder": holder, "counter": 0},
|
|
524
|
+
kind=FLOOR_KIND,
|
|
525
|
+
origin=origin,
|
|
526
|
+
floor_grant=True,
|
|
527
|
+
)
|
|
528
|
+
LOG.info("floor_grant_queued", group=log.hid(group_id))
|
|
529
|
+
return {"status": "granted", "holder": holder, "msg_id": msg_id}
|
|
530
|
+
|
|
531
|
+
# -- commits ----------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
async def after_commit(self, group_id: str, summary: dict[str, Any], joined: list[str]) -> None:
|
|
534
|
+
"""After a merged commit (inside its transaction): keep the new
|
|
535
|
+
epoch's facts, write the resets the commit makes, fold away writes no
|
|
536
|
+
grant can come after any more, report a change, and send newcomers a
|
|
537
|
+
copy of the current value."""
|
|
538
|
+
e = self._engine
|
|
539
|
+
epoch = int(summary["epoch"])
|
|
540
|
+
holder, state = self._load(group_id)
|
|
541
|
+
facts = self._now_facts(group_id)
|
|
542
|
+
if epoch == self._epoch(group_id):
|
|
543
|
+
state["facts"][str(epoch)] = facts
|
|
544
|
+
before = state["facts"].get(str(epoch - 1))
|
|
545
|
+
reason = "moderator"
|
|
546
|
+
if self._floor_changed(summary, before, facts):
|
|
547
|
+
holder = self._merge(group_id, state, (epoch, 0, "", ""), "reset", None)
|
|
548
|
+
reason = "policy"
|
|
549
|
+
gone = {str(m["identity"]) for m in summary.get("removed") or []}
|
|
550
|
+
for identity in sorted(gone - set(facts["identities"])):
|
|
551
|
+
holder = self._merge(group_id, state, (epoch, 0, "", identity), "drop", identity)
|
|
552
|
+
if reason != "policy" and holder != state["reported"].get("holder"):
|
|
553
|
+
reason = "reset"
|
|
554
|
+
if joined:
|
|
555
|
+
copier = self._copier(group_id, summary, facts)
|
|
556
|
+
state["joins"][str(epoch)] = {"identities": sorted(set(joined)), "copier": copier}
|
|
557
|
+
self._fold_old(group_id, state, epoch - PAST_EPOCHS)
|
|
558
|
+
data = self._report(state, facts, holder, str(summary["committer"]["identity"]), reason)
|
|
559
|
+
settled = self._settled(group_id, state)
|
|
560
|
+
if joined and state["joins"][str(epoch)]["copier"] and settled[0] is not None:
|
|
561
|
+
self._send_copy(group_id, state, settled, epoch)
|
|
562
|
+
e.store.set_floor(group_id, holder, state)
|
|
563
|
+
if data is not None:
|
|
564
|
+
await e.emit(group_id, "floor_changed", **data)
|
|
565
|
+
|
|
566
|
+
@staticmethod
|
|
567
|
+
def _floor_changed(
|
|
568
|
+
summary: dict[str, Any], before: dict[str, Any] | None, facts: dict[str, Any]
|
|
569
|
+
) -> bool:
|
|
570
|
+
"""Whether the commit changed ``floor`` or ``floor_moderator``."""
|
|
571
|
+
if not summary.get("policy_changed"):
|
|
572
|
+
return False
|
|
573
|
+
previous = summary.get("previous_policy")
|
|
574
|
+
if previous:
|
|
575
|
+
old = json.loads(previous)
|
|
576
|
+
return (old.get("floor"), old.get("floor_moderator")) != (
|
|
577
|
+
facts["mode"],
|
|
578
|
+
facts["moderator"],
|
|
579
|
+
)
|
|
580
|
+
# A summary journalled before the core reported policies.
|
|
581
|
+
return before is not None and (before["mode"], before["moderator"]) != (
|
|
582
|
+
facts["mode"],
|
|
583
|
+
facts["moderator"],
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
def _fold_old(self, group_id: str, state: dict[str, Any], oldest: int) -> None:
|
|
587
|
+
"""Fold the writes of epochs before ``oldest`` into the base value:
|
|
588
|
+
no grant from those epochs can be decrypted any more. An epoch with
|
|
589
|
+
an own grant still pending keeps its writes and facts."""
|
|
590
|
+
store = self._engine.store
|
|
591
|
+
pending = [int(k[0]) for k in state["pending"].values()]
|
|
592
|
+
oldest = min([oldest, *pending])
|
|
593
|
+
old = [w for w in store.floor_writes(group_id) if w[0] < oldest]
|
|
594
|
+
if old:
|
|
595
|
+
base = state["base"] or {"holder": None, "source": None}
|
|
596
|
+
holder, source = fold(base["holder"], _key(base["source"]), old)
|
|
597
|
+
state["base"] = {"holder": holder, "source": None if source is None else list(source)}
|
|
598
|
+
state["bound"] = list(old[-1][:4])
|
|
599
|
+
store.drop_floor_writes(group_id, oldest)
|
|
600
|
+
for field in ("facts", "counters", "joins"):
|
|
601
|
+
state[field] = {k: v for k, v in state[field].items() if int(k) >= oldest}
|
|
602
|
+
|
|
603
|
+
def _copier(self, group_id: str, summary: dict[str, Any], facts: dict[str, Any]) -> bool:
|
|
604
|
+
"""Whether this instance copies the floor to the instances a commit
|
|
605
|
+
added: the lowest instance, among those that were in the group before
|
|
606
|
+
the commit, of the effective moderator, or else of the owner (a
|
|
607
|
+
moderator that just joined has no value yet)."""
|
|
608
|
+
e = self._engine
|
|
609
|
+
if facts["mode"] != "moderated":
|
|
610
|
+
return False
|
|
611
|
+
added = {str(m.get("mailbox_id")) for m in summary.get("added") or []}
|
|
612
|
+
members = [m for m in e.client.members(unb64(group_id)) if m["mailbox_id"] not in added]
|
|
613
|
+
senders = [m for m in members if m["identity"] == facts["effective"]] or [
|
|
614
|
+
m for m in members if m["identity"] == facts["owner"]
|
|
615
|
+
]
|
|
616
|
+
lowest = min(senders, key=lambda m: int(m["leaf_index"]), default=None)
|
|
617
|
+
return lowest is not None and str(lowest["mailbox_id"]) == e.mailbox
|
|
618
|
+
|
|
619
|
+
def _send_copy(
|
|
620
|
+
self,
|
|
621
|
+
group_id: str,
|
|
622
|
+
state: dict[str, Any],
|
|
623
|
+
settled: tuple[str | None, Key | None],
|
|
624
|
+
join: int,
|
|
625
|
+
) -> None:
|
|
626
|
+
"""Send the instances that joined in ``join`` the settled value
|
|
627
|
+
(:meth:`_settled`) with the key of the write that set it, and the
|
|
628
|
+
next ``seq`` of this join's copies: the daemon's own control, hop 0,
|
|
629
|
+
replying to nothing."""
|
|
630
|
+
holder, source = settled
|
|
631
|
+
if source is None:
|
|
632
|
+
return
|
|
633
|
+
record = state["joins"][str(join)]
|
|
634
|
+
record["seq"] = min(int(record.get("seq", 0)) + 1, MAX_FLOOR_COUNTER)
|
|
635
|
+
epoch, counter, sender, instance = source
|
|
636
|
+
copy = {
|
|
637
|
+
"epoch": epoch,
|
|
638
|
+
"sender": sender,
|
|
639
|
+
"instance": instance,
|
|
640
|
+
"join": join,
|
|
641
|
+
"seq": record["seq"],
|
|
642
|
+
}
|
|
643
|
+
self._engine.queue_message(
|
|
644
|
+
group_id,
|
|
645
|
+
{"holder": holder, "counter": counter, "copy": copy},
|
|
646
|
+
to=sorted(set(record["identities"])),
|
|
647
|
+
kind=FLOOR_KIND,
|
|
648
|
+
daemon_control=True,
|
|
649
|
+
)
|