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,409 @@
1
+ """What this node says it is alive, and what it makes of a peer saying so.
2
+
3
+ Three things that are one thing: the descriptor this node publishes, the
4
+ heartbeat that says the descriptor is still true, and the subscription that
5
+ reads a peer's. All three ran loose in the node's tick, and the pair of them
6
+ that can find a fault - a heartbeat whose class contradicts the peer's live
7
+ descriptor, one whose endpoint digest says the descriptor has gone stale - fed
8
+ a finding into the budget and recorded nothing an operator could read back.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from typing import Any, Callable, Optional, Sequence
15
+
16
+ from fleetmesh_core.crypto import giftwrap
17
+ from fleetmesh_core.identity import relaylist
18
+ from fleetmesh_core.services import lifecycle, telemetry
19
+ from fleetmesh_core.storage import store as ref_store
20
+ from fleetmesh_core.wire.interfaces import NostrEvent
21
+ from node.channels import journal
22
+ from node.compartment import stores as st
23
+ from node.protocols.nostr import client as relay
24
+ import dataclasses
25
+ from node.config.parameter import Parameter, ParamBlock
26
+ from node.channels import Channel, NOTE, INFO
27
+ from node.metrics import COUNTER, Metric
28
+ from node.compartment import Command, Compartment, Every, Need, Worker
29
+ from .types import PresenceStateReport
30
+
31
+ # One kind 21802 per window. The counters are the transport's and the
32
+ # strain ledger's totals, binned by fleetmesh_core/telemetry so nothing finer than
33
+ # an order of magnitude leaves the node and no peer is named.
34
+ TELEMETRY_WINDOW: int = 3600
35
+
36
+
37
+ @dataclasses.dataclass(frozen=True)
38
+ class PresenceConfig(ParamBlock):
39
+ key = 'presence'
40
+ name = 'presence'
41
+ DERIVED: bool = False
42
+ PARAMS = (
43
+ Parameter(
44
+ key='heartbeat', name='Heartbeat Enabled',
45
+ description='Whether to publish periodic heartbeat events',
46
+ type=bool, default=True, tags=('presence', 'heartbeat'),
47
+ ),
48
+ Parameter(
49
+ key='telemetry_window', name='Telemetry Window',
50
+ description='Window size in seconds for telemetry aggregation',
51
+ type=int, default=3600, minimum=60, tags=('presence', 'telemetry'),
52
+ ),
53
+ )
54
+ heartbeat: bool = True
55
+ telemetry_window: int = 3600
56
+
57
+
58
+ class Presence(Compartment):
59
+ requires: tuple[str, ...] = ('budget', 'strain') # strain totals are one of the five telemetry metrics
60
+
61
+ name: str = 'presence'
62
+ # `heartbeat` and `class` are read every tick but neither is live: a node's
63
+ # class is what the node IS, and a heartbeat switched off under a running
64
+ # node would take its liveness off the mesh without anything on the mesh
65
+ # being told. Both change on a restart.
66
+ reads: tuple[str, ...] = ('heartbeat', 'class', 'telemetry')
67
+ config_cls = PresenceConfig
68
+ NEEDS = (
69
+ Need('keystore', 'ks'),
70
+ Need('peers'),
71
+ Need('grant_store', 'stores.grants'),
72
+ Need('relays'),
73
+ Need('budget'),
74
+ Need('stop'),
75
+ Need('timeout'),
76
+ Need('strain'),
77
+ Need('descriptor'),
78
+ Need('rebuild_descriptor', 'build_descriptor'),
79
+ Need('transport'),
80
+ )
81
+ TELEMETRY_WINDOW: int = TELEMETRY_WINDOW
82
+ CHANNELS = (
83
+ Channel('peer_retiring', 'Peer indicated graceful retirement', level=NOTE,
84
+ say='peer {peer} says it is retiring; the partition that follows is not stress from that direction'),
85
+ Channel('inbound_event', 'Inbound heartbeat event processed', level=INFO,
86
+ say='< {event}'),
87
+ )
88
+ METRICS = (
89
+ Metric(name='presence_peers_retiring', description='Times peers reported retirement.',
90
+ kind=COUNTER, counts='peer_retiring', tags=('presence', 'peers')),
91
+ Metric(name='presence_events_inbound', description='Times heartbeat events were processed.',
92
+ kind=COUNTER, counts='inbound_event', tags=('presence', 'heartbeat')),
93
+ )
94
+ EVERY = (Every(seconds=TELEMETRY_WINDOW, run='_run_telemetry'),)
95
+ WORKERS = (Worker(run='_listen', name='presence-listen'),)
96
+ STORES = (
97
+ st.Store(
98
+ key='_seen',
99
+ description='peer -> the id of the last heartbeat read from it',
100
+ bound=1024,
101
+ evict=st.LRU,
102
+ kind=st.TABLE,
103
+ keyed_by='peer',
104
+ ),
105
+ st.Store(
106
+ key='_retiring',
107
+ description='peers that said they were going, and when',
108
+ bound=1024,
109
+ evict=st.LRU,
110
+ kind=st.TABLE,
111
+ keyed_by='peer',
112
+ ),
113
+ )
114
+ COMMANDS = (
115
+ Command(
116
+ key='show',
117
+ name='Presence',
118
+ description='the descriptor this node publishes, when it expires, and the peers it '
119
+ 'has heard from',
120
+ tags=('presence', 'status'),
121
+ permission='presence/show',
122
+ run='state',
123
+ reply='{descriptor, expires, renews_at, last_heartbeat, inbox_relays, heard_from, '
124
+ 'retiring}',
125
+ ),
126
+ )
127
+
128
+ def __init__(
129
+ self,
130
+ keystore: Any,
131
+ peers: Any,
132
+ grant_store: Any,
133
+ relays: Sequence[str],
134
+ budget: Optional[Any] = None,
135
+ stop: Optional[threading.Event] = None,
136
+ clock: Optional[Callable[[], int]] = None,
137
+ timeout: float = 15.0,
138
+ node: Optional[Any] = None,
139
+ strain: Optional[Any] = None,
140
+ descriptor: Optional[Callable[[], Optional[NostrEvent]]] = None,
141
+ rebuild_descriptor: Optional[Callable[[], NostrEvent]] = None,
142
+ transport: Optional[Any] = None,
143
+ publisher: Optional[Callable[[Sequence[str], Any], Any]] = None,
144
+ ) -> None:
145
+ super().__init__(node, clock)
146
+ # How a heartbeat, telemetry or renewed descriptor reaches the relays;
147
+ # given so a suite can record them without a socket.
148
+ self.publisher: Callable[[Sequence[str], Any], Any] = publisher or relay.publish_all
149
+ # The transport whose nostr-rung counters the telemetry event reports.
150
+ self.transport: Optional[Any] = transport if transport is not None else getattr(node, 'transport', None)
151
+ # The node's current descriptor, and the one call that rebuilds it
152
+ # from what the compartments say now. Suites that pass a fake node
153
+ # get both off it.
154
+ self.descriptor: Callable[[], Optional[NostrEvent]] = descriptor or (lambda: getattr(node, 'descriptor', None))
155
+ self.rebuild_descriptor: Callable[[], NostrEvent] = rebuild_descriptor or (lambda: getattr(node, 'build_descriptor')())
156
+ self.ks: Any = keystore
157
+ self.peers: Any = peers
158
+ self.grants: Any = grant_store
159
+ self.relays: list[str] = list(relays) # not a store: the configured relays
160
+ self.budget: Optional[Any] = budget
161
+ self.strain: Optional[Any] = strain
162
+ self.timeout: float = timeout
163
+ self._stop: threading.Event = stop or getattr(self, '_stop_event', None) or threading.Event()
164
+ self._last_heartbeat: Optional[int] = None
165
+ self._last_telemetry: Optional[int] = None
166
+ # The relay list this node last published (kind 10050): the relays it
167
+ # reads, as a tuple, so a changed set is published once and an
168
+ # unchanged one never again.
169
+ self._inbox_said: Optional[tuple[str, ...]] = None
170
+ # The event itself, for whoever projects the floor (the linked face).
171
+ self.last_inbox: Optional[NostrEvent] = None
172
+ self._seen: st.Table[str, str] # peer -> the id of the last heartbeat read from it
173
+ # Peers that said they were going, and when. A partition from one of
174
+ # these is not stress: this node was told. Cleared when the peer is
175
+ # heard from again without the flag, because that is a node that came
176
+ # back and is expecting to be counted like any other.
177
+ self._retiring: st.Table[str, int]
178
+
179
+ # ---- what this node says --------------------------------------------------
180
+ def _renews_at(self) -> tuple[int, int]:
181
+ """A descriptor is renewed inside the last tenth of its life."""
182
+ desc = self.descriptor()
183
+ created_at = desc.get('created_at', self.clock()) if desc else self.clock()
184
+ exp = None
185
+ if desc:
186
+ for t in desc.get('tags', []):
187
+ if isinstance(t, (list, tuple)) and len(t) > 1 and t[0] == 'expiration':
188
+ try:
189
+ exp = int(t[1])
190
+ break
191
+ except (ValueError, TypeError):
192
+ pass
193
+ if exp is None:
194
+ exp = created_at + 86400
195
+ return exp, exp - (exp - created_at) // 10
196
+
197
+ def publish_inbox(self, now: int) -> Optional[NostrEvent]:
198
+ """The kind 10050 that states this node's relay floor: the relays it
199
+ reads are where a stored wrap for it goes (NIP-17). Every node
200
+ publishes one - the relay rung is the one way a node can always be
201
+ reached, and this is where that is said, in the place any Nostr
202
+ client already looks. Published on the first tick and again only
203
+ when the set changes; None when nothing was due."""
204
+ if not self.relays:
205
+ return None
206
+ now_set = tuple(self.relays)
207
+ if self._inbox_said == now_set:
208
+ return None
209
+ tags = relaylist.build(self.relays)
210
+ ev: NostrEvent = ref_store.make_signed_event(self.ks.node_sk, now, relaylist.KIND, tags, '')
211
+ self.publisher(self.relays, ev)
212
+ self._inbox_said = now_set
213
+ self.last_inbox = ev
214
+ return ev
215
+
216
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
217
+ did: list[tuple[Any, ...]] = []
218
+ inbox = self.publish_inbox(now)
219
+ if inbox is not None:
220
+ did.append(('inbox relays published', inbox['id']))
221
+ exp, renew = self._renews_at()
222
+ if now >= renew:
223
+ desc = self.rebuild_descriptor()
224
+ if self.relays:
225
+ self.publisher(self.relays, desc)
226
+ did.append(('descriptor renewed', desc['id']))
227
+ # anchors must, edges should, leaves never
228
+ if self.config('heartbeat', True) and lifecycle.heartbeat_due(
229
+ self.config('class'), self._last_heartbeat, now):
230
+ cur = self.descriptor()
231
+ eps = [t for t in cur['tags'] if t[0] == 'endpoint'] if cur is not None else []
232
+ hb = lifecycle.heartbeat(self.ks.node_pk, self.config('class'), eps, now)
233
+ signed_hb = ref_store.make_signed_event(self.ks.node_sk, now, hb['kind'], hb['tags'], hb['content'])
234
+ if self.relays:
235
+ self.publisher(self.relays, signed_hb)
236
+ self._last_heartbeat = now
237
+ did.append(('heartbeat', signed_hb['id']))
238
+ did += self.run_every(now)
239
+ return did
240
+
241
+ _tick_runs_every = True
242
+
243
+ def _run_telemetry(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
244
+ now = now if now is not None else self.clock()
245
+ if not (self.config('telemetry', False) and self.relays):
246
+ return []
247
+ tm = self._telemetry(now)
248
+ signed_tm = ref_store.make_signed_event(self.ks.node_sk, now, tm['kind'], tm['tags'], tm['content'])
249
+ self.publisher(self.relays, signed_tm)
250
+ self._last_telemetry = now
251
+ return [('telemetry', signed_tm['id'])]
252
+
253
+ # One kind 21802 per window. The counters are the transport's and the
254
+ # strain ledger's totals, binned by fleetmesh_core/telemetry so nothing finer than
255
+ # an order of magnitude leaves the node and no peer is named.
256
+
257
+ def _telemetry(self, now: int) -> Any:
258
+ tr = getattr(self.transport, 'nostr', None) if self.transport is not None else None
259
+ counters: dict[str, int] = {'peers': len(self.grants.peers())}
260
+ if tr is not None:
261
+ counters['wraps_in'] = sum(getattr(tr, 'wraps_in', {}).values())
262
+ counters['wraps_seen_again'] = sum(getattr(tr, 'wraps_seen_again', {}).values())
263
+ counters['wraps_dropped'] = len(getattr(tr, 'dropped', ()))
264
+ if self.strain is not None:
265
+ counters['strain'] = sum(self.strain.by_direction().values())
266
+ return telemetry.telemetry(self.ks.node_pk, now, self.TELEMETRY_WINDOW, counters,
267
+ expiration=now + 2 * self.TELEMETRY_WINDOW)
268
+
269
+ def announce_retiring(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
270
+ """Say this node is going, before it starts giving things away.
271
+
272
+ Published directly rather than through the tick's due check: a
273
+ retirement does not wait for the hour to come round, and the whole
274
+ value of the flag is that it arrives before the silence it explains.
275
+ A leaf publishes no heartbeat and so cannot announce one, which is
276
+ correct - a leaf claims nothing and hands nothing over.
277
+
278
+ Ephemeral, like every heartbeat: only a peer listening now hears it.
279
+ That is the right answer and it is why it is advisory. A peer that was
280
+ itself down records the partition, and the partition is true.
281
+ """
282
+ now_val = now if now is not None else self.clock()
283
+ if self.config('class') == 'leaf' or not self.relays or not self.config('heartbeat', True):
284
+ return []
285
+ desc = self.descriptor()
286
+ eps = [t for t in desc['tags'] if t[0] == 'endpoint'] if desc is not None else []
287
+ hb = lifecycle.heartbeat(self.ks.node_pk, self.config('class'), eps, now_val, load=1.0, retiring=True)
288
+ signed = ref_store.make_signed_event(self.ks.node_sk, now_val, hb['kind'], hb['tags'], hb['content'])
289
+ self.publisher(self.relays, signed)
290
+ self._last_heartbeat = now_val
291
+ return [('retiring announced', signed['id'])]
292
+
293
+ def is_retiring(self, peer_pk: str) -> bool:
294
+ """Did this peer tell us it was going? Read by anything that would
295
+ otherwise price the silence that follows."""
296
+ return peer_pk in self._retiring
297
+
298
+ # ---- what a peer says -----------------------------------------------------
299
+ def on_heartbeat(self, hb: Any) -> list[tuple[Any, ...]]:
300
+ """One kind 21801 from a peer, checked against what that peer published.
301
+
302
+ A node published one of these every hour and nothing ever read one, so
303
+ two rules the reference already implements were dead. A heartbeat whose
304
+ class contradicts the peer's live descriptor is a misreport about
305
+ itself and an S2. One whose endpoint digest differs from the
306
+ descriptor's says the descriptor has gone stale, which is the whole
307
+ reason that digest is in there: a peer can tell without dialling every
308
+ address it was given.
309
+
310
+ A heartbeat is *ephemeral*, so no relay stores one and none can be
311
+ asked for after the fact. Liveness is a statement about now, and the
312
+ only way to hear it is to be listening when it is made - which is why
313
+ this is fed by a subscription and not by a query. A node that was down
314
+ misses them, and that is the right answer.
315
+ """
316
+ did: list[tuple[Any, ...]] = []
317
+ # A subscription by author returns whatever a relay chooses to serve
318
+ # under that name. Believing it unsigned would let a relay file an S2
319
+ # against any peer this node holds a grant with.
320
+ if not isinstance(hb, dict) or not giftwrap.verify_event(hb) or hb.get('kind') != lifecycle.HEARTBEAT:
321
+ self.refusals.refuse('heartbeat', 'not a signed kind 21801',
322
+ subject=(hb or {}).get('pubkey') if isinstance(hb, dict) else None)
323
+ return did
324
+ pk = hb.get('pubkey')
325
+ if not isinstance(pk, str) or pk not in set(self.grants.peers()):
326
+ self.refusals.refuse('heartbeat', 'from a node this one holds no grant with', subject=pk)
327
+ return did
328
+ hb_id = hb.get('id')
329
+ if not isinstance(hb_id, str) or self._seen.get(pk) == hb_id:
330
+ return did
331
+ self._seen[pk] = hb_id
332
+ desc = self.peers.descriptor(pk)
333
+ if desc is None:
334
+ self.refusals.refuse('heartbeat', 'the peer publishes no descriptor to check it against', subject=pk)
335
+ return did
336
+ problems, findings = lifecycle.check_heartbeat(hb, desc)
337
+ for severity, why in findings:
338
+ if self.budget is not None:
339
+ self.budget.findings.append((pk, None, severity, 'heartbeat: %s' % why))
340
+ self.refusals.refuse('heartbeat', 'contradicts the descriptor the peer publishes',
341
+ subject=pk, detail={'severity': severity, 'what': why})
342
+ did.append(('heartbeat contradicts its descriptor', pk, severity))
343
+ if lifecycle.is_retiring(hb):
344
+ # Not a finding and not a fault. The peer is telling us the silence
345
+ # that follows is a departure, so nothing downstream prices it.
346
+ if pk not in self._retiring:
347
+ self._retiring[pk] = self.clock()
348
+ self.say('peer_retiring', peer=pk[:16])
349
+ did.append(('peer is retiring', pk))
350
+ elif self._retiring.pop(pk, None) is not None:
351
+ did.append(('peer came back', pk))
352
+ if problems:
353
+ self.refusals.refuse('heartbeat', 'malformed', subject=pk,
354
+ detail={'problems': problems[:2]})
355
+ did.append(('heartbeat is malformed', pk, '; '.join(problems[:2])))
356
+ elif lifecycle.descriptor_is_stale(hb, desc):
357
+ self.peers.descriptor(pk, refresh=True)
358
+ did.append(('descriptor was stale, refreshed', pk))
359
+ return did
360
+
361
+ def _listen(self) -> None:
362
+ """One subscription per relay for the liveness of peers we hold grants with."""
363
+ while not self._stop.is_set():
364
+ peers_held = self.grants.peers()
365
+ if not peers_held or not self.relays:
366
+ self._stop.wait(30)
367
+ continue
368
+ r = relay.Relay(self.relays[0], timeout=5.0)
369
+ try:
370
+ sid = r.connect().subscribe([{'kinds': [lifecycle.HEARTBEAT], 'authors': peers_held}])
371
+ started, watching = time.time(), set(peers_held)
372
+ for hb in r.events(sid):
373
+ if self._stop.is_set():
374
+ break
375
+ for d in self.on_heartbeat(hb):
376
+ self.say('inbound_event', event=' '.join(journal.short(x) for x in d))
377
+ # re-subscribe when the peer set has moved on
378
+ if time.time() - started > 300 and set(self.grants.peers()) != watching:
379
+ break
380
+ except Exception as e:
381
+ self.refusals.refuse('subscription', 'could not be held open',
382
+ subject=self.relays[0], detail={'error': str(e)})
383
+ finally:
384
+ r.close()
385
+ self._stop.wait(5)
386
+
387
+ # ---- being watched --------------------------------------------------------
388
+ def state(self) -> PresenceStateReport:
389
+ desc = self.descriptor()
390
+ exp, renew = self._renews_at() if desc else (None, None)
391
+ return {
392
+ 'descriptor': desc['id'] if desc else None,
393
+ 'expires': exp,
394
+ 'renews_at': renew,
395
+ 'last_heartbeat': self._last_heartbeat,
396
+ 'inbox_relays': list(self._inbox_said or ()),
397
+ 'heard_from': sorted(pk[:16] for pk in self._seen),
398
+ 'retiring': sorted(pk[:16] for pk in self._retiring),
399
+ }
400
+
401
+ def trouble(self) -> list[str]:
402
+ out: list[str] = []
403
+ wrong = self.refusals.within(self.TROUBLE_WINDOW, self.clock(), kind='heartbeat',
404
+ why='contradicts the descriptor the peer publishes')
405
+ if wrong:
406
+ out.append('%d heartbeat(s) contradict the descriptor their peer publishes: %s. '
407
+ 'Each is a finding against that peer'
408
+ % (len(wrong), ', '.join(sorted({(r.subject or '')[:16] for r in wrong}))))
409
+ return out
@@ -0,0 +1,14 @@
1
+ """Types and records for the Presence compartment."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional, TypedDict
5
+
6
+
7
+ class PresenceStateReport(TypedDict):
8
+ descriptor: Optional[str]
9
+ expires: Optional[int]
10
+ renews_at: Optional[int]
11
+ last_heartbeat: Optional[int]
12
+ inbox_relays: list[str]
13
+ heard_from: list[str]
14
+ retiring: list[str]
@@ -0,0 +1,13 @@
1
+ """Operations plane: node lifecycle, task scheduling, health, and software updates.
2
+
3
+ The 2 compartments of the operations plane:
4
+ - scheduler: cadence pacing, peer sync throttling, and task scheduling
5
+ - updates: check-and-report software updates and release notifications
6
+ """
7
+ from .scheduler import Scheduler
8
+ from .updates import Updates
9
+
10
+ __all__ = [
11
+ 'Scheduler',
12
+ 'Updates',
13
+ ]
@@ -0,0 +1,9 @@
1
+ """The pulls this node is configured to make, and every reason one did not happen.
2
+
3
+ One-module compartment: everything is in compartment.py.
4
+ """
5
+ from .compartment import Scheduler
6
+ from .types import SchedulerPullStatus, SchedulerStateReport
7
+
8
+ __all__ = ['Scheduler', 'SchedulerPullStatus', 'SchedulerStateReport']
9
+