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,790 @@
1
+ """The Registry: the compartments a node has, and the uniform ways to reach them.
2
+
3
+ Split out of node/compartment.py when it became a package.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import collections
8
+ import json
9
+ import threading
10
+ import time
11
+ from typing import Any, Callable, Iterator, Mapping, Optional, Sequence, TypeVar
12
+
13
+ from . import lifecycle as lifecycle_mod
14
+ from . import stores as stores_mod
15
+ from ..interfaces import CommandSpec
16
+ from ..metrics import COUNTER, Metric
17
+ from .base import Compartment, CompartmentProtocol
18
+ from .problem import Note, NoteList, Trouble, TroubleList
19
+ from .refusals import Refusal, why_detail
20
+
21
+
22
+ T = TypeVar('T', bound=CompartmentProtocol)
23
+
24
+
25
+ class Registry:
26
+ """The compartments a node has, and the uniform ways to reach all of them.
27
+
28
+ It also owns where each one is in its lifecycle (node/lifecycle): every
29
+ verb below moves a compartment's Tracker, a tick that raises degrades it,
30
+ enough of them trip its breaker, and a quarantined compartment is left
31
+ alone until its backoff passes. `say`, when given, is the node log's
32
+ `say`, and every transition goes out on a declared lifecycle channel.
33
+ """
34
+
35
+ def __init__(self, say: Optional[Callable[..., Any]] = None,
36
+ clock: Optional[Callable[[], int]] = None) -> None:
37
+ self._by_name: collections.OrderedDict[str, CompartmentProtocol] = collections.OrderedDict()
38
+ self._life: dict[str, lifecycle_mod.Tracker] = {}
39
+ self.say: Optional[Callable[..., Any]] = say
40
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
41
+ # The tick and an operator's `resume` arrive on different threads.
42
+ self._life_lock: threading.Lock = threading.Lock()
43
+ # And a `restart` must never stop a compartment in the middle of its
44
+ # tick: held around each compartment's tick and around each restart.
45
+ # Re-entrant because a restart from inside a tick (a quarantine trial)
46
+ # already holds it.
47
+ self._busy: threading.RLock = threading.RLock()
48
+ # name -> error, for each compartment whose last report() raised.
49
+ self._report_failed: dict[str, str] = {}
50
+
51
+ def add(self, sub: T) -> T:
52
+ if not sub.name:
53
+ raise ValueError('a compartment must have a name')
54
+ if sub.name in self._by_name:
55
+ raise ValueError('two compartments called %r' % sub.name)
56
+ self._by_name[sub.name] = sub
57
+ decl = getattr(sub, 'LIFECYCLE', None)
58
+ if not isinstance(decl, lifecycle_mod.Lifecycle):
59
+ decl = lifecycle_mod.DEFAULT
60
+ problems = decl.problems()
61
+ if decl.snapshot and not (callable(getattr(sub, 'snapshot', None)) and callable(getattr(sub, 'restore', None))):
62
+ # The declaration is the promise; the two methods are the code that
63
+ # keeps it. One without the other would read as continuity the
64
+ # node does not have.
65
+ problems.append('Lifecycle(snapshot=%d) needs snapshot() and restore(state)' % decl.snapshot)
66
+ # The base bound each declared store's container as `self.<key>`. One
67
+ # that is not there any more was replaced in __init__ - by a plain
68
+ # list or dict, which is the unbounded collection the declaration
69
+ # promised was not there.
70
+ holdings = getattr(sub, 'holdings', None)
71
+ declared_stores: tuple[object, ...] = tuple(getattr(sub, 'STORES', None) or ())
72
+ if holdings is None and declared_stores:
73
+ # Declared but never built: an __init__ that did not call
74
+ # Compartment.__init__. The compartment would run without one of
75
+ # its containers and fail at its first write - or at shutdown,
76
+ # when snapshot() asks for holdings that are not there.
77
+ problems.append('it declares STORES but has no holdings - its __init__ never called '
78
+ 'Compartment.__init__, which builds them')
79
+ for key, container in (holdings.items() if holdings is not None else ()):
80
+ if getattr(sub, key, None) is not container:
81
+ problems.append('store %s was replaced after the base built it (self.%s = ...); '
82
+ 'write into the container instead' % (key, key))
83
+ if problems:
84
+ del self._by_name[sub.name]
85
+ raise ValueError('%s declares what it cannot keep: %s' % (sub.name, '; '.join(problems)))
86
+ self._life[sub.name] = lifecycle_mod.Tracker(sub.name, decl, at=self.clock())
87
+ return sub
88
+
89
+ # ---- lifecycle ----------------------------------------------------------
90
+ def _moved(self, t: Optional[lifecycle_mod.Transition]) -> None:
91
+ """Say a transition, if there was one and there is a log to say it to."""
92
+ if t is None or self.say is None:
93
+ return
94
+ try:
95
+ self.say(lifecycle_mod.channel_for(t), t)
96
+ except Exception:
97
+ pass # a log must never fail the lifecycle it records
98
+
99
+ def _move(self, name: Optional[str], to: str, at: Optional[int] = None, why: str = '') -> None:
100
+ tr = self._life.get(name or '')
101
+ if tr is None:
102
+ return
103
+ with self._life_lock:
104
+ t = tr.move(to, self.clock() if at is None else at, why)
105
+ self._moved(t)
106
+
107
+ def state_of(self, name: str) -> Optional[str]:
108
+ tr = self._life.get(name)
109
+ return tr.state if tr is not None else None
110
+
111
+ def lifecycle(self) -> dict[str, lifecycle_mod.LifecycleRecord]:
112
+ """Where every compartment is, for health and `meshnode lifecycle`."""
113
+ return {n: tr.describe() for n, tr in self._life.items()}
114
+
115
+ def resume(self, name: str, why: str = 'resumed by the operator') -> str:
116
+ """End a compartment's quarantine now. Returns the state it is left in.
117
+
118
+ Raises KeyError for a compartment this node does not have. Resuming
119
+ one that is not quarantined is not an error - it is already ticking -
120
+ and the state it is in is returned unchanged.
121
+ """
122
+ tr = self._life.get(name)
123
+ if tr is None:
124
+ raise KeyError('no compartment called %r; compartments: %s' % (name, ', '.join(self._life)))
125
+ s = self._by_name.get(name)
126
+ if s is not None and tr.state == lifecycle_mod.QUARANTINED and tr.started is False:
127
+ # Quarantined because its start() raised: it never began, so
128
+ # ending the quarantine means starting it, not ticking it.
129
+ with self._busy:
130
+ self._restart(s, tr, 'resumed: %s' % why)
131
+ return tr.state
132
+ with self._life_lock:
133
+ t = tr.resume(self.clock(), why)
134
+ self._moved(t)
135
+ return tr.state
136
+
137
+ def quarantined(self) -> list[str]:
138
+ return [n for n, tr in self._life.items() if tr.state == lifecycle_mod.QUARANTINED]
139
+
140
+ def failed(self) -> list[str]:
141
+ return [n for n, tr in self._life.items() if tr.state == lifecycle_mod.FAILED]
142
+
143
+ def restartable(self, name: str) -> bool:
144
+ tr = self._life.get(name)
145
+ return tr is not None and tr.lifecycle.restart
146
+
147
+ def restart(self, name: str, why: str = 'by the operator') -> str:
148
+ """Stop and start one compartment in place. Returns the state it is left in.
149
+
150
+ Only for a compartment that declares `Lifecycle(restart=True)`; any
151
+ other is refused with ValueError, because a stop() and start() nobody
152
+ designed to run twice could leave threads or sockets behind and call
153
+ it a recovery. KeyError for a name this node does not have.
154
+
155
+ The same instance is stopped and started: every other compartment
156
+ that reached it in `wire()` still holds it, and a fresh instance would
157
+ leave them holding the old one. A start() that raises quarantines it
158
+ with backoff; the trial that follows restarts it again.
159
+ """
160
+ tr = self._life.get(name)
161
+ s = self._by_name.get(name)
162
+ if tr is None or s is None:
163
+ raise KeyError('no compartment called %r; compartments: %s' % (name, ', '.join(self._life)))
164
+ if not tr.lifecycle.restart:
165
+ raise ValueError('%s does not declare Lifecycle(restart=True): its stop() and start() are not '
166
+ 'known to be safe to run twice on one instance, so only a node restart '
167
+ 'restarts it' % name)
168
+ if tr.state in (lifecycle_mod.DRAINING, lifecycle_mod.RETIRED):
169
+ raise ValueError('%s is %s; the node is stopping' % (name, tr.state))
170
+ with self._busy:
171
+ self._restart(s, tr, why)
172
+ return tr.state
173
+
174
+ def _restart(self, s: CompartmentProtocol, tr: lifecycle_mod.Tracker, why: str,
175
+ trial: bool = False, now: Optional[int] = None) -> bool:
176
+ """stop() then start(). True when start() returned. Holds `_busy`.
177
+
178
+ A trial restart (from quarantine) moves nothing on success: the trial
179
+ tick that follows decides whether it recovered, and says so.
180
+ """
181
+ now = self.clock() if now is None else now
182
+ if tr.started and not trial:
183
+ self._move(s.name, lifecycle_mod.DRAINING, now, 'restart: %s' % why)
184
+ if tr.started:
185
+ try:
186
+ s.stop()
187
+ except Exception as e:
188
+ # Said and gone past: a stop() that raised has still stopped
189
+ # as far as it is going to, and start() is the recovery.
190
+ s.refusals.refuse('restart', 'stop raised', detail=why_detail(e))
191
+ finally:
192
+ if hasattr(s, 'stop_workers'):
193
+ try:
194
+ s.stop_workers()
195
+ except Exception:
196
+ pass
197
+ tr.started = False
198
+ try:
199
+ if hasattr(s, 'start_workers'):
200
+ s.start_workers()
201
+ s.start()
202
+ except Exception as e:
203
+ s.refusals.refuse('restart', 'start raised', detail=why_detail(e))
204
+ with self._life_lock:
205
+ t = tr.start_failed(now, '%s: %s' % (type(e).__name__, e))
206
+ self._moved(t)
207
+ return False
208
+ if trial:
209
+ tr.started = True
210
+ tr.restarts += 1
211
+ return True
212
+ with self._life_lock:
213
+ t = tr.restarted(now, why)
214
+ self._moved(t)
215
+ return True
216
+
217
+ # ---- carrying state across a process restart ----------------------------
218
+ def snapshot(self) -> dict[str, lifecycle_mod.SnapshotEntry]:
219
+ """What every started compartment that declares `snapshot` hands on.
220
+
221
+ One whose snapshot() raises, or returns something that is not a
222
+ JSON object, is a refusal against itself and is left out: the next
223
+ process starts it fresh, and every other compartment's state still
224
+ goes across.
225
+ """
226
+ out: dict[str, lifecycle_mod.SnapshotEntry] = {}
227
+ for s in self:
228
+ tr = self._life.get(s.name or '')
229
+ if tr is None or not tr.lifecycle.snapshot or tr.started is not True:
230
+ continue
231
+ try:
232
+ state = getattr(s, 'snapshot')()
233
+ if not isinstance(state, Mapping):
234
+ raise TypeError('snapshot() returned %s, not a mapping' % type(state).__name__)
235
+ state = json.loads(json.dumps(dict(state)))
236
+ except Exception as e:
237
+ s.refusals.refuse('snapshot', 'raised', detail=why_detail(e))
238
+ continue
239
+ out[s.name or ''] = {'version': tr.lifecycle.snapshot, 'state': state}
240
+ return out
241
+
242
+ def restore(self, saved: Mapping[str, lifecycle_mod.SnapshotEntry], at: int = 0) -> dict[str, str]:
243
+ """Hand each compartment the state the last process left for it.
244
+ Before start(), so a compartment starts from where it was.
245
+
246
+ Returns {name: what happened} for every compartment that declares
247
+ `snapshot`, and records the same in its lifecycle. Refused - and so
248
+ started fresh - when there is no entry, the entry's version is not
249
+ the one the compartment reads now, or restore() raises. Never raises
250
+ itself: continuity is worth having, not worth refusing to start for.
251
+ """
252
+ out: dict[str, str] = {}
253
+ for s in self:
254
+ tr = self._life.get(s.name or '')
255
+ if tr is None or not tr.lifecycle.snapshot:
256
+ continue
257
+ e = saved.get(s.name or '')
258
+ if e is None:
259
+ tr.restored = ''
260
+ continue
261
+ if e['version'] != tr.lifecycle.snapshot:
262
+ tr.restored = ('refused: the snapshot is v%d and this %s reads v%d; started fresh'
263
+ % (e['version'], s.name, tr.lifecycle.snapshot))
264
+ else:
265
+ try:
266
+ getattr(s, 'restore')(e['state'])
267
+ tr.restored = 'restored (v%d, written at %d)' % (e['version'], at)
268
+ except Exception as exc:
269
+ s.refusals.refuse('restore', 'raised', detail=why_detail(exc))
270
+ tr.restored = 'refused: restore raised %s: %s; started fresh' % (type(exc).__name__, exc)
271
+ out[s.name or ''] = tr.restored
272
+ return out
273
+
274
+ def lifecycle_metrics(self) -> tuple[Any, ...]:
275
+ """The lifecycle's counters, and gauges sampled from these trackers."""
276
+ return lifecycle_mod.METRICS + (
277
+ lifecycle_mod.gauge(lambda: len(self.quarantined()), 'compartments_quarantined',
278
+ 'Compartments whose breaker is open now.'),
279
+ lifecycle_mod.gauge(
280
+ lambda: sum(1 for tr in self._life.values() if tr.state == lifecycle_mod.DEGRADED),
281
+ 'compartments_degraded', 'Compartments whose last tick raised.'),
282
+ lifecycle_mod.gauge(lambda: len(self.failed()), 'compartments_failed',
283
+ 'Optional compartments whose start() raised and that cannot restart.'),
284
+ )
285
+
286
+ # ---- what the compartments keep -------------------------------------------
287
+ def _containers(self) -> list[tuple[str, str, stores_mod.Container]]:
288
+ out: list[tuple[str, str, stores_mod.Container]] = []
289
+ for s in self:
290
+ holdings = getattr(s, 'holdings', None)
291
+ for key, c in (holdings.items() if holdings is not None else ()):
292
+ out.append((s.name or '', key, c))
293
+ return out
294
+
295
+ def store_metrics(self) -> tuple[Any, ...]:
296
+ """Every declared store, metered without its compartment writing a
297
+ metric: size, bound and high-water as gauges, and the writes, the
298
+ evictions and the refusals as counters. One series per (compartment,
299
+ store), sampled from the containers when /metrics is read."""
300
+ def per(read: Callable[[stores_mod.Container], int]) -> Callable[[], dict[tuple[str, str], int]]:
301
+ return lambda: {(n, k): read(c) for n, k, c in self._containers()}
302
+ labels = ('compartment', 'store')
303
+ return (
304
+ Metric(name='store_entries', description='Entries a compartment store holds now.',
305
+ labels=labels, sample=per(len), tags=('stores',)),
306
+ Metric(name='store_bound', description='The most entries a store may hold, as declared.',
307
+ labels=labels, sample=per(lambda c: c.store.limit), tags=('stores',)),
308
+ Metric(name='store_high_water', description='The most entries a store has held since start.',
309
+ labels=labels, sample=per(lambda c: c.high_water), tags=('stores',)),
310
+ Metric(name='store_writes', description='Entries written to a store (and counters incremented).',
311
+ kind=COUNTER, labels=labels, sample=per(lambda c: c.inserts), tags=('stores',)),
312
+ Metric(name='store_evictions', description='Entries a store dropped at its bound (OLDEST or LRU).',
313
+ kind=COUNTER, labels=labels, sample=per(lambda c: c.evictions), tags=('stores',)),
314
+ Metric(name='store_refused', description='Writes a full REFUSE store turned away.',
315
+ kind=COUNTER, labels=labels, sample=per(lambda c: c.refused), tags=('stores',)),
316
+ )
317
+
318
+ def store_trouble(self) -> list[str]:
319
+ """A REFUSE store is trouble once it is nearly full, and while it has
320
+ refused anything: the state it holds is the kind that must not be
321
+ lost, so the write that failed was a decision the compartment had to
322
+ make without it. A ring at its bound is a log doing its job."""
323
+ out: list[str] = []
324
+ for n, k, c in self._containers():
325
+ st = c.store
326
+ if st.kind == stores_mod.COUNTERS or st.evict != stores_mod.REFUSE:
327
+ continue
328
+ if c.refused or c.nearly_full():
329
+ out.append('%s: store %s holds %d of %d %s%s' % (
330
+ n, k, len(c), st.limit, st.keyed_by or 'entries',
331
+ '; %d write(s) refused' % c.refused if c.refused else ' and refuses new ones when full'))
332
+ return out
333
+
334
+ def store_notes(self) -> list[str]:
335
+ """A TABLE or SET that has evicted is dropping state it was keeping
336
+ on purpose. Not a fault - the bound is the decision - but worth
337
+ reading when something it tracked is unexpectedly gone."""
338
+ out: list[str] = []
339
+ for n, k, c in self._containers():
340
+ if c.store.kind in (stores_mod.TABLE, stores_mod.SET) and c.evictions:
341
+ out.append('%s: store %s has dropped %d %s at its bound of %d (%s first)' % (
342
+ n, k, c.evictions, c.store.keyed_by or 'entries', c.store.limit, c.store.evict))
343
+ return out
344
+
345
+ def __iter__(self) -> Iterator[CompartmentProtocol]:
346
+ return iter(self._by_name.values())
347
+
348
+ def __contains__(self, name: object) -> bool:
349
+ return name in self._by_name
350
+
351
+ def get(self, name: str) -> Optional[CompartmentProtocol]:
352
+ return self._by_name.get(name)
353
+
354
+ def names(self) -> list[str]:
355
+ return list(self._by_name)
356
+
357
+ # ---- assembly -----------------------------------------------------------
358
+ def assemble(self, ctx: Any, manifest: Sequence[type[Any]],
359
+ enabled: Optional[Callable[[str], bool]] = None) -> list[str]:
360
+ """Build every compartment in the manifest from one context, in the
361
+ manifest's order, and register each as it is built.
362
+
363
+ The manifest is the node's declared tick order. It is *validated*
364
+ against each class's `requires` rather than re-sorted from it, because
365
+ tick order is a reviewed decision with reasons the dependency graph
366
+ does not carry (the relay journal ticks first so a dropped
367
+ subscription is said before anything that depends on it; the
368
+ operator's decisions tick before the mechanisms they outrank). A
369
+ manifest that lists a compartment before one it requires, or omits
370
+ one it requires, is a programming error and is refused at startup
371
+ with the two names in the message - not discovered three ticks in.
372
+
373
+ Optional compartments are built only when `enabled(name)` says so;
374
+ the default asks whether `enabled_by` is a present config key. A
375
+ compartment whose `api` major differs from this registry's, or whose
376
+ `protocol` is set and differs from the digest in `ctx`, is refused
377
+ the same way: the node would rather not start than start wrong.
378
+
379
+ Returns the names built, in order. The context's `node` (when it is
380
+ a NodeApp) gets each compartment set on it under its name, which is
381
+ what the rest of the node and the tests reach for today.
382
+ """
383
+ is_on: Callable[[str], bool] = enabled or (lambda key: bool(ctx.cfg.get(key)))
384
+ digest = getattr(ctx, 'digest', None)
385
+ setattr(ctx, '_registry', self)
386
+ built: list[str] = []
387
+ seen: list[str] = [] # every manifest name passed, built or not
388
+ for cls in manifest:
389
+ name = getattr(cls, 'name', None)
390
+ if not name:
391
+ raise ValueError('%s declares no name' % cls.__name__)
392
+ for dep in getattr(cls, 'uses', ()):
393
+ if dep not in seen:
394
+ raise ValueError('%s uses %s, which the manifest does not list before it (manifest order: %s)'
395
+ % (name, dep, ' '.join(seen) or '(nothing yet)'))
396
+ seen.append(name)
397
+ api = getattr(cls, 'api', Compartment.API)
398
+ if api.split('/')[0] != Compartment.API.split('/')[0] or \
399
+ api.split('/')[1].split('.')[0] != Compartment.API.split('/')[1].split('.')[0]:
400
+ raise ValueError('%s is written against %s; this node speaks %s' % (name, api, Compartment.API))
401
+ proto = getattr(cls, 'protocol', '')
402
+ if proto and digest and proto != digest:
403
+ raise ValueError('%s was built against schema %s; this node speaks %s' % (name, proto[:12], digest[:12]))
404
+ if getattr(cls, 'optional', False):
405
+ key = getattr(cls, 'enabled_by', None) or name
406
+ if not is_on(key):
407
+ continue
408
+ for dep in getattr(cls, 'requires', ()):
409
+ if dep not in self._by_name:
410
+ raise ValueError('%s requires %s, which is not built before it (manifest order: %s)'
411
+ % (name, dep, ' '.join(built) or '(nothing yet)'))
412
+ sub = cls.from_context(ctx)
413
+ self.add(sub)
414
+ refs = self.store_ref_problems(sub)
415
+ if refs:
416
+ del self._by_name[name]
417
+ self._life.pop(name, None)
418
+ raise ValueError('%s reads what is not there: %s' % (name, '; '.join(refs)))
419
+ built.append(name)
420
+ if ctx.node is not None:
421
+ setattr(ctx.node, getattr(cls, 'attr', None) or name, sub)
422
+ return built
423
+
424
+ def store_ref_problems(self, sub: Any) -> list[str]:
425
+ """Every `other.key` a command of `sub` reads, where `other` is built
426
+ and declares no store `key`.
427
+
428
+ The class lint (Compartment._command_data_problems) holds the ref to
429
+ `requires`/`uses`; only here do both sides exist, so only here can
430
+ the store itself be checked. The manifest puts every required and
431
+ used compartment before `sub`, so all that will be built already is.
432
+ One that is not built is an absent `uses` - optional by definition,
433
+ and the command must cope without it - and is skipped.
434
+ """
435
+ out: list[str] = []
436
+ refs = getattr(type(sub), 'store_refs', None)
437
+ for cmd, other, key in (refs() if callable(refs) else ()):
438
+ target = self._by_name.get(other)
439
+ if target is None:
440
+ continue
441
+ declared = {getattr(s, 'key', None) for s in getattr(target, 'STORES', ())}
442
+ if key not in declared:
443
+ out.append('command %s reads %s.%s, and %s declares no store %r (it declares: %s)'
444
+ % (cmd, other, key, other, key, ', '.join(sorted(str(k) for k in declared)) or 'none'))
445
+ return out
446
+
447
+ # ---- the uniform verbs --------------------------------------------------
448
+ def wire(self, ctx: Any) -> None:
449
+ """Every compartment's back-edges, in manifest order, once all are built."""
450
+ for s in self:
451
+ for need in getattr(type(s), 'NEEDS', ()) or ():
452
+ if getattr(need, 'late', False):
453
+ val = need.resolve_late(ctx, s, registry=self)
454
+ setattr(s, need.key, val)
455
+ s.wire(self, ctx)
456
+ self._move(s.name, lifecycle_mod.WIRED)
457
+
458
+ def route(self, router: Any) -> list[tuple[str, str]]:
459
+ """Register every compartment's declared protocols on the router.
460
+
461
+ Returns [(compartment, piuri)] as registered. A compartment that
462
+ declares a protocol and has no `handle` is a programming error and
463
+ is refused here, at startup, with its name.
464
+ """
465
+ out: list[tuple[str, str]] = []
466
+ for s in self:
467
+ piuris = tuple(getattr(s, 'piuris', ()) or ())
468
+ exact = tuple(getattr(s, 'exact_types', ()) or ())
469
+ if not piuris and not exact:
470
+ continue
471
+ handle = getattr(s, 'handle', None)
472
+ is_base = getattr(handle, '__func__', handle) is Compartment.handle
473
+ if not callable(handle) or (is_base and not getattr(s, 'HANDLERS', ())):
474
+ raise ValueError('%s declares protocols %s but has no handle(inbound)'
475
+ % (s.name, ', '.join(piuris + exact)))
476
+
477
+
478
+ for p in piuris:
479
+ router.register(p, handle)
480
+ out.append((s.name or '', p))
481
+ for t in exact:
482
+ router.register_exact(t, handle)
483
+ out.append((s.name or '', t))
484
+ return out
485
+
486
+ def start(self) -> None:
487
+ """Start every compartment, in manifest order, each isolated.
488
+
489
+ One that raises is a refusal against itself, and what happens next
490
+ depends on whether the node can run without it (audit L1):
491
+
492
+ required (not `optional`) every compartment already started is
493
+ stopped, newest first, and the raise goes
494
+ on and stops the node. A node that cannot
495
+ begin is a configuration the operator
496
+ must hear about at boot - and it must not
497
+ have half-begun, with a farewell sent by
498
+ something that never served.
499
+ optional the rest still start. It is QUARANTINED
500
+ when it declares restart (its trial
501
+ restarts it), FAILED when it does not.
502
+ """
503
+ begun: list[CompartmentProtocol] = []
504
+ for s in self:
505
+ tr = self._life.get(s.name or '')
506
+ try:
507
+ if hasattr(s, 'start_workers'):
508
+ s.start_workers()
509
+ s.start()
510
+ except Exception as e:
511
+ s.refusals.refuse('start', 'raised', detail=why_detail(e))
512
+ err = '%s: %s' % (type(e).__name__, e)
513
+ if not getattr(s, 'optional', False):
514
+ for o in reversed(begun):
515
+ self._move(o.name, lifecycle_mod.DRAINING, why='%s could not start' % s.name)
516
+ try:
517
+ o.stop()
518
+ except Exception as e2:
519
+ o.refusals.refuse('stop', 'raised', detail=why_detail(e2))
520
+ self._move(o.name, lifecycle_mod.RETIRED)
521
+ if tr is not None:
522
+ tr.started = False
523
+ self._move(s.name, lifecycle_mod.FAILED, why='start raised: %s' % err)
524
+ raise
525
+ if tr is not None:
526
+ with self._life_lock:
527
+ t = tr.start_failed(self.clock(), err)
528
+ self._moved(t)
529
+ continue
530
+ if tr is not None:
531
+ tr.started = True
532
+ begun.append(s)
533
+ self._move(s.name, lifecycle_mod.RUNNING)
534
+
535
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
536
+ """Every compartment's periodic work. One that raises does not stop the
537
+ others, and its failure is a refusal against itself rather than a
538
+ traceback nobody reads.
539
+
540
+ A raise also moves the compartment: to DEGRADED, or to QUARANTINED once
541
+ its breaker trips. A quarantined compartment is skipped until its
542
+ backoff passes, then given one trial tick - restarted first when it
543
+ declares restart, because "turn it off and on again" is the recovery a
544
+ stuck connection or a wedged worker needs. A retired or failed one is
545
+ skipped.
546
+ """
547
+ did: list[tuple[Any, ...]] = []
548
+ for s in self:
549
+ tr = self._life.get(s.name or '')
550
+ if tr is not None and not tr.due(now):
551
+ continue
552
+ with self._busy:
553
+ if tr is not None and tr.state == lifecycle_mod.QUARANTINED and tr.lifecycle.restart:
554
+ if not self._restart(s, tr, 'trial after quarantine', trial=True, now=now):
555
+ if tr.state == lifecycle_mod.QUARANTINED:
556
+ s.refusals.refuse('tick', 'quarantined', detail={'until': tr.until, 'trips': tr.trips})
557
+ did.append((s.name, 'restart failed', tr.last_error))
558
+ continue
559
+ try:
560
+ did += list(s.run_tick(now) if hasattr(s, 'run_tick') else (s.tick(now) or []))
561
+ except Exception as e:
562
+ s.refusals.refuse('tick', 'raised', detail=why_detail(e))
563
+ did.append((s.name, 'tick failed', '%s: %s' % (type(e).__name__, e)))
564
+ if tr is not None:
565
+ with self._life_lock:
566
+ t = tr.failed(now, '%s: %s' % (type(e).__name__, e))
567
+ self._moved(t)
568
+ if tr.state == lifecycle_mod.QUARANTINED:
569
+ s.refusals.refuse('tick', 'quarantined', detail={'until': tr.until, 'trips': tr.trips})
570
+ continue
571
+ if tr is not None:
572
+ with self._life_lock:
573
+ t = tr.succeeded(now)
574
+ self._moved(t)
575
+ return did
576
+
577
+ def stop(self) -> list[tuple[Optional[str], Any]]:
578
+ """Stop every compartment, and hand back what each had to say.
579
+
580
+ Returns [(name, whatever stop returned)]. One that raises does not stop
581
+ the others: a node that cannot say goodbye must still stop. One whose
582
+ start() raised is not stopped - it never began - and returns None.
583
+ """
584
+ out: list[tuple[Optional[str], Any]] = []
585
+ for s in self:
586
+ tr = self._life.get(s.name or '')
587
+ if tr is not None and tr.started is False:
588
+ out.append((s.name, None))
589
+ self._move(s.name, lifecycle_mod.RETIRED, why='never started')
590
+ continue
591
+ self._move(s.name, lifecycle_mod.DRAINING)
592
+ try:
593
+ out.append((s.name, s.stop()))
594
+ except Exception as e:
595
+ s.refusals.refuse('stop', 'raised', detail=why_detail(e))
596
+ out.append((s.name, None))
597
+ finally:
598
+ if hasattr(s, 'stop_workers'):
599
+ try:
600
+ s.stop_workers()
601
+ except Exception:
602
+ pass
603
+ self._move(s.name, lifecycle_mod.RETIRED)
604
+ return out
605
+
606
+ def report(self) -> dict[str, Mapping[str, Any]]:
607
+ """Every compartment's report, each isolated as trouble() and notes()
608
+ already were.
609
+
610
+ This is what health(), status() and status.json are made of, and the
611
+ tick writes status.json. One compartment whose report() raised used to
612
+ raise here, so the node did its work and then failed the tick, every
613
+ tick, and `meshnode health` failed with it (audit 2026-10-15, B2). A
614
+ report that raises is now that compartment's entry, an `error`, and a
615
+ `report_error` trouble until a later report() succeeds - the others
616
+ still report.
617
+ """
618
+ out: dict[str, Mapping[str, Any]] = {}
619
+ failed: dict[str, str] = {}
620
+ for s in self:
621
+ try:
622
+ out[s.name or ''] = s.report()
623
+ except Exception as e:
624
+ failed[s.name or ''] = '%s: %s' % (type(e).__name__, e)
625
+ out[s.name or ''] = {'error': failed[s.name or '']}
626
+ self._report_failed = failed
627
+ return out
628
+
629
+ def trouble(self) -> list[str]:
630
+ out = TroubleList()
631
+ for s in self:
632
+ try:
633
+ for t in (s.trouble() or []):
634
+ if isinstance(t, Trouble):
635
+ comp = t.compartment or s.name or ''
636
+ msg = '%s: %s' % (comp, t.message)
637
+ out.append(Trouble(
638
+ msg,
639
+ code=t.code,
640
+ compartment=comp,
641
+ severity=t.severity,
642
+ since=t.since,
643
+ hint=t.hint,
644
+ **t.metadata,
645
+ ))
646
+ else:
647
+ out.append(Trouble(
648
+ '%s: %s' % (s.name, t),
649
+ code='fault',
650
+ compartment=s.name or '',
651
+ severity='trouble',
652
+ ))
653
+ except Exception as e:
654
+ out.append(Trouble(
655
+ '%s: could not report trouble (%s)' % (s.name, e),
656
+ code='report_error',
657
+ compartment=s.name or '',
658
+ severity='fatal',
659
+ ))
660
+ # A quarantine is trouble whatever the compartment itself reports:
661
+ # while its breaker is open it is not ticking, and it may not be in a
662
+ # position to say so.
663
+ for n in self.quarantined():
664
+ tr = self._life[n]
665
+ msg = ('%s: quarantined - not ticking until %s (trip %d; last: %s); '
666
+ 'the control command `resume` with name=%s ends it now'
667
+ % (n, time.strftime('%H:%M:%SZ', time.gmtime(tr.until)), tr.trips,
668
+ tr.last_error or 'unknown', n))
669
+ out.append(Trouble(msg, code='quarantined', compartment=n, severity='fault'))
670
+ for n in self.failed():
671
+ tr = self._life[n]
672
+ msg = ('%s: failed to start (%s) and does not declare restart; it is not running '
673
+ 'until the node restarts' % (n, tr.last_error or 'unknown'))
674
+ out.append(Trouble(msg, code='failed_start', compartment=n, severity='fatal'))
675
+ # As of the last report(): health() takes the report first, and a
676
+ # second report() here would double the cost of every health read.
677
+ for n, err in self._report_failed.items():
678
+ out.append(Trouble('%s: could not report its state (%s)' % (n, err),
679
+ code='report_error', compartment=n, severity='fatal'))
680
+ for st in self.store_trouble():
681
+ out.append(Trouble(st, code='store_trouble', severity='trouble'))
682
+ return out
683
+
684
+ def trouble_items(self) -> list[Trouble]:
685
+ """All active troubles as structured Trouble instances."""
686
+ res = self.trouble()
687
+ return [t if isinstance(t, Trouble) else Trouble(str(t)) for t in res]
688
+
689
+
690
+ def notes(self) -> NoteList:
691
+ out = NoteList()
692
+ for s in self:
693
+ try:
694
+ for t in (s.notes() or []):
695
+ if isinstance(t, Note):
696
+ out.append(Note(
697
+ '%s: %s' % (s.name, t),
698
+ topic=t.topic,
699
+ compartment=s.name or '',
700
+ level=t.level,
701
+ since=t.since,
702
+ **t.metadata,
703
+ ))
704
+ else:
705
+ out.append(Note('%s: %s' % (s.name, t), compartment=s.name or ''))
706
+ except Exception as e:
707
+ out.append(Note('%s: could not report notes (%s)' % (s.name, e), compartment=s.name or '', level='notice'))
708
+ for n, tr in self._life.items():
709
+ if tr.state == lifecycle_mod.DEGRADED:
710
+ out.append(Note(
711
+ '%s: degraded - the last tick raised (%s); it recovers after one clean tick'
712
+ % (n, tr.last_error or 'unknown'),
713
+ compartment=n,
714
+ topic='degraded',
715
+ level='notice',
716
+ ))
717
+ for sn in self.store_notes():
718
+ out.append(Note(sn, topic='store', level='info'))
719
+ return out
720
+
721
+ def note_items(self) -> list[Note]:
722
+ """All active notes as structured Note instances."""
723
+ res = self.notes()
724
+ return [n if isinstance(n, Note) else Note(str(n)) for n in res]
725
+
726
+
727
+ def refusals(self, n: int = 5) -> list[tuple[Optional[str], Refusal]]:
728
+ """Every no this node said, across every compartment, newest last."""
729
+ out: list[tuple[Optional[str], Refusal]] = []
730
+ for s in self:
731
+ for r in s.refusals.last(n):
732
+ out.append((s.name, r))
733
+ return sorted(out, key=lambda p: p[1].at)
734
+
735
+ def live_keys(self) -> tuple[str, ...]:
736
+ keys: list[str] = []
737
+ for s in self:
738
+ keys += [k for k in s.live if k not in keys]
739
+ return tuple(keys)
740
+
741
+ def commands(self) -> collections.OrderedDict[str, CommandSpec]:
742
+ """name -> spec, prefixed by subsystem, for the control socket."""
743
+ out: collections.OrderedDict[str, CommandSpec] = collections.OrderedDict()
744
+ for s in self:
745
+ for cmd, spec in (s.commands or {}).items():
746
+ named: CommandSpec = {**spec, 'subsystem': s.name or ''}
747
+ out['%s.%s' % (s.name, cmd)] = named
748
+ return out
749
+
750
+ def declared_commands(self) -> tuple[Any, ...]:
751
+ """Every Command declared across compartments."""
752
+ out: list[Any] = []
753
+ for s in self:
754
+ out.extend(getattr(s, 'COMMANDS', ()))
755
+ return tuple(out)
756
+
757
+ def dispatch(self, name: str, args: Optional[Mapping[str, Any]]) -> Any:
758
+ # All three refusals below are the caller naming something that does
759
+ # not exist - a typo, not a fault in this node. They were plain
760
+ # KeyErrors, which the control socket's boundary treats as a bug: it
761
+ # prefixes the type name and appends to its 200-entry ring, and
762
+ # health reports the last five of that ring. So five `meshctl` typos
763
+ # put five errors in the node's own health and pushed any real bug
764
+ # out of the window (audit 2026-10-07, I14). ControlError is the
765
+ # declared way to answer the caller and leave no trace. The import is
766
+ # function-local to keep compartment/ off app/ at module scope, which
767
+ # is how the compartments that already raise it do it.
768
+ from ..app import socket as control_mod
769
+ sub, _, cmd = name.partition('.')
770
+ s = self.get(sub)
771
+ if s is None:
772
+ raise control_mod.ControlError("unknown compartment %r in command %r" % (sub, name))
773
+ if not cmd:
774
+ raise control_mod.ControlError("missing command in %r" % (name,))
775
+ if s.commands and cmd not in s.commands:
776
+ raise control_mod.ControlError(
777
+ "unknown command %r on compartment %r; available: %s" % (cmd, sub, list(s.commands)))
778
+ try:
779
+ return s.command(cmd, args or {})
780
+ except KeyError as e:
781
+ # `Compartment.command` raises KeyError(name) for a name it does
782
+ # not declare - the same caller mistake as the three above, one
783
+ # level down, reached when a compartment publishes no `commands`
784
+ # mapping for the check above to screen against. Only that exact
785
+ # shape is converted: a KeyError raised from inside a command's
786
+ # own body is a real bug and must keep reaching the error ring.
787
+ if e.args == (cmd,):
788
+ raise control_mod.ControlError(
789
+ "unknown command %r on compartment %r" % (cmd, sub)) from None
790
+ raise