fleetmesh-node 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,257 @@
1
+ """The relay links this node holds open, as a compartment.
2
+
3
+ The transport keeps the sockets and counts what it drops. Nothing watched it:
4
+ the tick read `transport.health` directly to decide what to say to the journal,
5
+ and `health()` read six more of its counters by name. A subscription that
6
+ dropped was therefore visible in two hand-written places and in no refusal
7
+ channel at all, so `meshnode refusals` - the one view that answers "what has
8
+ this node been saying no to" - could not show a relay that had been refusing
9
+ this node for an hour.
10
+
11
+ This compartment owns what the journal has already been told, so it says a
12
+ transition once and not once a tick, and it records the drop as a refusal like
13
+ every other no.
14
+
15
+ It also records the transport's verdicts on stored messages. A kind 1059 the
16
+ relay accepted and never served used to be indistinguishable from one that
17
+ arrived; now every stored message asks for an acknowledgment, and a peer that
18
+ has spoken since and not acknowledged is a `stored: unacknowledged` refusal
19
+ here, with the message type and the relays it went through, so `meshnode
20
+ refusals` can show the day the control plane went quiet.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Callable, Optional
25
+
26
+ from node.compartment import stores as st
27
+ from node.channels import Channel, NOTE, TROUBLE
28
+ from node.compartment import Command, Compartment, Need
29
+ from node.metrics import COUNTER, Metric
30
+ from node.interfaces import AwaitingAckRow
31
+ from node.transport.ladder import Fault
32
+ from node.transport.relay_rung.cooldown import Cooling
33
+
34
+
35
+ from node.config import TransportConfig
36
+ from .types import LinkState
37
+
38
+
39
+ class Link(Compartment):
40
+
41
+ name: str = 'link'
42
+ reads: tuple[str, ...] = ('transport',)
43
+ config_cls = TransportConfig
44
+ # strain is constructed before link so a reconnect can be recorded into it,
45
+ # but ticks after link (the journal first); it is bound, not required.
46
+ requires: tuple[str, ...] = ()
47
+ NEEDS = (
48
+ Need('transport'),
49
+ Need('strain', late=True),
50
+ )
51
+ COMMANDS = (
52
+ Command(
53
+ key='show',
54
+ name='Link',
55
+ description='every relay subscription, what it has dropped, what is waiting to be '
56
+ 'read, and the stored messages still waiting to be acknowledged',
57
+ tags=('link', 'status'),
58
+ permission='link/show',
59
+ run='state',
60
+ reply='{relays, dropped, recent_faults, publish_refused_in_window, cooling, inbox, '
61
+ 'wraps_in, wraps_seen_again, unfinished_envelopes, awaiting_ack, acked, '
62
+ 'acks_in, acks_out, unacknowledged}',
63
+ ),
64
+ )
65
+ STORES = (
66
+ st.Store(key='_up', description='last reported connection state per relay url',
67
+ kind=st.TABLE, shape=bool, bound=1024, keyed_by='url', evict=st.LRU),
68
+ st.Store(key='_said', description='last reported unreachable reason per relay url',
69
+ kind=st.TABLE, bound=1024, keyed_by='url', evict=st.LRU),
70
+ st.Store(key='_ack', description='last reported subscription-acknowledged state per relay url',
71
+ kind=st.TABLE, shape=bool, bound=1024, keyed_by='url', evict=st.LRU),
72
+ )
73
+ CHANNELS = (
74
+ Channel('relay_up', 'Relay subscription connected', say='relay {url} subscription up'),
75
+ Channel('relay_down', 'Relay subscription disconnected', level=TROUBLE,
76
+ say='relay {url} subscription down ({reason}), reconnecting'),
77
+ Channel('relay_unreachable', 'Relay not reachable', level=TROUBLE,
78
+ say='relay {url} not reachable ({reason}), retrying'),
79
+ Channel('unacknowledged', 'Stored message was never acknowledged', level=NOTE,
80
+ say='stored {peer} {type} never acknowledged; the peer has spoken since via {relays}'),
81
+ Channel('relay_deaf', 'Relay holds the socket but never acknowledged the subscription',
82
+ level=TROUBLE,
83
+ say='relay {url} took the subscription and never acknowledged it ({reason})'),
84
+ Channel('relay_acked', 'Relay acknowledged the subscription',
85
+ say='relay {url} acknowledged the subscription'),
86
+ )
87
+ METRICS = (
88
+ Metric(name='link_relays_connected', description='Times a relay connection came up.',
89
+ kind=COUNTER, counts='relay_up', tags=('link', 'relay')),
90
+ Metric(name='link_relays_dropped', description='Times a relay connection dropped.',
91
+ kind=COUNTER, counts='relay_down', tags=('link', 'relay')),
92
+ Metric(name='link_relays_unreachable', description='Times a relay was unreachable.',
93
+ kind=COUNTER, counts='relay_unreachable', tags=('link', 'relay')),
94
+ Metric(name='link_unacknowledged', description='Times a stored message was unacknowledged.',
95
+ kind=COUNTER, counts='unacknowledged', tags=('link', 'transport')),
96
+ )
97
+
98
+ def bind(self, strain: Any) -> None:
99
+ """The ledger a reconnect is recorded into. Bound after both exist
100
+ because strain ticks after link and the assembler builds in tick order."""
101
+ self.strain = strain
102
+
103
+ def __init__(
104
+ self,
105
+ transport: Any,
106
+ strain: Optional[Any] = None,
107
+ clock: Optional[Callable[[], int]] = None,
108
+ node: Optional[Any] = None,
109
+ ) -> None:
110
+ super().__init__(node, clock)
111
+ self.transport: Any = transport
112
+ self.strain = strain
113
+ self._up: st.Table[str, bool] # url -> the connected state the journal was last told
114
+ self._said: st.Table[str, Optional[str]] # url -> the last unreachable reason the journal was told
115
+ self._ack: st.Table[str, bool] # url -> the acknowledged state the journal was last told
116
+
117
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
118
+ """Say what changed, once each way it changed.
119
+
120
+ A relay that is down stays down, and saying so every thirty seconds
121
+ buries everything else in the journal. A transition is news; a state
122
+ is not.
123
+ """
124
+ for url, h in self.transport.health.items():
125
+ prev = self._up.get(url)
126
+ if h['connected'] and prev is not True:
127
+ self.say('relay_up', url=url)
128
+ elif not h['connected'] and prev is True:
129
+ self.say('relay_down', url=url, reason=h['last_error'] or 'no reason')
130
+ self.refusals.refuse('subscription', 'dropped', subject=url,
131
+ detail={'error': h['last_error']}, at=now)
132
+ self._said[url] = h['last_error'] # the next tick must not say the same reason again
133
+ if self.strain is not None:
134
+ self.strain.record('reconnect', transport=url)
135
+ elif not h['connected'] and h['last_error'] and self._said.get(url) != h['last_error']:
136
+ self.say('relay_unreachable', url=url, reason=h['last_error'])
137
+ self._said[url] = h['last_error']
138
+ self.refusals.refuse('subscription', 'not reachable', subject=url,
139
+ detail={'error': h['last_error']}, at=now)
140
+ # `connected` is the socket; `confirmed` is the relay's own EOSE.
141
+ # A relay that holds the socket and never acknowledges carries no
142
+ # events to us, so the inbox is deaf - and every branch above keys
143
+ # off `connected`, which is True, so the journal said 'subscription
144
+ # up' and nothing further (audit I13). Said on transition, like
145
+ # `connected`; trouble() is what covers a run that is never
146
+ # acknowledged at all, because that run has no transition to say.
147
+ if h['connected']:
148
+ was = self._ack.get(url)
149
+ acked = bool(h.get('confirmed'))
150
+ if acked and was is not True:
151
+ self.say('relay_acked', url=url)
152
+ elif not acked and was is True:
153
+ self.say('relay_deaf', url=url, reason=h['last_error'] or 'no EOSE')
154
+ self._ack[url] = acked
155
+ self._up[url] = h['connected']
156
+ out: list[tuple[Any, ...]] = []
157
+ self.transport.sweep(now)
158
+ for v in self.transport.take_unacked():
159
+ if v['verdict'] != 'unacknowledged':
160
+ continue # expired: nothing could arrive any more, and nobody was heard
161
+ self.say('unacknowledged', peer=v['peer'][:16], type=v['type'], relays=', '.join(v['relays']) or 'no relay')
162
+ self.refusals.refuse('stored', 'unacknowledged', subject=v['peer'],
163
+ detail={'type': v['type'], 'sent_at': v['at'], 'heard_at': v['heard_at'],
164
+ 'relays': v['relays'], 'mid': v['mid'][:16]}, at=now)
165
+ out.append(('unacknowledged', v['peer'], v['type']))
166
+ return out
167
+
168
+ FAULTS_SHOWN: int = 20
169
+
170
+ def _faults(self, since: Optional[int] = None) -> list[Fault]:
171
+ recent = getattr(self.transport, 'recent_faults', None)
172
+ if recent is None: # a transport without a fault log: nothing to read
173
+ return []
174
+ return list(recent(since))
175
+
176
+ def _cooling(self) -> dict[str, Cooling]:
177
+ cd = self.transport.cooldown
178
+ return cd.state() if cd is not None else {}
179
+
180
+ def publish_refused(self, now: Optional[int] = None) -> list[Fault]:
181
+ """Wraps a relay refused within TROUBLE_WINDOW: the fault the OK
182
+ reports and nothing else shows, because a refused wrap is counted
183
+ as sent by the caller that published it."""
184
+ at = self.clock() if now is None else now
185
+ return [f for f in self._faults(at - self.TROUBLE_WINDOW)
186
+ if f['reason'].split(':', 1)[0] == 'publish refused']
187
+
188
+ def state(self) -> LinkState:
189
+ t = self.transport
190
+ return LinkState(
191
+ relays={url: dict(h) for url, h in t.health.items()}, # type: ignore[misc]
192
+ rungs=dict(t.rung_health()) if hasattr(t, 'rung_health') else {},
193
+ dropped=dict(t.drop_counts),
194
+ recent_faults=list(reversed(self._faults()))[:self.FAULTS_SHOWN],
195
+ publish_refused_in_window=len(self.publish_refused()),
196
+ cooling=self._cooling(),
197
+ inbox=t.inbox.qsize(),
198
+ wraps_in={str(k): v for k, v in t.wraps_in.items()},
199
+ wraps_seen_again={str(k): v for k, v in t.wraps_seen_again.items()},
200
+ unfinished_envelopes=t.unfinished(),
201
+ awaiting_ack=[AwaitingAckRow(peer=e['peer'][:16], type=e['type'], at=e['at'], relays=e['relays'])
202
+ for e in list(t.awaiting.values())],
203
+ acked=dict(t.acked),
204
+ acks_in=t.acks_in,
205
+ acks_out=t.acks_out,
206
+ unacknowledged=[AwaitingAckRow(peer=v['peer'][:16], type=v['type'], at=v['at'],
207
+ heard_at=v['heard_at'], relays=v['relays'])
208
+ for v in list(t.unacked) if v['verdict'] == 'unacknowledged'],
209
+ )
210
+
211
+ def notes(self) -> list[str]:
212
+ cooling = self._cooling()
213
+ if not cooling:
214
+ return []
215
+ # Not wrong in the node, but a relay it is not publishing to; an
216
+ # operator who sees a quiet relay should know it was asked to stop.
217
+ return ['%d relay(s) cooling after a rate-limit: %s'
218
+ % (len(cooling), '; '.join('%s until %d (%s)' % (u, c['until'], c['why'])
219
+ for u, c in sorted(cooling.items())))]
220
+
221
+ def trouble(self) -> list[str]:
222
+ out: list[str] = []
223
+ down = sorted(u for u, h in self.transport.health.items() if not h['connected'])
224
+ if down:
225
+ out.append('relay subscription down: %s' % ', '.join(down))
226
+ # The deaf inbox. `wait_listening` counts `confirmed`, and app start
227
+ # warns once on a timeout justifying itself with "`link` will keep
228
+ # saying so" - which it did not, because every row here read
229
+ # `connected` instead. A relay that holds the socket and never sends
230
+ # EOSE is connected, unconfirmed, quiet, and green in health and
231
+ # `meshctl watch`, while live wraps sent to it are lost: kind 21059 is
232
+ # ephemeral, so no relay keeps a copy (audit I13).
233
+ deaf = sorted((u, h['last_error']) for u, h in self.transport.health.items()
234
+ if h['connected'] and not h.get('confirmed'))
235
+ if deaf:
236
+ out.append('%d relay subscription(s) connected but never acknowledged: %s'
237
+ % (len(deaf), '; '.join('%s (%s)' % (u, e or 'no EOSE') for u, e in deaf)))
238
+ refused = self.publish_refused()
239
+ if refused:
240
+ # A relay that takes the subscription and refuses every wrap
241
+ # looks healthy in every other row: connected, confirmed, quiet.
242
+ by_relay: dict[str, str] = {}
243
+ for f in refused:
244
+ rest = f['reason'].split(':', 1)[1].strip() # 'wss://relay reason text'
245
+ url, _, why = rest.partition(' ')
246
+ by_relay.setdefault(url, why)
247
+ out.append('%d wrap(s) refused by relays in the last %ds: %s'
248
+ % (len(refused), self.TROUBLE_WINDOW,
249
+ '; '.join('%s (%s)' % (u, w) for u, w in sorted(by_relay.items()))))
250
+ recent = self.refusals.within(self.TROUBLE_WINDOW, self.clock(), kind='stored')
251
+ if recent:
252
+ # The one fault the publish result cannot show: what was said to
253
+ # the peer never reached it, and the peer was there to hear it.
254
+ by = sorted({'%s %s' % ((r.subject or '')[:16], r.detail.get('type', '')) for r in recent})
255
+ out.append('%d stored message(s) never acknowledged by a peer that has spoken since: %s'
256
+ % (len(recent), '; '.join(by)))
257
+ return out
@@ -0,0 +1,27 @@
1
+ """Types and records for the Link compartment."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Mapping, TypedDict
5
+
6
+ from node.interfaces import AwaitingAckRow, RelayHealthRow
7
+ from node.transport.ladder import Fault
8
+ from node.transport.relay_rung.cooldown import Cooling
9
+
10
+
11
+ class LinkState(TypedDict):
12
+ """What `meshnode link show` and the health report say about the relays."""
13
+ relays: dict[str, RelayHealthRow]
14
+ rungs: dict[str, Mapping[str, Any]] # every transport rung's own report, by rung name (nostr, iroh, ...)
15
+ dropped: dict[str, int] # fault family -> count, ever across the ladder
16
+ recent_faults: list[Fault] # the newest first, FAULTS_SHOWN of them, with reasons
17
+ publish_refused_in_window: int # wraps a relay refused within TROUBLE_WINDOW
18
+ cooling: dict[str, Cooling] # relays held out of publishing after a rate-limit, until when
19
+ inbox: int
20
+ wraps_in: dict[str, int]
21
+ wraps_seen_again: dict[str, int]
22
+ unfinished_envelopes: int
23
+ awaiting_ack: list[AwaitingAckRow]
24
+ acked: dict[str, int]
25
+ acks_in: int
26
+ acks_out: int
27
+ unacknowledged: list[AwaitingAckRow]
@@ -0,0 +1,45 @@
1
+ """Mobile Leaf & DIDComm Mediation (`routing/2.0` and `coordinate-mediation/2.0`).
2
+
3
+ Spec reference: spec.html section "Mediation is how a phone gets an address" and
4
+ "Cold-Start Mediator Discovery via NIP-66" (lines 3313-3348).
5
+
6
+ Covers:
7
+ 1. DIDComm `coordinate-mediation/2.0` protocol (mediate-request, mediate-grant, mediate-deny, keylist-update).
8
+ 2. DIDComm `routing/2.0` protocol (forward messages wrapping inner encrypted envelopes).
9
+ 3. DIDComm `messagepickup/2.0` protocol (status-request, delivery-request, delivery, messages-received).
10
+ 4. `MediatorService`: Anchor mediator buffering store-and-forward envelopes for offline/mobile leaves.
11
+ 5. `MobileLeafClient`: Mobile leaf lifecycle, dual-mediation, and NIP-66 cold-start mediator discovery.
12
+ 6. `Mediation` Compartment: Wire integration with NodeApp.
13
+
14
+ Laid out as a package by those seams: messages.py (the wire vocabulary),
15
+ store.py, service.py (the anchor), leaf.py (the phone), compartment.py (the
16
+ compartment). Everything the module used to export is re-exported here.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from .leaf import MobileLeafClient
21
+ from .messages import (KEYLIST_QUERY, KEYLIST_RESPONSE, KEYLIST_UPDATE, KEYLIST_UPDATE_RESPONSE,
22
+ MEDIATE_DENY, MEDIATE_GRANT, MEDIATE_REQUEST, PICKUP_DELIVERY,
23
+ PICKUP_DELIVERY_REQUEST, PICKUP_LIMIT_DEFAULT, PICKUP_LIMIT_MAX, PICKUP_LIMIT_MIN,
24
+ PICKUP_MESSAGES_RECEIVED, PICKUP_STATUS, PICKUP_STATUS_REQUEST, PIURIS,
25
+ ROUTING_FORWARD, clamp_pickup_limit, leaf_of, make_forward, make_keylist_update,
26
+ make_keylist_update_response, make_mediate_deny, make_mediate_grant,
27
+ make_mediate_request, make_pickup_delivery, make_pickup_delivery_request,
28
+ make_pickup_messages_received, make_pickup_status, make_pickup_status_request)
29
+ from .service import MediatorService
30
+ from .store import MediationStore
31
+ from .compartment import Mediation
32
+ from .types import DeliveredMessage, MediationGrantInfo
33
+
34
+ __all__ = [
35
+ 'KEYLIST_QUERY', 'KEYLIST_RESPONSE', 'KEYLIST_UPDATE', 'KEYLIST_UPDATE_RESPONSE',
36
+ 'MEDIATE_DENY', 'MEDIATE_GRANT', 'MEDIATE_REQUEST', 'PICKUP_DELIVERY',
37
+ 'PICKUP_DELIVERY_REQUEST', 'PICKUP_LIMIT_DEFAULT', 'PICKUP_LIMIT_MAX', 'PICKUP_LIMIT_MIN',
38
+ 'PICKUP_MESSAGES_RECEIVED', 'PICKUP_STATUS', 'PICKUP_STATUS_REQUEST', 'PIURIS', 'ROUTING_FORWARD',
39
+ 'clamp_pickup_limit', 'leaf_of', 'make_forward', 'make_keylist_update', 'make_keylist_update_response',
40
+ 'make_mediate_deny', 'make_mediate_grant', 'make_mediate_request', 'make_pickup_delivery',
41
+ 'make_pickup_delivery_request', 'make_pickup_messages_received', 'make_pickup_status',
42
+ 'make_pickup_status_request',
43
+ 'MediationStore', 'MediatorService', 'MobileLeafClient', 'Mediation',
44
+ 'DeliveredMessage', 'MediationGrantInfo',
45
+ ]
@@ -0,0 +1,238 @@
1
+ """The Mediation compartment: the operator commands and the inbound door
2
+ for the three mediation PIURIs, acting for the proven sender."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Callable, Mapping, Optional
7
+
8
+ from node import channels as ch_mod
9
+ from node import metrics as mx_mod
10
+ from node.compartment import stores as st
11
+ from node.compartment import Command, Handler, Need, Outbound, WireProtocol
12
+ from node.config import MediationConfig
13
+ from node.config.parameter import Parameter
14
+ from .messages import (KEYLIST_UPDATE, MEDIATE_REQUEST, PICKUP_DELIVERY_REQUEST, PICKUP_MESSAGES_RECEIVED,
15
+ PIURIS, ROUTING_FORWARD, make_mediate_request, make_pickup_delivery_request)
16
+ from .service import MediatorService
17
+ from .store import MediationStore
18
+
19
+
20
+ class Mediation(WireProtocol):
21
+ """The mediation compartment of meshnode (routing/2.0 & coordinate-mediation/2.0)."""
22
+
23
+ name = 'mediation'
24
+ piuris: tuple[str, ...] = tuple(PIURIS)
25
+ OUTBOUND = (
26
+ Outbound('mediate-grant', doc='grant coordinate-mediation admission and routing keys to leaf'),
27
+ Outbound('mediate-deny', doc='deny coordinate-mediation admission to leaf'),
28
+ Outbound('keylist-update-response', doc='acknowledge leaf routing keys updates'),
29
+ Outbound('delivery', doc='drain queued messages to leaf'),
30
+ Outbound('status', doc='deliver queue status to leaf'),
31
+ )
32
+ reads = ('mediation', 'endpoints', 'relays')
33
+ config_cls = MediationConfig
34
+ SCHEMA = MediationStore.SCHEMA
35
+ STORES = (
36
+ st.Store(key='handled', description='messages handled by protocol message type',
37
+ kind=st.TABLE, shape=int, bound=64, keyed_by='msg_type', evict=st.LRU),
38
+ )
39
+ CHANNELS = (
40
+ ch_mod.Channel('message_forwarded', 'Mediation forward message queued for leaf',
41
+ level=ch_mod.INFO, say='forward message queued for recipient {recipient}',
42
+ tags=('mediation', 'forward')),
43
+ ch_mod.Channel('forward_refused', 'Mediation forward message refused or dropped',
44
+ level=ch_mod.TROUBLE, say='forward message refused for recipient {recipient}',
45
+ tags=('mediation', 'forward')),
46
+ ch_mod.Channel('mediation_granted', 'Mediation admission granted to leaf',
47
+ level=ch_mod.INFO, say='mediation admission granted to {leaf}',
48
+ tags=('mediation', 'admission')),
49
+ ch_mod.Channel('messages_drained', 'Queued messages picked up by leaf',
50
+ level=ch_mod.INFO, say='picked up {count} messages for {leaf}',
51
+ tags=('mediation', 'pickup')),
52
+ ch_mod.Channel('sender_unauthenticated', 'Mediation message sender not authenticated',
53
+ level=ch_mod.TROUBLE, say='mediation request refused: sender not authenticated',
54
+ tags=('mediation', 'security')),
55
+ )
56
+ METRICS = (
57
+ mx_mod.Metric(name='mediation_messages_forwarded', description='Messages buffered for mediated leaves.',
58
+ kind=mx_mod.COUNTER, counts='message_forwarded', tags=('mediation', 'forward')),
59
+ mx_mod.Metric(name='mediation_forward_refused', description='Forward messages refused or dropped.',
60
+ kind=mx_mod.COUNTER, counts='forward_refused', tags=('mediation', 'forward')),
61
+ mx_mod.Metric(name='mediation_admissions_granted', description='Leaves admitted into mediation.',
62
+ kind=mx_mod.COUNTER, counts='mediation_granted', tags=('mediation', 'admission')),
63
+ mx_mod.Metric(name='mediation_messages_drained', description='Batches of messages drained by leaves.',
64
+ kind=mx_mod.COUNTER, counts='messages_drained', tags=('mediation', 'pickup')),
65
+ mx_mod.Metric(name='mediation_unauthenticated', description='Mediation messages from unauthenticated senders.',
66
+ kind=mx_mod.COUNTER, counts='sender_unauthenticated', tags=('mediation', 'security')),
67
+ )
68
+ HANDLERS = (
69
+ Handler('forward', run='handle_forward', doc='buffer inbound routing/2.0 forward envelope for mobile leaf'),
70
+ Handler('mediate-request', run='handle_mediate_request', writes=('mediation',), doc='grant coordinate-mediation admission'),
71
+ Handler('keylist-update', run='handle_keylist_update', writes=('mediation',), doc='update leaf routing keys'),
72
+ Handler('delivery-request', run='handle_pickup_delivery_request', reads=('mediation',), doc='drain queued messages for leaf'),
73
+ Handler('messages-received', run='handle_pickup_messages_received', writes=('mediation',), doc='acknowledge and purge delivered messages'),
74
+ )
75
+ COMMANDS = (
76
+ Command(
77
+ key='show',
78
+ name='Mediation Status',
79
+ description='display active mediated leaves and queue depth',
80
+ tags=('mediation', 'status'),
81
+ permission='mediation/show',
82
+ run='show_cmd',
83
+ reply='dict',
84
+ ),
85
+ Command(
86
+ key='grant',
87
+ name='Grant Mediation',
88
+ description='admit leaf into mediation and grant routing keys',
89
+ tags=('mediation', 'grant'),
90
+ parameters=(
91
+ Parameter(key='leaf', name='leaf', description='did of the leaf to admit', type=str, default='', required=True),
92
+ ),
93
+ permission='mediation/grant',
94
+ run='grant_cmd',
95
+ reply='dict',
96
+ ),
97
+ Command(
98
+ key='forward',
99
+ name='Forward Message',
100
+ description='buffer inbound routing/2.0 forward envelope for mobile leaf',
101
+ tags=('mediation', 'forward'),
102
+ parameters=(
103
+ Parameter(key='message', name='message', description='forward envelope (dict or json string)', type=object, default=dict, required=True),
104
+ ),
105
+ permission='mediation/forward',
106
+ run='forward_cmd',
107
+ reply='bool',
108
+ ),
109
+ Command(
110
+ key='pickup',
111
+ name='Pickup Messages',
112
+ description='drain queued forwarded envelopes for mobile leaf',
113
+ tags=('mediation', 'pickup'),
114
+ parameters=(
115
+ Parameter(key='leaf', name='leaf', description='did of the leaf to drain for', type=str, default='', required=True),
116
+ Parameter(key='limit', name='limit', description='max envelopes to drain', type=int, default=10, required=False),
117
+ ),
118
+ permission='mediation/pickup',
119
+ run='pickup_cmd',
120
+ reply='dict',
121
+ ),
122
+ )
123
+
124
+ DB: str = 'mediation.sqlite'
125
+ NEEDS = (
126
+ Need('db_path', path='DB'),
127
+ Need('mediator_did', 'did'),
128
+ Need('transport', 'transport'),
129
+ )
130
+
131
+ def __init__(self, node: Any = None, db_path: Optional[str] = None, mediator_did: Optional[str] = None,
132
+ clock: Optional[Callable[[], int]] = None, transport: Optional[Any] = None,
133
+ db: Optional[Any] = None) -> None:
134
+ """Built by `from_context` in a node. The bare `Mediation()` and
135
+ `Mediation(node=fake)` forms the tests use still work: with no path
136
+ the store is in memory, and with no DID the one on `node.ks` is used
137
+ if there is one."""
138
+ if db is None and db_path is not None:
139
+ from node.storage.adapter import SqliteAdapter
140
+ db = SqliteAdapter(db_path, schema=self.SCHEMA, clock=clock)
141
+ super().__init__(node, clock, db=db)
142
+ # The transport a reply goes back over; off the fake node in the suites.
143
+ self.transport: Optional[Any] = transport if transport is not None else getattr(node, 'transport', None)
144
+ self.handled: st.Table[str, int]
145
+ if mediator_did is None:
146
+ ks = getattr(node, 'ks', None) if node is not None else None
147
+ actual_did = 'did:nostr:%s' % ks.node_pk if ks is not None else ''
148
+ else:
149
+ actual_did = str(mediator_did)
150
+ if db_path is not None:
151
+ self.service: MediatorService = MediatorService(db_path=db_path, mediator_did=actual_did, clock=clock, db=self.db)
152
+ else:
153
+ self.service = MediatorService(mediator_did=actual_did, clock=clock, db=self.db)
154
+
155
+ def show_cmd(self) -> Mapping[str, Any]:
156
+ return {
157
+ 'mediator_did': self.service.mediator_did,
158
+ 'routing_keys': self.service.routing_keys,
159
+ }
160
+
161
+ def grant_cmd(self, args: Mapping[str, Any]) -> Any:
162
+ req = make_mediate_request(from_did=str(args['leaf']), to_did=self.service.mediator_did)
163
+ return self.service.handle_mediate_request(req)
164
+
165
+ def forward_cmd(self, args: Mapping[str, Any]) -> bool:
166
+ raw = args['message']
167
+ msg = raw if isinstance(raw, dict) else json.loads(raw)
168
+ return bool(self.service.handle_forward(msg))
169
+
170
+ def pickup_cmd(self, args: Mapping[str, Any]) -> Any:
171
+ pick = make_pickup_delivery_request(
172
+ from_did=str(args['leaf']),
173
+ to_did=self.service.mediator_did,
174
+ limit=int(args.get('limit') or 10),
175
+ )
176
+ return self.service.handle_pickup_delivery_request(pick)
177
+
178
+ # ---- the wire ---------------------------------------------------------
179
+ def handle_forward(self, inbound: Any, sender: str) -> Optional[tuple[Any, ...]]:
180
+ msg = getattr(inbound, 'message', inbound)
181
+ if not isinstance(msg, dict) or not isinstance(sender, str) or not sender:
182
+ return None
183
+ queued = self.service.handle_forward(msg)
184
+ self._count(ROUTING_FORWARD)
185
+ recipient = str(msg.get('body', {}).get('next', ''))[:24]
186
+ if queued:
187
+ self.say('message_forwarded', recipient=recipient)
188
+ else:
189
+ self.say('forward_refused', recipient=recipient)
190
+ return ('mediation forward', 'queued' if queued else 'refused', recipient)
191
+
192
+ def _handle_authenticated(self, inbound: Any, sender_pk: str, mtype: str, action: Callable[..., Any]) -> Optional[tuple[Any, ...]]:
193
+ msg = getattr(inbound, 'message', inbound)
194
+ if not isinstance(msg, dict) or not isinstance(sender_pk, str) or not sender_pk:
195
+ return None
196
+ if not getattr(inbound, 'authenticated', False):
197
+ self._count('unauthenticated')
198
+ self.say('sender_unauthenticated')
199
+ return ('mediation refused', mtype.rsplit('/', 1)[-1], 'sender not authenticated')
200
+ sender = 'did:nostr:' + sender_pk
201
+ try:
202
+ reply = action(msg, sender=sender)
203
+ except PermissionError as e:
204
+ self._count('impersonation')
205
+ return ('mediation refused', mtype.rsplit('/', 1)[-1], str(e))
206
+ self._count(mtype)
207
+ if mtype == MEDIATE_REQUEST and isinstance(reply, dict) and reply.get('type', '').endswith('mediate-grant'):
208
+ self.say('mediation_granted', leaf=sender)
209
+ elif mtype == PICKUP_DELIVERY_REQUEST and isinstance(reply, dict):
210
+ msgs = reply.get('body', {}).get('messages', [])
211
+ self.say('messages_drained', count=len(msgs), leaf=sender)
212
+ if mtype == PICKUP_MESSAGES_RECEIVED:
213
+ return ('mediation received', sender_pk[:16], reply)
214
+ if self.transport is not None and isinstance(reply, dict):
215
+ self.wire_client.send(sender_pk, reply)
216
+ return ('mediation', mtype.rsplit('/', 1)[-1], sender_pk[:16], str(reply.get('type', ''))[-30:] if isinstance(reply, dict) else '')
217
+
218
+ def handle_mediate_request(self, inbound: Any, sender: str) -> Optional[tuple[Any, ...]]:
219
+ return self._handle_authenticated(inbound, sender, MEDIATE_REQUEST, self.service.handle_mediate_request)
220
+
221
+ def handle_keylist_update(self, inbound: Any, sender: str) -> Optional[tuple[Any, ...]]:
222
+ return self._handle_authenticated(inbound, sender, KEYLIST_UPDATE, self.service.handle_keylist_update)
223
+
224
+ def handle_pickup_delivery_request(self, inbound: Any, sender: str) -> Optional[tuple[Any, ...]]:
225
+ return self._handle_authenticated(inbound, sender, PICKUP_DELIVERY_REQUEST, self.service.handle_pickup_delivery_request)
226
+
227
+ def handle_pickup_messages_received(self, inbound: Any, sender: str) -> Optional[tuple[Any, ...]]:
228
+ return self._handle_authenticated(inbound, sender, PICKUP_MESSAGES_RECEIVED, self.service.handle_messages_received)
229
+
230
+ def _count(self, key: str) -> None:
231
+ self.handled[key] = self.handled.get(key, 0) + 1
232
+
233
+ def stop(self) -> None:
234
+ """Safely close mediation persistence and worker state on shutdown."""
235
+ if hasattr(self, 'service') and self.service:
236
+ self.service.close()
237
+ super().stop()
238
+