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
node/app/core.py ADDED
@@ -0,0 +1,867 @@
1
+ """NodeApp: what `meshnode run` starts. Construction, the compartment
2
+ assembly in start(), the run loop, the inbound door and status.json.
3
+
4
+ Separate from control.py so the file that registers the compartments - the
5
+ one scripts/check-cli.py reads for tick order - holds nothing else.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import collections
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ from typing import Any, Callable, Mapping, MutableMapping, Sequence, cast
15
+
16
+ from ..config.node import NodeConfigDict
17
+
18
+ from fleetmesh_core.accounting import standing
19
+ from fleetmesh_core.identity import identity
20
+ from fleetmesh_core.peering import peering
21
+ from .. import (channels as channels_mod, compartment as compartment_mod,
22
+ keystore, metrics as metrics_mod, transport as transport_mod)
23
+ from ..channels import journal
24
+ from fleetmesh_core.wire import validate
25
+ from ..protocols.nostr import blobstore as blobstore_mod, eventstore, peers as peers_mod
26
+ from ..storage import accounts as accounts_mod, grants as grants_mod
27
+ from ..protocols import ProtocolManager
28
+ from ..transport.host import ProtocolHost
29
+ from . import router as router_mod
30
+ from ..compartment import context as context_mod
31
+ from ..metrics import adapters as metrics_adapters
32
+ from ..metrics.face import face_metrics
33
+ from ..protocols.nostr import client as relay, descriptor
34
+ from ..planes.identity.announce import compartment as announce_mod
35
+ from ..planes.identity.discovery import compartment as discovery_wire
36
+ from ..planes.identity.presence import compartment as presence_mod
37
+ from ..planes.identity.linked import compartment as linked_mod
38
+ from ..planes.store.sync import core as sync_wire
39
+ from ..planes.store.retention import compartment as retention_mod
40
+ from ..planes.store.blob_wire import core as blob_wire_mod
41
+ from ..planes.store.blob_replication import compartment as blob_repl_mod
42
+ from ..planes.peering.trust import compartment as trust_mod
43
+ from ..planes.peering.strain import compartment as strain_mod
44
+ from ..planes.peering.peering import core as peering_wire
45
+ from ..planes.peering.budget import core as budget_mod
46
+ from ..planes.peering.capacity import compartment as capacity_mod
47
+ from ..planes.peering.reporter import core as reporter_mod
48
+ from ..planes.transport.link import compartment as link_mod
49
+ from ..planes.transport.lan import compartment as lan_mod
50
+ from ..planes.transport.p2p import compartment as p2p_wire
51
+ from ..planes.transport.mediation import compartment as mediation_mod
52
+ from ..planes.transport.bridge import compartment as bridge_mod
53
+ from ..planes.services.gossip import core as gossip_wire
54
+ from ..planes.services.compute import compartment as compute_mod
55
+ from ..planes.services.compute_wire import compartment as compute_wire_mod
56
+ from ..planes.services.settle import compartment as settlement_mod
57
+ from ..planes.services.paidfetch import compartment as fetch_pricing_mod
58
+ from ..planes.services.sidecars import compartment as sidecars_mod
59
+ from ..planes.services.dvm import gateway as dvm_mod
60
+ from ..planes.services.nsite import compartment as nsite_mod
61
+ from ..planes.services.video import compartment as video_mod
62
+ from ..planes.operations.scheduler import compartment as scheduler_mod
63
+ from ..planes.operations.updates import core as updates_mod
64
+ from ..compartment import lifecycle as lifecycle_mod
65
+ from . import socket as control_mod
66
+ from ..config import NodeConfig
67
+ from ..interfaces import NostrEvent
68
+ from .config import _blob_storage_cap, load_config, typed_config
69
+ from .control import ControlMixin
70
+ from .plugins import load_plugins, plugin_sidecars
71
+ from .systemd import sd_notify
72
+ from .views import PLANES as _VIEW_PLANES, node_health, node_status
73
+
74
+ # How anything in the node says something an operator will read, and how a
75
+ # trust entry is understood. Both live where a compartment can reach them
76
+ # without importing the node that imports the compartment.
77
+ _line, _short = journal.line, journal.short
78
+ trust_decision, trust_tiers = trust_mod.decision, trust_mod.tiers
79
+
80
+ # The node's compartments in tick order. This is the one list scripts/check-cli.py,
81
+ # the `subsystems` command and the docs agree on. The order is a decision, not
82
+ # a derivation: the relay journal first, so a subscription that dropped is said
83
+ # before anything that depends on it; then the operator's decisions, which
84
+ # outrank the ladder; then the mechanisms; then what the node publishes about
85
+ # itself; then the pulls, which are the only load it chooses; then the paid and
86
+ # fronted services, which are inert until configured. Optional compartments sit
87
+ # where they tick and are built only when their config section is present.
88
+ MANIFEST: tuple[type[compartment_mod.Compartment], ...] = (
89
+ link_mod.Link,
90
+ trust_mod.Trust,
91
+ strain_mod.StrainLedger,
92
+ peering_wire.Peering,
93
+ budget_mod.Budget,
94
+ capacity_mod.Capacity,
95
+ sync_wire.SyncPeer,
96
+ reporter_mod.Reporter,
97
+ discovery_wire.Discovery,
98
+ announce_mod.Announce,
99
+ lan_mod.Lan,
100
+ p2p_wire.P2PTransport, # optional: cfg['p2p']
101
+ gossip_wire.GossipMesh, # optional: cfg['gossip']
102
+ presence_mod.Presence,
103
+ linked_mod.LinkedFace, # the schema.org face; reads presence's 10050 and budget's ledger
104
+ retention_mod.Retention,
105
+ scheduler_mod.Scheduler,
106
+ compute_mod.Compute, # optional: cfg['compute']
107
+ compute_wire_mod.ComputeWire, # optional: cfg['compute']
108
+ bridge_mod.RelayBridge, # optional: cfg['bridge']
109
+ settlement_mod.Settlement,
110
+ mediation_mod.Mediation,
111
+ blob_wire_mod.BlobWire,
112
+ blob_repl_mod.Replication,
113
+ fetch_pricing_mod.PaidFetch,
114
+ sidecars_mod.Sidecars,
115
+ dvm_mod.DVMGateway, # optional: cfg['dvm']
116
+ nsite_mod.NsiteGateway, # optional: cfg['nsite']; after bridge, which it uses for manifests
117
+ video_mod.VideoTransitCompartment, # optional: cfg['video']; after nsite, the other HTTP face
118
+ updates_mod.Updates, # optional: cfg['updates']; last - it reports on all the above
119
+ )
120
+
121
+
122
+ class NodeApp(ControlMixin):
123
+ # The compartments, set on the node by Registry.assemble() under each
124
+ # class's `attr` (or `name`). Declared so the node's surface is stated,
125
+ # and so the checker knows it: setattr is invisible to mypy. The six (6)
126
+ # optional ones - p2p, gossip, compute, bridge, dvm, updates - are absent
127
+ # on a node whose config does not enable them.
128
+ manifest: tuple[type[compartment_mod.Compartment], ...] # MANIFEST plus any configured plugins
129
+ link: link_mod.Link
130
+ trust: trust_mod.Trust
131
+ strain: strain_mod.StrainLedger
132
+ peering: peering_wire.Peering
133
+ budget: budget_mod.Budget
134
+ capacity: capacity_mod.Capacity
135
+ sync: sync_wire.SyncPeer
136
+ reporter: reporter_mod.Reporter
137
+ discovery: discovery_wire.Discovery
138
+ announce: announce_mod.Announce
139
+ lan: lan_mod.Lan
140
+ p2p: p2p_wire.P2PTransport
141
+ gossip: gossip_wire.GossipMesh
142
+ presence: presence_mod.Presence
143
+ retention: retention_mod.Retention
144
+ scheduler: scheduler_mod.Scheduler
145
+ compute: compute_mod.Compute
146
+ compute_wire: compute_wire_mod.ComputeWire
147
+ bridge: bridge_mod.RelayBridge
148
+ settlement: settlement_mod.Settlement
149
+ mediation: mediation_mod.Mediation
150
+ blob_wire: blob_wire_mod.BlobWire
151
+ blob_replication: blob_repl_mod.Replication
152
+ paid_fetch: fetch_pricing_mod.PaidFetch
153
+ sidecars: sidecars_mod.Sidecars
154
+ dvm: dvm_mod.DVMGateway
155
+ video: video_mod.VideoTransitCompartment
156
+
157
+ def __init__(self, directory: str, cfg: Mapping[str, Any] | None = None,
158
+ clock: Callable[[], int] | None = None,
159
+ relays_override: Sequence[str] | None = None) -> None:
160
+ self.dir: str = directory
161
+ self.cfg: MutableMapping[str, Any] = dict(cfg if cfg is not None else load_config(directory))
162
+ # The typed view of the same file. Compartments get their block through
163
+ # NodeContext.block(); the ambient keys the node itself reads are below.
164
+ self.settings: NodeConfig = typed_config(self.cfg)
165
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
166
+ self.relays: list[str] = list(relays_override if relays_override is not None else self.cfg['relays'])
167
+ self.ks: keystore.Keystore = keystore.Keystore.load_or_generate(directory)
168
+ self.store: eventstore.NodeStore = eventstore.NodeStore(os.path.join(directory, 'events.sqlite'))
169
+ self.grants: grants_mod.GrantStore = grants_mod.GrantStore(os.path.join(directory, 'grants.sqlite'))
170
+ self.accounts: accounts_mod.AccountStore = accounts_mod.AccountStore(os.path.join(directory, 'accounts.sqlite'))
171
+ blobs_cfg = self.settings.blobs
172
+ self.blobs: blobstore_mod.BlobStore = blobstore_mod.BlobStore(
173
+ os.path.join(directory, 'blobs'), max_storage_bytes=_blob_storage_cap(self.cfg),
174
+ fanout=blobs_cfg.fanout, data_dir=blobs_cfg.data_dir)
175
+ # What this node measures, declared by the compartments rather than
176
+ # written out here. Built before the face so the face's
177
+ # `metrics_provider` can close over it; the declarations arrive later,
178
+ # at assemble(), and `read()` walks whatever is registered when a
179
+ # scrape happens.
180
+ self.monitors: metrics_mod.Monitors = metrics_mod.Monitors()
181
+
182
+ # The unified protocol host multiplexer
183
+ host_cfg = self.settings.protocol_suites.host
184
+ host_ip = host_cfg.listen if (host_cfg.listen != '127.0.0.1' or not self.settings.face.host) else self.settings.face.host
185
+ host_port = host_cfg.port if (host_cfg.port != 0 or not self.settings.face.port) else self.settings.face.port
186
+ self.host: ProtocolHost = ProtocolHost(
187
+ host=host_ip,
188
+ port=host_port,
189
+ name=self.settings.name,
190
+ clock=self.clock,
191
+ )
192
+
193
+ # The protocol manager orchestrating all active suites
194
+ self.protocols: ProtocolManager = ProtocolManager(
195
+ node_dir=directory,
196
+ keystore=self.ks,
197
+ clock=self.clock,
198
+ name=self.settings.name,
199
+ )
200
+ self.protocols.load_suites_config(
201
+ self.settings.protocol_suites,
202
+ backend_store=self.store,
203
+ blob_store=self.blobs,
204
+ grants=self.grants,
205
+ node_instance=self,
206
+ strain_ledger=getattr(self, 'strain', None),
207
+ )
208
+ self.protocols.mount_ingress(self.host)
209
+
210
+ nostr_pkg = self.protocols.get_package('nostr')
211
+ relay_srv = getattr(nostr_pkg, 'relay_server', None) if nostr_pkg is not None else None
212
+ self.face: Any = relay_srv
213
+ if relay_srv is not None:
214
+ relay_srv.metrics_provider = metrics_adapters.provider(self.monitors)
215
+ self.monitors.register(face_metrics(relay_srv), owner='face')
216
+ if relay_srv.blossom is not None:
217
+ apm = self.settings.storage.allow_private_mirror
218
+ relay_srv.blossom.allow_private_mirror = list(apm) if isinstance(apm, tuple) else apm
219
+
220
+ self.descriptor: NostrEvent | None = None
221
+ self.timeout: float = 15.0
222
+ self.peers: peers_mod.Peers = peers_mod.Peers(self.relays, clock=self.clock)
223
+ self.transport: transport_mod.TransportRouter = transport_mod.TransportRouter(
224
+ self.ks, self.peers, self.relays,
225
+ endpoints=self.cfg.get('endpoints', []),
226
+ clock=self.clock,
227
+ seen_store=(self.accounts.seen_wrap, self.accounts.put_seen_wrap),
228
+ ack_store=self.accounts,
229
+ sidecar_dir=self.dir,
230
+ enable_nostr=(nostr_pkg is not None),
231
+ )
232
+ self.protocols.mount_transport_rungs(self.transport)
233
+ pol = self.settings.policy
234
+ self.policy: peering.Policy = peering.Policy(pow_bits=self.settings.pow_bits, open_enrolment=pol.open_enrolment,
235
+ protocols=list(pol.protocols),
236
+ tiers=trust_tiers(self.cfg.get('trust')))
237
+ self.ledger: standing.Ledger = standing.Ledger()
238
+ self.events: channels_mod.NodeEventBus = channels_mod.NodeEventBus()
239
+ self.events.tap = channels_mod.Tap(clock=self.clock)
240
+ # What this node says, fanned out from one declaration per event.
241
+ # After `self.events`, because the bus is one of the places a record
242
+ # goes: anything already subscribed to a topic by name keeps working.
243
+ # Not `self.log`: that name is already a 500-entry deque of tick and
244
+ # handler failures (below), and shadowing it would have silently
245
+ # replaced one with the other. `says` is what this does.
246
+ self.says: channels_mod.Log = channels_mod.Log(
247
+ backends=[channels_mod.ConsoleBackend()], clock=self.clock,
248
+ bus=self.events, monitors=self.monitors)
249
+ self.stores: context_mod.NodeStores = context_mod.NodeStores(
250
+ events=self.store,
251
+ grants=self.grants,
252
+ accounts=self.accounts,
253
+ blobs=self.blobs,
254
+ )
255
+ self.router: router_mod.ProtocolRouter = router_mod.ProtocolRouter()
256
+ self._stop: threading.Event = threading.Event()
257
+ self.context: context_mod.NodeContext = context_mod.NodeContext(
258
+ dir=self.dir, ks=self.ks, cfg=cast(NodeConfigDict, self.cfg), clock=self.clock, events=self.events,
259
+ stores=self.stores, relays=self.relays, transport=self.transport, router=self.router,
260
+ face=self.face, host=self.host, peers=self.peers, policy=self.policy, ledger=self.ledger,
261
+ timeout=self.timeout, stop=self._stop, node=self, persist_cfg=self.save_coverage,
262
+ redescribe=self.redescribe,
263
+ regrant=self.regrant,
264
+ propose=self.propose,
265
+ pull=self.pull,
266
+ amend=self._amend,
267
+ build_descriptor=self.build_descriptor,
268
+ says=self.says,
269
+ )
270
+ # What was a dozen dicts here is now the state of the compartment that
271
+ # keeps it: the pulls belong to the scheduler, the publications to
272
+ # discovery, the descriptor and the heartbeats to presence, the relay
273
+ # journal to link. The node keeps only what the node owns.
274
+ self.log: collections.deque[Any] = collections.deque(maxlen=500)
275
+ self.counts: collections.Counter[str] = collections.Counter() # handler failed, tick failed, config rejected
276
+ self.rejected: collections.Counter[str] = collections.Counter() # messages refused at the door, by reason
277
+ self._last_said: dict[str, float] = {} # rate limit on repeated log lines
278
+ self._last_tick: Sequence[tuple[Any, ...]] = () # what the last tick did, for status.json
279
+ self._last_tick_error: str | None = None
280
+ self._config_error: str | None = None
281
+ self._tick_lock: threading.RLock = threading.RLock() # the run loop and the control socket both tick
282
+ self._built: bool = False
283
+ self._stopped: bool = False
284
+ # Config blocks edited on disk that wait for a node restart, as last
285
+ # said: their owner does not declare restart, so the edit is reported
286
+ # once per change rather than once a tick.
287
+ self._waiting: MutableMapping[str, Any] = {}
288
+
289
+ # ---- assembly ----------------------------------------------------------------
290
+ DESCRIPTOR_FILE: str = 'descriptor.json'
291
+ # Present while descriptor.json holds a descriptor no relay has accepted
292
+ # yet. start() mines before it assembles (peering reads the descriptor at
293
+ # assembly) but publishes only once the compartments have started, so a
294
+ # refused start leaves a mined, unpublished descriptor behind. Reusing it
295
+ # keeps the proof of work; this marker is what makes the next start that
296
+ # does succeed publish it, rather than assume the relays already have it.
297
+ PENDING_FILE: str = 'descriptor.pending'
298
+
299
+ def _endpoints(self) -> list[tuple[str, str, int]]:
300
+ """What the descriptor advertises, which is what the operator configured.
301
+
302
+ It used to fall back to this node's own relay face, and the face binds
303
+ loopback on an ephemeral port by default: the descriptor then advertised
304
+ an address no peer could reach, and one that changed on every restart,
305
+ so the proof of work could never be reused either. `endpoint` is
306
+ optional on a kind 11801. A node with nothing reachable to advertise
307
+ says nothing, and is still reachable over the nostr transport, which is
308
+ the binding the specification says always works.
309
+ """
310
+ return [(e['transport'], e['address'], e.get('priority', 50)) for e in self.cfg['endpoints']]
311
+
312
+ def load_descriptor(self) -> NostrEvent | None:
313
+ """The descriptor this node published last time, if it is still the one
314
+ it would publish now.
315
+
316
+ A descriptor costs a NIP-13 proof of work to mine - twenty bits, which
317
+ took fourteen seconds on a small droplet - and it is a replaceable
318
+ event, so republishing a fresh one on every start burns that work and
319
+ churns the relay for nothing. The first two-machine run re-mined on
320
+ every service restart because nothing here read the file back. It is
321
+ reused only when it would still be published unchanged: valid under the
322
+ digest this node speaks, not yet inside its renewal window, and
323
+ describing the same class and endpoints the config now asks for.
324
+ """
325
+ path = os.path.join(self.dir, self.DESCRIPTOR_FILE)
326
+ if not os.path.exists(path):
327
+ return None
328
+ try:
329
+ with open(path, encoding='utf-8') as f:
330
+ d = json.load(f)
331
+ except (ValueError, OSError):
332
+ return None
333
+ now = self.clock()
334
+ if identity.validate_descriptor(d, now=now, digest=descriptor.DIGEST):
335
+ return None
336
+ exp = int([t for t in d['tags'] if t[0] == 'expiration'][0][1])
337
+ if now >= exp - (exp - d['created_at']) // 10:
338
+ return None
339
+ want = [['endpoint', s, a, str(p)] for s, a, p in sorted(self._endpoints(), key=lambda e: (int(e[2]), e[0]))]
340
+ if [t for t in d['tags'] if t[0] == 'endpoint'] != want:
341
+ return None
342
+ cls = [t[1] for t in d['tags'] if t[0] == 'class'][0]
343
+ # Optional protocols (the sidecar capabilities) are declared by the
344
+ # compartment that verifies them, after start, and are not config:
345
+ # they neither qualify nor disqualify a reuse here. `sidecars` brings
346
+ # the descriptor into agreement with the live self-tests itself.
347
+ optional = set(identity.CONSTANTS['descriptor'].get('optional_protocols', ()))
348
+ if cls != self.cfg['class'] or identity.protocols_of(d) - optional != set(self.cfg['protocols']) | set(identity.REQUIRED_PROTOCOLS):
349
+ return None
350
+ # The operator is the declaration the independence model reads, and
351
+ # the plain capabilities are what a leaf searches for. Both are
352
+ # config; a change to either is a new descriptor, not a reused one.
353
+ operator = [t[1] for t in d['tags'] if t[0] == 'operator']
354
+ if operator != [self.cfg.get('operator')]:
355
+ return None
356
+ hints = {t[1] for t in d['tags'] if t[0] == 'c' and len(t) > 1 and not t[1].startswith('https://')}
357
+ if hints != set(self.cfg.get('capabilities') or ()):
358
+ return None
359
+ self.descriptor = d
360
+ return d
361
+
362
+ def _descriptor(self) -> NostrEvent:
363
+ """The descriptor once start() has loaded or mined it. Reaching for it
364
+ earlier is a programming error, not a state the node should paper over."""
365
+ if self.descriptor is None:
366
+ raise RuntimeError('the descriptor is not built yet; start() mines or loads it')
367
+ return self.descriptor
368
+
369
+ def build_descriptor(self) -> NostrEvent:
370
+ # Any built compartment may add capability identifiers (`c` tags) and
371
+ # descriptor tags: sidecars declare the protocols they front, paid_fetch
372
+ # its prices, a plugin the dialect it speaks. Before start() assembles
373
+ # the compartments none have anything to add, which is also right.
374
+ protocols = list(self.cfg['protocols'])
375
+ extra_tags: list[list[str]] = []
376
+ subs = getattr(self, 'subsystems', None)
377
+ for sub in (subs or ()):
378
+ add = getattr(sub, 'capability_protocols', None)
379
+ if callable(add):
380
+ protocols += [p for p in add() if p not in protocols]
381
+ tags = getattr(sub, 'descriptor_tags', None)
382
+ if callable(tags):
383
+ extra_tags += list(tags())
384
+ capabilities = list(self.cfg.get('capabilities', ()))
385
+ if getattr(self, 'settings', None) is not None and not self.settings.protocol_suites.nostr.enabled:
386
+ capabilities = [c for c in capabilities if c not in ('relay', 'negentropy', 'blossom')]
387
+ if getattr(self, 'protocols', None) is not None:
388
+ for tag in self.protocols.descriptor_capabilities():
389
+ tag_list = list(tag)
390
+ if len(tag_list) == 2 and tag_list[0] == 'c' and tag_list[1] in capabilities:
391
+ continue
392
+ if tag_list not in extra_tags:
393
+ extra_tags.append(tag_list)
394
+ self.descriptor = descriptor.build(self.ks, self.cfg['class'], self._endpoints(), self.cfg['operator'],
395
+ protocols, now=self.clock(),
396
+ capabilities=capabilities, bits=self.cfg.get('pow_bits'),
397
+ extra_tags=extra_tags)
398
+ path = os.path.join(self.dir, self.DESCRIPTOR_FILE)
399
+ tmp = path + '.tmp'
400
+ with open(tmp, 'w', encoding='utf-8') as f:
401
+ json.dump(self.descriptor, f, indent=2)
402
+ os.replace(tmp, path)
403
+ return self.descriptor
404
+
405
+ def redescribe(self) -> NostrEvent:
406
+ """Rebuild the descriptor from what the compartments say NOW and
407
+ publish it. For a compartment whose declaration changed after start
408
+ (a sidecar's self-tests began passing, or stopped): the kind 11801
409
+ must say what is true, and a replaceable event costs one proof of
410
+ work to correct - cheaper than a leaf dialling a protocol nothing
411
+ here answers."""
412
+ d = self.build_descriptor()
413
+ if self.relays:
414
+ relay.publish_all(self.relays, d, self.timeout)
415
+ return d
416
+
417
+ def start(self) -> NodeApp:
418
+ """Bring the node up. All or nothing: a start that raises leaves no
419
+ bound face, no running transport and no started compartment behind.
420
+
421
+ Audit L1, option c - both halves:
422
+
423
+ a. `_start` puts every point that can refuse - assemble, wire, the
424
+ plugin-origin check, route, and each compartment's start() -
425
+ before anything another machine can see. The face starts serving
426
+ only after the router exists, and a freshly mined descriptor is
427
+ published only after Registry.start returns. (The face's socket
428
+ is bound when the RelayServer is constructed, which is earlier;
429
+ the `from_context` readers hold that object and read its port
430
+ lazily, so none of them needs it serving.)
431
+ b. This wrapper is the backstop. With Registry.start's own unwinding,
432
+ a refused start releases its port at once rather than at process
433
+ exit, and no compartment that started is left running.
434
+
435
+ A refused start therefore publishes nothing. What it mined stays on
436
+ disk under PENDING_FILE and is published by the next start that
437
+ succeeds.
438
+ """
439
+ try:
440
+ return self._start()
441
+ except BaseException:
442
+ self._abandon_start()
443
+ raise
444
+
445
+ def _abandon_start(self) -> None:
446
+ """Release whatever a failed start had taken. Every step is guarded:
447
+ the raise that brought us here is the one the operator must see."""
448
+ self._stop.set()
449
+ steps: list[Callable[[], Any]] = []
450
+ if getattr(self, 'control', None) is not None:
451
+ steps.append(self.control.stop)
452
+ if self._built:
453
+ steps.append(self.subsystems.stop)
454
+ steps += [self.transport.stop]
455
+ if self.face is not None:
456
+ steps.append(self.face.stop)
457
+ if getattr(self, 'protocols', None) is not None:
458
+ steps.append(self.protocols.stop_all)
459
+ if getattr(self, 'host', None) is not None:
460
+ steps.append(self.host.stop)
461
+ for step in steps:
462
+ try:
463
+ step()
464
+ except Exception as e:
465
+ _line(' while abandoning the start:', '%s: %s' % (type(e).__name__, e))
466
+ self._stopped = True
467
+
468
+ def _start(self) -> NodeApp:
469
+ _line('starting', self.ks.did[:22] + '…', 'class', self.cfg['class'])
470
+ pending = os.path.join(self.dir, self.PENDING_FILE)
471
+ if self.descriptor is None and self.load_descriptor() is None:
472
+ mined = self.build_descriptor()
473
+ with open(pending, 'w', encoding='utf-8') as f:
474
+ f.write(mined['id'] + '\n')
475
+ _line(' mined a descriptor', mined['id'][:12] + '…',
476
+ '(%d bits)' % identity.pow_bits(mined['id']))
477
+ elif os.path.exists(pending):
478
+ _line(' reusing a descriptor no relay has accepted yet',
479
+ self._descriptor()['id'][:12] + '…')
480
+ else:
481
+ _line(' reusing the descriptor it already published',
482
+ self._descriptor()['id'][:12] + '…')
483
+ # The compartments, built from one context in the declared tick order.
484
+ # Each class says what it needs (`requires`) and the assembler refuses
485
+ # a manifest that contradicts it; what each one takes from the node is
486
+ # in its own `from_context`, not here. See MANIFEST for the order and
487
+ # the reasons behind it.
488
+ self.subsystems = compartment_mod.Registry(say=self.says.say)
489
+ self.context.descriptor = self._descriptor
490
+ self.context.digest = descriptor.DIGEST
491
+ self.manifest = load_plugins(self.settings.plugins.specs, MANIFEST)
492
+ self.subsystems.assemble(self.context, self.manifest)
493
+ # Every edge that runs against tick order (link records into strain,
494
+ # peering refuses through capacity, budget reports through the
495
+ # reporter, the face asks paid_fetch for a price ...) is named by the
496
+ # compartment that owns it, in its `wire()`, and reached here once
497
+ # the whole manifest exists. The node no longer holds that list.
498
+ self.subsystems.wire(self.context)
499
+ # Every compartment's declared channels and metrics, in one place. A
500
+ # rejected metric is kept in the registry's state rather than raised:
501
+ # one malformed declaration must not stop a node booting, and it is
502
+ # reported by `meshnode health` instead of vanishing.
503
+ self.says.register_from(list(self.subsystems))
504
+ self.monitors.register_from(list(self.subsystems))
505
+ # And the lifecycle's own: every compartment moves through the same
506
+ # machine, so its transitions are the registry's to say and count.
507
+ self.says.register(lifecycle_mod.CHANNELS)
508
+ self.monitors.register(self.subsystems.lifecycle_metrics(), owner='lifecycle')
509
+ # And every declared store's size, bound and counts, labelled by
510
+ # compartment: memory metered without a compartment writing a metric.
511
+ self.monitors.register(self.subsystems.store_metrics(), owner='stores')
512
+ self.settle_engine: settlement_mod.SettlementEngine = self.settlement.engine
513
+ self.mediator: mediation_mod.MediatorService = self.mediation.service
514
+
515
+ # The router is a dumb ladder: each compartment declares the
516
+ # protocols it speaks (`piuris`, `exact_types`) and the registry
517
+ # puts its `handle` behind them. A plugin speaks its own origin; one
518
+ # that claims fleetmesh.org's is refused before it is routed. One
519
+ # that fronts a sidecar declares its manifest(s) as `sidecars`
520
+ # (dicts, or '@/path.json'); they join the registry before the
521
+ # sidecars compartment runs its first self-test below.
522
+ for cls in self.manifest:
523
+ if cls in MANIFEST:
524
+ continue
525
+ for piuri in getattr(cls, 'piuris', ()) or ():
526
+ if piuri.startswith(identity.FLEETMESH):
527
+ raise ValueError('plugin %s claims fleetmesh.org protocol %s; plugins speak their own origin' % (cls.name, piuri))
528
+ for sc in plugin_sidecars(cls):
529
+ self.sidecars.declare(sc)
530
+ self.router = router_mod.ProtocolRouter()
531
+ self.subsystems.route(self.router)
532
+ # What the last process left for the compartments that declare
533
+ # `snapshot`, handed back before any of them starts. Never raises; a
534
+ # refusal is recorded on the compartment's lifecycle and it starts
535
+ # fresh. The file is discarded only once the start succeeds, so a
536
+ # refused start does not cost the next one its continuity.
537
+ saved, saved_at, why_not = lifecycle_mod.read_snapshots(self.dir, self.clock())
538
+ if why_not:
539
+ _line(' ' + why_not)
540
+ for name, what in self.subsystems.restore(saved, saved_at).items():
541
+ _line(' %s: %s' % (name, what))
542
+ # Everything above can refuse and none of it is visible off this
543
+ # machine. From here on the node starts being seen: the face serves
544
+ # (before the compartments start, because some of them dial it), the
545
+ # transport subscribes, and - once they have started - the
546
+ # descriptor is published.
547
+ self.host.start()
548
+ self.protocols.start_all()
549
+ if self.face is not None:
550
+ self.face.start()
551
+ self.strain.record('restart')
552
+ self.transport.start()
553
+ wait_listen_timeout = float(self.cfg.get('wait_listening_timeout', 5.0))
554
+ if self.relays and wait_listen_timeout > 0 and not self.transport.wait_listening(wait_listen_timeout):
555
+ # Said, not waited on for ever. Until a relay acknowledges the
556
+ # subscription this node cannot receive a live wrap and would not
557
+ # know it had missed one, because kind 21059 is ephemeral and no
558
+ # relay keeps a copy. Starting anyway is right - the stored kind
559
+ # replays, the compartments have work that needs no inbox, and
560
+ # `link` will keep saying so - but starting silently is not.
561
+ _line(' no relay has acknowledged the inbox subscription yet;',
562
+ 'live messages sent before it does are lost')
563
+ if not self.settings.face.port and not self.settings.protocol_suites.host.port:
564
+ # Port 0 asks the kernel for any free port, which is right for a test
565
+ # and wrong for a node an operator runs: the relay face moved on every
566
+ # restart, so every client holding its address lost it. Record what we
567
+ # were given, once.
568
+ port_assigned = self.host.port if self.host is not None else (self.face.server.port if self.face and self.face.server else 0)
569
+ self.cfg['face'] = dict(self.cfg.get('face') or {}, port=port_assigned)
570
+ self.save_coverage()
571
+ _line(' recorded the edge face port', port_assigned, 'so it survives a restart')
572
+ edge_url = self.face.url if self.face is not None else self.host.url
573
+ _line(' edge face', edge_url, '| relays', ', '.join(self.relays) or '(none)',
574
+ '| descriptor', self._descriptor()['id'][:12] + '…')
575
+ if not self.cfg['endpoints']:
576
+ _line(' no endpoint configured: reachable over the nostr transport only')
577
+ _line(' peers configured:', len(self.cfg.get('peers', [])),
578
+ '| grants held:', len(self.grants.list()))
579
+ # The registry's own lifecycle verb, which nothing called: presence
580
+ # holds the heartbeat subscription open and had no way to say so.
581
+ # It unwinds itself when a required compartment cannot start, so
582
+ # `_built` - which tells stop() to stop the compartments - is set only
583
+ # once they have (audit L1: it used to be set before, and a failed
584
+ # start then sent the budget's farewell for a node that never served).
585
+ self.subsystems.start()
586
+ self._built = True
587
+ lifecycle_mod.discard_snapshots(self.dir)
588
+ self._publish_pending(pending)
589
+ self.control = control_mod.Server(self.dir, self.control_request).start()
590
+ threading.Thread(target=self._serve, daemon=True).start()
591
+ sd_notify('READY=1\nSTATUS=Fleetmesh node active')
592
+ self._start_watchdog()
593
+ return self
594
+
595
+ def _publish_pending(self, pending: str) -> None:
596
+ """Publish a descriptor no relay has accepted yet, now that the node
597
+ has really started. The marker goes only when a relay accepted it: a
598
+ node with no relays configured, or none reachable, publishes on the
599
+ next start instead of believing it already had."""
600
+ if not os.path.exists(pending) or not self.relays:
601
+ return
602
+ out = relay.publish_all(self.relays, self._descriptor())
603
+ if any(ok for ok, _ in out.values()):
604
+ os.unlink(pending)
605
+ else:
606
+ _line(' no relay accepted the descriptor yet; the next start publishes it')
607
+
608
+ def _start_watchdog(self) -> None:
609
+ watchdog_usec = os.environ.get('WATCHDOG_USEC')
610
+ if watchdog_usec:
611
+ try:
612
+ usec = int(watchdog_usec)
613
+ if usec > 0:
614
+ interval = (usec / 1_000_000.0) / 2.0
615
+ threading.Thread(target=self._watchdog_loop, args=(interval,), daemon=True).start()
616
+ except (ValueError, TypeError):
617
+ pass
618
+
619
+ def _watchdog_loop(self, interval: float) -> None:
620
+ while not self._stop.is_set():
621
+ if self._stop.wait(interval):
622
+ break
623
+ sd_notify('WATCHDOG=1')
624
+
625
+ def run(self) -> None:
626
+ """Tick on the configured cadence until stop()."""
627
+ while not self._stop.is_set():
628
+ try:
629
+ for d in self.tick():
630
+ _line(' ', ' '.join(_short(x) for x in d))
631
+ except Exception as e: # one bad tick must not stop the node
632
+ self.log.append(('tick failed', str(e)))
633
+ self.counts['tick failed'] += 1
634
+ self._last_tick_error = '%s: %s' % (type(e).__name__, e)
635
+ _line(' tick failed:', e)
636
+ self._stop.wait(self.cfg.get('tick_seconds', 30))
637
+
638
+ def stop(self) -> None:
639
+ # Once. `meshnode run` stops from the SIGTERM handler and again from
640
+ # its finally:, and each pass said goodbye: every peer got the same
641
+ # window's farewell at two sequence numbers a few seconds apart, and
642
+ # whichever end saw the second first read the first as a receipt the
643
+ # wire had dropped. The three-continent mesh reported that gap on two
644
+ # of three nodes after its first restart.
645
+ if self._stopped:
646
+ return
647
+ self._stopped = True
648
+ sd_notify('STOPPING=1\nSTATUS=Fleetmesh node shutting down')
649
+ self._stop.set()
650
+ # Before the transport goes, tell every peer what this node used. A node
651
+ # that simply vanishes leaves its issuer with traffic measured and no
652
+ # receipt, which is active silence and an S2 - and an S2 drops the tier
653
+ # to probation, which only a person can raise again. That made the
654
+ # fleet-wide restart a digest change requires cost every peering its
655
+ # tier, every time.
656
+ if getattr(self, '_built', False):
657
+ # The registry stops every compartment, and the budget's stop is the
658
+ # farewell. Calling it here as well sent every peer two receipts for
659
+ # the same window at two sequence numbers - valid, and pointless.
660
+ for name, did in self.subsystems.stop():
661
+ for d in (did or []):
662
+ _line(' ' + ' '.join(str(x) for x in d))
663
+ # After stop(), so the snapshot is what each compartment ended
664
+ # with. Only on a clean stop: a process that died wrote nothing,
665
+ # and the next one starts its compartments fresh.
666
+ snaps = self.subsystems.snapshot()
667
+ if snaps:
668
+ try:
669
+ lifecycle_mod.write_snapshots(self.dir, self.clock(), snaps)
670
+ _line(' kept the state of', ', '.join(snaps), 'for the next start')
671
+ except OSError as e:
672
+ _line(' could not keep compartment state:', '%s: %s' % (type(e).__name__, e))
673
+ if getattr(self, 'control', None) is not None:
674
+ self.control.stop()
675
+ self.transport.stop()
676
+ if self.face is not None:
677
+ self.face.stop()
678
+ if getattr(self, 'protocols', None) is not None:
679
+ self.protocols.stop_all()
680
+ if getattr(self, 'host', None) is not None:
681
+ self.host.stop()
682
+
683
+ def close(self) -> None:
684
+ """Safely stop node workers and close all persistent database connections.
685
+
686
+ A close that fails is counted and said. The four stores here are shut
687
+ in turn and a failure in one must not leave the other three open, so
688
+ the exception is still caught - but it is no longer discarded: a store
689
+ that cannot close (a locked database, a disk full at commit) is
690
+ exactly the one an operator needs told about at restart, and
691
+ `except Exception: pass` told nobody (audit 2026-10-07, L5)."""
692
+ self.stop()
693
+ for name in ('store', 'accounts', 'grants', 'blobs', 'video'):
694
+ st = getattr(self, name, None)
695
+ if st is not None and hasattr(st, 'close'):
696
+ try:
697
+ st.close()
698
+ except Exception as e:
699
+ self.log.append(('close failed', '%s: %s' % (name, e)))
700
+ self.counts['close failed'] += 1
701
+ _line(' closing %s failed:' % name, '%s: %s' % (type(e).__name__, e))
702
+
703
+
704
+ # ---- inbound -------------------------------------------------------------------
705
+ def _say_once(self, key: str, *parts: Any, every: float = 60) -> None:
706
+ """One log line per key per minute. A peer that sends the same
707
+ malformed message forever must not own the journal."""
708
+ now = time.time()
709
+ if now - self._last_said.get(key, 0) < every:
710
+ return
711
+ self._last_said[key] = now
712
+ if len(self._last_said) > 500:
713
+ self._last_said = {k: v for k, v in self._last_said.items() if now - v < every}
714
+ _line(*parts)
715
+
716
+ def _serve(self) -> None:
717
+ while not self._stop.is_set():
718
+ inbound = self.transport.receive(timeout=0.5)
719
+ if inbound is None:
720
+ continue
721
+ # The door. A message without the shape of its type is dropped here
722
+ # with a reason, counted, and never reaches a handler; what a handler
723
+ # had done before it raised on a bad field used to stay done.
724
+ why = validate.check(inbound.message)
725
+ if why is not None:
726
+ self.rejected[why] += 1
727
+ self._say_once('rejected:' + why, '! rejected', _short(inbound.sender),
728
+ str(inbound.message.get('type'))[-40:], '-', why)
729
+ continue
730
+ try:
731
+ done = self.router.dispatch(inbound)
732
+ except Exception as e:
733
+ self.log.append(('handler failed', inbound.message.get('type'), str(e)))
734
+ self.counts['handler failed'] += 1
735
+ self._say_once('handler:' + str(inbound.message.get('type')),
736
+ '! handler failed on', inbound.message.get('type'), '-', e)
737
+ done = ('error',)
738
+ if done is not None:
739
+ self.log.append(done)
740
+ if done[0] not in ('ignored',):
741
+ _line('<', ' '.join(_short(x) for x in done[:3]))
742
+
743
+ # What an operator may change while the node runs. The rest - class, keys,
744
+ # relays - is what the node is. Every live key belongs to the compartment
745
+ # that reads it, and this is what is left over: nothing.
746
+ LIVE_KEYS: tuple[str, ...] = ()
747
+
748
+ def reload_config(self) -> None:
749
+ """Take up whatever the operator has written since the last tick.
750
+
751
+ Every command writes the config and nothing else, because a running node
752
+ owns its directory and its relay face port and a second process cannot
753
+ stand one up beside it. So the file is the interface, and it is read in
754
+ one place: `trust` was being re-read and `coverage` was not, which meant
755
+ a shard added to the file was never claimed until the node restarted.
756
+ Neither is named here now. A compartment declares the keys it re-reads
757
+ and the registry collects them, so the list cannot fall behind the
758
+ mechanism that depends on it.
759
+ """
760
+ try:
761
+ fresh = load_config(self.dir) or {}
762
+ except (OSError, ValueError) as e:
763
+ # The last good config stays in force. Said once a minute, not once
764
+ # a tick, and shown in status so an operator finds it without the log.
765
+ self._config_error = str(e)
766
+ self.counts['config rejected'] += 1
767
+ self._say_once('config', '! config.json is not usable, keeping the last good one:', e)
768
+ return
769
+ self._config_error = None
770
+ for key in tuple(self.LIVE_KEYS) + self.subsystems.live_keys():
771
+ if key in fresh:
772
+ self.cfg[key] = fresh[key]
773
+ if self._built and self.capacity.set_total(self.cfg.get('capacity')):
774
+ _line(' aggregate ceiling is now',
775
+ ', '.join('%s %s' % kv for kv in sorted(self.capacity.total.items()))
776
+ or 'undeclared - this node will accept load until it fails')
777
+ if self._built:
778
+ self._reconfigure(fresh)
779
+
780
+ def _reconfigure(self, fresh: Mapping[str, Any]) -> list[str]:
781
+ """Take up a changed config block that is not live, by restarting its owner.
782
+
783
+ A live key is copied above and its compartment re-reads it. Any other
784
+ block used to wait silently for the next node restart. Now, when the
785
+ compartment that owns it declares `Lifecycle(restart=True)`, the new
786
+ block is parsed first - the running settings stay if it does not
787
+ parse - then adopted, and the compartment restarted in place. One that
788
+ does not declare restart keeps the running settings and the operator
789
+ is told, once a minute, that the edit waits for the node.
790
+
791
+ Returns the names restarted.
792
+ """
793
+ done: list[str] = []
794
+ for s in self.subsystems:
795
+ blk = getattr(s, 'config_cls', None)
796
+ own = getattr(blk, 'key', '') if blk is not None else ''
797
+ if blk is None or not own or own in s.live or fresh.get(own) == self.cfg.get(own):
798
+ continue
799
+ name = s.name or ''
800
+ if not self.subsystems.restartable(name):
801
+ # Once per edit: the running settings are right until the
802
+ # node restarts, so this is news when it changes, not weather.
803
+ if self._waiting.get(own) != fresh.get(own):
804
+ self._waiting[own] = fresh.get(own)
805
+ _line(' %s changed in config.json;' % own, name, 'takes it up when the node restarts')
806
+ continue
807
+ try:
808
+ blk.from_dict(fresh.get(own) or {})
809
+ except Exception as e:
810
+ self._say_once('config:' + own, '! %s in config.json is not usable, keeping the running one:' % own, e)
811
+ continue
812
+ if own in fresh:
813
+ self.cfg[own] = fresh[own]
814
+ else:
815
+ self.cfg.pop(own, None)
816
+ state = self.subsystems.restart(name, 'its %s block changed' % own)
817
+ _line(' %s restarted for its new %s settings:' % (name, own), state)
818
+ done.append(name)
819
+ return done
820
+
821
+ def tick(self, now: int | None = None) -> list[tuple[Any, ...]]:
822
+ with self._tick_lock:
823
+ return self._tick(now)
824
+
825
+ def _tick(self, now: int | None = None) -> list[tuple[Any, ...]]:
826
+ """One tick: take up the config, run every compartment, write the status.
827
+
828
+ This was a hundred and thirty lines with five mechanisms in it. What is
829
+ left is the three things the node itself owns - the config file, the
830
+ registry, and status.json - and one line about clock drift, which is
831
+ read here because it is the operator's to act on at both ends and not
832
+ one node's to fix.
833
+ """
834
+ now = now if now is not None else self.clock()
835
+ seen_drift = len(self.budget.clock_drift)
836
+ self.reload_config()
837
+ did = self.subsystems.tick(now)
838
+ self._last_tick = did
839
+ for peer, epoch, drift, _at in self.budget.clock_drift[seen_drift:]:
840
+ _line(' a receipt from', _short(peer), 'is timed %ds from this clock' % drift,
841
+ '- check NTP on both ends before reading any divergence')
842
+ self.write_status(did)
843
+ sd_notify('WATCHDOG=1')
844
+ return did
845
+
846
+ # ---- status --------------------------------------------------------------------
847
+ PLANES: tuple[tuple[str, str, tuple[str, ...]], ...] = _VIEW_PLANES
848
+
849
+ def status(self) -> Mapping[str, Any]:
850
+ return node_status(self)
851
+
852
+ def health(self) -> Mapping[str, Any]:
853
+ """What is failing, and how often: the operator's view of every error
854
+ plane the node has. Nothing here is a finding about a peer."""
855
+ return node_health(self)
856
+
857
+ def write_status(self, did: Sequence[tuple[Any, ...]] | None = None) -> Mapping[str, Any]:
858
+ if did:
859
+ self._last_tick = did
860
+ st = self.status()
861
+ path = os.path.join(self.dir, 'status.json')
862
+ tmp = path + '.tmp'
863
+ with open(tmp, 'w', encoding='utf-8') as f:
864
+ json.dump(st, f, indent=2)
865
+ os.replace(tmp, path)
866
+ return st
867
+