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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
node/compartment/base.py
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""BASE: the abstract compartment every mechanism in this node extends.
|
|
2
|
+
|
|
3
|
+
A compartment is one mechanism of the node - link, budget, announce - and it
|
|
4
|
+
is written as a set of declarations rather than wired into the node by hand.
|
|
5
|
+
Six of them, each a tuple of frozen records on the class, each with a census
|
|
6
|
+
behind it and a package that defines it:
|
|
7
|
+
|
|
8
|
+
PARAMS what it can be told its config block (node/config)
|
|
9
|
+
CHANNELS what it says node/channels
|
|
10
|
+
METRICS what it measures node/metrics
|
|
11
|
+
COMMANDS what it can be asked to do node/compartment/command.py
|
|
12
|
+
LIFECYCLE how it fails, and restarts node/lifecycle
|
|
13
|
+
STORES what it keeps in memory node/stores
|
|
14
|
+
|
|
15
|
+
The base class is abstract in two senses, both enforced:
|
|
16
|
+
|
|
17
|
+
* It cannot be built. `Compartment(node)` raises, and so does any class
|
|
18
|
+
that says `class X(Compartment, abstract=True)` - a KIND of compartment,
|
|
19
|
+
like sidecar_bridge.Bridge, which is extended rather than run.
|
|
20
|
+
* Its declarations are linted when a subclass is DEFINED, not when a node
|
|
21
|
+
happens to build it. A concrete compartment must have a name; a store,
|
|
22
|
+
command, channel or metric must be unique within it; a snapshot must have
|
|
23
|
+
something to carry; a command's `reads`/`writes` must name a store this
|
|
24
|
+
compartment declares, or one of a compartment it `requires` or `uses`.
|
|
25
|
+
A class that breaks one of those fails at import, with every problem
|
|
26
|
+
listed - which is what makes the declarations a lint rather than prose.
|
|
27
|
+
|
|
28
|
+
And it is a working default for everything a compartment need not say: no
|
|
29
|
+
tick, no state, no trouble, and a snapshot derived from its SNAPSHOT stores.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from typing import (
|
|
36
|
+
TYPE_CHECKING,
|
|
37
|
+
Any,
|
|
38
|
+
Callable,
|
|
39
|
+
ClassVar,
|
|
40
|
+
Mapping,
|
|
41
|
+
Optional,
|
|
42
|
+
Protocol,
|
|
43
|
+
Sequence,
|
|
44
|
+
TypedDict,
|
|
45
|
+
TypeVar,
|
|
46
|
+
Union,
|
|
47
|
+
runtime_checkable,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from ..channels import journal
|
|
51
|
+
from . import lifecycle as lifecycle_mod
|
|
52
|
+
from . import stores as stores_mod
|
|
53
|
+
from ..interfaces import CommandSpec
|
|
54
|
+
from .command import Command
|
|
55
|
+
from .every import Every
|
|
56
|
+
from .problem import (
|
|
57
|
+
Note,
|
|
58
|
+
NoteList,
|
|
59
|
+
Trouble,
|
|
60
|
+
TroubleList,
|
|
61
|
+
)
|
|
62
|
+
from .refusals import Refusals
|
|
63
|
+
from .worker import Worker
|
|
64
|
+
|
|
65
|
+
T = TypeVar('T', bound='Compartment')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EveryJobReport(TypedDict):
|
|
69
|
+
"""One EVERY job as `report()` gives it. `seconds` and `next_run` are
|
|
70
|
+
None for a job that is off (`Every.is_off`)."""
|
|
71
|
+
seconds: Optional[Union[int, float]]
|
|
72
|
+
last_run: Optional[int]
|
|
73
|
+
next_run: Optional[int]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def wall() -> int:
|
|
77
|
+
"""The wall clock in whole seconds: every compartment's default clock."""
|
|
78
|
+
return int(time.time())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from .registry import Registry
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@runtime_checkable
|
|
86
|
+
class CompartmentProtocol(Protocol):
|
|
87
|
+
"""The contract the Registry relies on, stated structurally.
|
|
88
|
+
|
|
89
|
+
`Compartment` below is the convenient base class and every compartment in
|
|
90
|
+
node/ extends it, but the registry's verbs need only this surface. Naming
|
|
91
|
+
it lets a test double or a foreign compartment be typed against the
|
|
92
|
+
contract without inheriting the base.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
# Read-only in the contract: a compartment declares `name: str = 'strain'`
|
|
96
|
+
# and a plain mutable attribute would make that an invariance mismatch.
|
|
97
|
+
@property
|
|
98
|
+
def name(self) -> Optional[str]: ...
|
|
99
|
+
@property
|
|
100
|
+
def attr(self) -> Optional[str]: ...
|
|
101
|
+
@property
|
|
102
|
+
def requires(self) -> tuple[str, ...]: ...
|
|
103
|
+
@property
|
|
104
|
+
def uses(self) -> tuple[str, ...]: ...
|
|
105
|
+
@property
|
|
106
|
+
def optional(self) -> bool: ...
|
|
107
|
+
@property
|
|
108
|
+
def enabled_by(self) -> Optional[str]: ...
|
|
109
|
+
@property
|
|
110
|
+
def reads(self) -> tuple[str, ...]: ...
|
|
111
|
+
@property
|
|
112
|
+
def live(self) -> tuple[str, ...]: ...
|
|
113
|
+
@property
|
|
114
|
+
def config_cls(self) -> Optional[type[Any]]: ...
|
|
115
|
+
@property
|
|
116
|
+
def api(self) -> str: ...
|
|
117
|
+
@property
|
|
118
|
+
def protocol(self) -> str: ...
|
|
119
|
+
@property
|
|
120
|
+
def piuris(self) -> tuple[str, ...]: ...
|
|
121
|
+
@property
|
|
122
|
+
def exact_types(self) -> tuple[str, ...]: ...
|
|
123
|
+
@property
|
|
124
|
+
def commands(self) -> Mapping[str, CommandSpec]: ...
|
|
125
|
+
@property
|
|
126
|
+
def CHANNELS(self) -> tuple[Any, ...]: ...
|
|
127
|
+
@property
|
|
128
|
+
def METRICS(self) -> tuple[Any, ...]: ...
|
|
129
|
+
@property
|
|
130
|
+
def COMMANDS(self) -> tuple[Any, ...]: ...
|
|
131
|
+
@property
|
|
132
|
+
def EVERY(self) -> tuple[Any, ...]: ...
|
|
133
|
+
@property
|
|
134
|
+
def WORKERS(self) -> tuple[Any, ...]: ...
|
|
135
|
+
@property
|
|
136
|
+
def LIFECYCLE(self) -> lifecycle_mod.Lifecycle: ...
|
|
137
|
+
@property
|
|
138
|
+
def STORES(self) -> tuple[Any, ...]: ...
|
|
139
|
+
@property
|
|
140
|
+
def NEEDS(self) -> tuple[Any, ...]: ...
|
|
141
|
+
@property
|
|
142
|
+
def SCHEMA(self) -> tuple[Any, ...]: ...
|
|
143
|
+
@property
|
|
144
|
+
def MIGRATIONS(self) -> tuple[Any, ...]: ...
|
|
145
|
+
@property
|
|
146
|
+
def HANDLERS(self) -> tuple[Any, ...]: ...
|
|
147
|
+
@property
|
|
148
|
+
def holdings(self) -> Optional[stores_mod.Holdings]: ...
|
|
149
|
+
@property
|
|
150
|
+
def db(self) -> Any: ...
|
|
151
|
+
@property
|
|
152
|
+
def refusals(self) -> Refusals: ...
|
|
153
|
+
@property
|
|
154
|
+
def cli(self) -> tuple[tuple[str, Callable[..., Any]], ...]: ...
|
|
155
|
+
@property
|
|
156
|
+
def sidecars(self) -> tuple[Any, ...]: ...
|
|
157
|
+
|
|
158
|
+
def wire(self, registry: 'Registry', ctx: Any) -> None: ...
|
|
159
|
+
def start(self) -> None: ...
|
|
160
|
+
def start_workers(self) -> None: ...
|
|
161
|
+
def stop_workers(self) -> None: ...
|
|
162
|
+
def tick(self, now: int) -> list[tuple[Any, ...]]: ...
|
|
163
|
+
def run_tick(self, now: int) -> list[tuple[Any, ...]]: ...
|
|
164
|
+
def stop(self) -> Any: ...
|
|
165
|
+
def state(self) -> Mapping[str, Any]: ...
|
|
166
|
+
def trouble(self) -> list[str]: ...
|
|
167
|
+
def notes(self) -> list[str]: ...
|
|
168
|
+
def report(self) -> Mapping[str, Any]: ...
|
|
169
|
+
def sections(self) -> Mapping[str, Any]: ...
|
|
170
|
+
def snapshot(self) -> Mapping[str, Any]: ...
|
|
171
|
+
def restore(self, state: Mapping[str, Any]) -> None: ...
|
|
172
|
+
def command(self, name: str, args: Mapping[str, Any]) -> Any: ...
|
|
173
|
+
def handle(self, inbound: Any) -> Any: ...
|
|
174
|
+
def capability_protocols(self) -> Sequence[str]: ...
|
|
175
|
+
def descriptor_tags(self) -> Sequence[Sequence[str]]: ...
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Compartment:
|
|
180
|
+
"""One compartment of the node. Assembled, ticked and read uniformly.
|
|
181
|
+
|
|
182
|
+
The `subsystem/1` declaration. Every compartment states, as class
|
|
183
|
+
attributes, what it is and what it needs, and `Registry.assemble()` reads
|
|
184
|
+
those to build the node in dependency order:
|
|
185
|
+
|
|
186
|
+
name the one word the CLI, status.json and the docs use
|
|
187
|
+
requires names of compartments this one must be built after
|
|
188
|
+
(it receives them already constructed through ctx)
|
|
189
|
+
uses names of OPTIONAL compartments this one reaches when
|
|
190
|
+
they are present; it must still work without them. The
|
|
191
|
+
assembler holds them to the same manifest order as
|
|
192
|
+
`requires` (listed earlier) but does not demand they be
|
|
193
|
+
built. Resolve each once in `from_context` with
|
|
194
|
+
`ctx.optional(name)`; never probe the node per call.
|
|
195
|
+
optional True if the node runs without it; it is then built only
|
|
196
|
+
when `enabled_by` names a config section that is present
|
|
197
|
+
enabled_by the config key whose presence turns an optional one on
|
|
198
|
+
reads / live config keys read; of those, the ones re-read each tick
|
|
199
|
+
api the declaration version this class was written against
|
|
200
|
+
protocol the schema digest it was built against, or '' if it
|
|
201
|
+
reads no schema (checked at assembly when set)
|
|
202
|
+
piuris the wire protocols (PIURI prefixes) this one handles;
|
|
203
|
+
the registry routes each to its `handle(inbound)`
|
|
204
|
+
exact_types whole message types it handles, routed exactly
|
|
205
|
+
|
|
206
|
+
`from_context(ctx)` is the one constructor the assembler calls. The
|
|
207
|
+
default forwards to `cls(node=ctx.node)` so a compartment that has not
|
|
208
|
+
been migrated still builds; a migrated one overrides it and names its
|
|
209
|
+
dependencies there and nowhere else.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
API: str = 'subsystem/1'
|
|
213
|
+
|
|
214
|
+
# How far back `trouble` looks. Long enough to catch a fault that recurs on
|
|
215
|
+
# a slow cadence - receipts go every ten minutes - and short enough that a
|
|
216
|
+
# fault which has stopped stops being reported.
|
|
217
|
+
TROUBLE_WINDOW: int = 1800
|
|
218
|
+
|
|
219
|
+
name: Optional[str] = None
|
|
220
|
+
attr: Optional[str] = None # attribute the node keeps it under, when not `name`
|
|
221
|
+
requires: tuple[str, ...] = () # compartments built before this one
|
|
222
|
+
uses: tuple[str, ...] = () # optional compartments reached when present
|
|
223
|
+
optional: bool = False # off unless `enabled_by` is configured
|
|
224
|
+
enabled_by: Optional[str] = None # config key that turns an optional one on
|
|
225
|
+
reads: tuple[str, ...] = () # config keys this compartment reads
|
|
226
|
+
live: tuple[str, ...] = () # of those, the ones a running node re-reads each tick
|
|
227
|
+
config_cls: Optional[type[Any]] = None # the node/config Block that types this one's own key
|
|
228
|
+
api: str = API
|
|
229
|
+
protocol: str = '' # schema digest built against, or ''
|
|
230
|
+
piuris: tuple[str, ...] = () # PIURI prefixes routed to handle()
|
|
231
|
+
exact_types: tuple[str, ...] = () # exact message types routed to handle()
|
|
232
|
+
commands: Mapping[str, CommandSpec] = {} # name -> {'args': {...}, 'does': str, 'reply': str}
|
|
233
|
+
# What this compartment can say, declared here rather than as a topic name
|
|
234
|
+
# in a central list. A Channel carries both renderings - the structured
|
|
235
|
+
# payload a reader gets and the line a person reads - so an event cannot
|
|
236
|
+
# reach the control socket and miss the journal, which is what 45
|
|
237
|
+
# journal.line call sites against 6 bus emits had already produced. See
|
|
238
|
+
# node/channels.py.
|
|
239
|
+
CHANNELS: tuple[Any, ...] = ()
|
|
240
|
+
# What this compartment measures. node/metrics reads it through
|
|
241
|
+
# getattr(obj, 'METRICS', ()), so the census never needed the default -
|
|
242
|
+
# which is exactly why its absence went unnoticed until something asked a
|
|
243
|
+
# compartment for `c.METRICS` directly and got AttributeError on 27 of
|
|
244
|
+
# 29. Four surfaces, four defaults: a declaration surface that is only
|
|
245
|
+
# present when used is not a surface.
|
|
246
|
+
METRICS: tuple[Any, ...] = ()
|
|
247
|
+
COMMANDS: tuple[Any, ...] = ()
|
|
248
|
+
EVERY: tuple[Any, ...] = ()
|
|
249
|
+
WORKERS: tuple[Any, ...] = ()
|
|
250
|
+
# How this compartment behaves when it fails: the breaker that turns
|
|
251
|
+
# repeated tick failures into a quarantine with backoff. The default
|
|
252
|
+
# suits nearly every compartment; one that must be ticked whatever
|
|
253
|
+
# happens declares Lifecycle(breaker=Breaker(failures=0)). See
|
|
254
|
+
# node/lifecycle.
|
|
255
|
+
LIFECYCLE: lifecycle_mod.Lifecycle = lifecycle_mod.DEFAULT
|
|
256
|
+
# What this compartment keeps: every collection that grows with the
|
|
257
|
+
# outside world, with its bound and what happens at it. The base builds
|
|
258
|
+
# one container per store and binds it as `self.<key>`, so the
|
|
259
|
+
# declaration is also the allocation and the two cannot drift. See
|
|
260
|
+
# node/stores.
|
|
261
|
+
STORES: tuple[stores_mod.Store, ...] = ()
|
|
262
|
+
# What this compartment takes from the node: its dependencies, declared
|
|
263
|
+
# where the compartment is. The base `from_context` resolves each against
|
|
264
|
+
# the NodeContext at assembly. See node/compartment/need.py.
|
|
265
|
+
NEEDS: tuple[Any, ...] = ()
|
|
266
|
+
# What this compartment persists: relational database schema, declared
|
|
267
|
+
# as Table and Index entries. The base instantiates a database adapter
|
|
268
|
+
# (sqlite, psql, etc.) bound to the compartment.
|
|
269
|
+
SCHEMA: tuple[Any, ...] = ()
|
|
270
|
+
# Schema evolution: versioned migrations applied in order by the database
|
|
271
|
+
# adapter after SCHEMA.
|
|
272
|
+
MIGRATIONS: tuple[Any, ...] = ()
|
|
273
|
+
# Inbound wire protocol message handlers, dispatched by base handle(inbound).
|
|
274
|
+
HANDLERS: tuple[Any, ...] = ()
|
|
275
|
+
# CLI extension verbs declared by a plugin or compartment: (('verb', register_fn),)
|
|
276
|
+
cli: tuple[tuple[str, Callable[..., Any]], ...] = ()
|
|
277
|
+
# Sidecar manifests declared by a plugin or compartment: ({...}, '@/path/to/manifest.json')
|
|
278
|
+
sidecars: tuple[Any, ...] = ()
|
|
279
|
+
# Runtime collections, database adapter, and refusals buffer bound to this instance
|
|
280
|
+
holdings: Optional[stores_mod.Holdings] = None
|
|
281
|
+
db: Any = None
|
|
282
|
+
# The store class a compartment's `db` must be, when it is not the generic
|
|
283
|
+
# adapter. from_context fills a `db` parameter, and without this it filled
|
|
284
|
+
# it with a SqliteAdapter whatever the class used it as - so Updates, which
|
|
285
|
+
# reads `db` as an UpdateStore, could not be built by a running node at
|
|
286
|
+
# all (audit 2026-10-15, B13). Named here, the store is opened as that
|
|
287
|
+
# class, through the node's locator like every other store.
|
|
288
|
+
#
|
|
289
|
+
# Naming it also says the compartment owns its store: the base opens one
|
|
290
|
+
# only to hand to a `db` parameter, never on its own account. A class that
|
|
291
|
+
# declares SCHEMA and builds its own store used to get one or two generic
|
|
292
|
+
# adapters as well, which nothing read - an orphan `<name>.sqlite` on disk
|
|
293
|
+
# and a handle left to the collector (B5).
|
|
294
|
+
STORE_CLS: Optional[type] = None
|
|
295
|
+
refusals: Refusals = None # type: ignore[assignment]
|
|
296
|
+
|
|
297
|
+
# Set from the class keyword: `class Bridge(Compartment, abstract=True)`.
|
|
298
|
+
# Read from the class's own __dict__, so a concrete subclass of an
|
|
299
|
+
# abstract kind is concrete without saying so.
|
|
300
|
+
abstract: ClassVar[bool] = True
|
|
301
|
+
|
|
302
|
+
def __init_subclass__(cls, abstract: bool = False, **kw: Any) -> None:
|
|
303
|
+
"""Derive what is derived, then lint what is declared.
|
|
304
|
+
|
|
305
|
+
Derived: `commands` from `COMMANDS`, so the control socket and the CLI
|
|
306
|
+
never need a hand-written dict beside the declarations; and `live`
|
|
307
|
+
for this compartment's own config key from its parameters (see
|
|
308
|
+
`_derive_live`).
|
|
309
|
+
|
|
310
|
+
Linted: everything in `declaration_problems`. A problem is a
|
|
311
|
+
TypeError naming the class and every problem at once, raised here -
|
|
312
|
+
at import - so a malformed compartment never reaches a node.
|
|
313
|
+
"""
|
|
314
|
+
super().__init_subclass__(**kw)
|
|
315
|
+
cls.abstract = abstract
|
|
316
|
+
# Read before `commands` is derived, which writes it into __dict__.
|
|
317
|
+
hand_written = 'commands' in cls.__dict__
|
|
318
|
+
if cls.COMMANDS:
|
|
319
|
+
cls.commands = {c.key: c.to_spec(subsystem=cls.name or '')
|
|
320
|
+
for c in cls.COMMANDS if isinstance(c, Command)}
|
|
321
|
+
if cls.HANDLERS and 'exact_types' not in cls.__dict__:
|
|
322
|
+
exact_from_handlers = tuple(
|
|
323
|
+
h.kind for h in cls.HANDLERS
|
|
324
|
+
if getattr(h, 'exact', False) or getattr(h, 'kind', '').startswith(('http://', 'https://'))
|
|
325
|
+
)
|
|
326
|
+
if exact_from_handlers:
|
|
327
|
+
cls.exact_types = tuple(sorted(set(cls.exact_types + exact_from_handlers)))
|
|
328
|
+
cls._derive_live()
|
|
329
|
+
problems = cls.declaration_problems()
|
|
330
|
+
if cls.COMMANDS and hand_written:
|
|
331
|
+
problems.append('a compartment that declares COMMANDS does not also write `commands`: '
|
|
332
|
+
'it is derived from them')
|
|
333
|
+
if problems:
|
|
334
|
+
raise TypeError('%s declares what it cannot be:\n %s' % (cls.__qualname__, '\n '.join(problems)))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@classmethod
|
|
338
|
+
def _derive_live(cls) -> None:
|
|
339
|
+
"""`live` for this compartment's own config key, from its parameters.
|
|
340
|
+
|
|
341
|
+
`live` named the config keys a running node re-reads each tick, and
|
|
342
|
+
`Parameter.live` says the same thing per setting - two expressions of
|
|
343
|
+
one fact, kept in step by hand. The parameters win: whether a reload
|
|
344
|
+
reaches a value is a property of the value, and the compartment's
|
|
345
|
+
tuple was the copy.
|
|
346
|
+
|
|
347
|
+
Only the compartment's OWN key is derived, and only when its block
|
|
348
|
+
states parameters. Other keys in `live` belong to blocks this
|
|
349
|
+
compartment merely reads (`scheduler` re-reads `peers`, which is a
|
|
350
|
+
NodeConfig field and not its own block) and stay declared.
|
|
351
|
+
"""
|
|
352
|
+
blk = cls.config_cls
|
|
353
|
+
params = getattr(blk, 'PARAMS', None)
|
|
354
|
+
own = getattr(blk, 'key', '') if blk is not None else ''
|
|
355
|
+
if params is None or not own:
|
|
356
|
+
return
|
|
357
|
+
rest = tuple(k for k in (cls.live or ()) if k != own)
|
|
358
|
+
cls.live = rest + ((own,) if any(p.live for p in params) else ())
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def declaration_problems(cls) -> list[str]:
|
|
362
|
+
"""Everything wrong with this class's declarations, as sentences.
|
|
363
|
+
|
|
364
|
+
Collected rather than raised one at a time, like `Metric.problems`
|
|
365
|
+
and `config.Problems`: whoever fixes one wants to see them all.
|
|
366
|
+
Returns Problems (a list subclass) holding typed Problem instances.
|
|
367
|
+
"""
|
|
368
|
+
from . import lint as lint_mod
|
|
369
|
+
return lint_mod.declaration_problems(cls, Compartment)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@classmethod
|
|
373
|
+
def store_refs(cls) -> tuple[tuple[str, str, str], ...]:
|
|
374
|
+
"""(command, compartment, store) for every store of ANOTHER
|
|
375
|
+
compartment this one's commands read."""
|
|
376
|
+
out: list[tuple[str, str, str]] = []
|
|
377
|
+
for c in cls.COMMANDS:
|
|
378
|
+
if isinstance(c, Command):
|
|
379
|
+
for ref in c.reads:
|
|
380
|
+
other, dot, key = ref.partition('.')
|
|
381
|
+
if dot:
|
|
382
|
+
out.append((c.key, other, key))
|
|
383
|
+
return tuple(out)
|
|
384
|
+
|
|
385
|
+
def __new__(cls, *args: Any, **kw: Any) -> 'Compartment':
|
|
386
|
+
if cls.__dict__.get('abstract', False):
|
|
387
|
+
raise TypeError('%s is abstract: extend it, with a name, rather than build it' % cls.__name__)
|
|
388
|
+
return super().__new__(cls)
|
|
389
|
+
|
|
390
|
+
def __init__(
|
|
391
|
+
self,
|
|
392
|
+
node: Any = None,
|
|
393
|
+
clock: Optional[Callable[[], int]] = None,
|
|
394
|
+
cfg: Optional[Mapping[str, Any] | Callable[[], Mapping[str, Any]]] = None,
|
|
395
|
+
says: Optional[Any] = None,
|
|
396
|
+
db: Optional[Any] = None,
|
|
397
|
+
) -> None:
|
|
398
|
+
self.node: Any = node
|
|
399
|
+
# The clock every compartment reads: the node's (`ctx.clock`), or the
|
|
400
|
+
# wall in whole seconds. 28 of 29 compartments took one and 25 wrote
|
|
401
|
+
# the same default by hand, after this ran - which is why the ledger
|
|
402
|
+
# below used to reach for `getattr(self, 'clock', time.time)`. It is
|
|
403
|
+
# still read late, through `self.clock`, so a suite that swaps the
|
|
404
|
+
# clock on a built compartment moves the refusals with it.
|
|
405
|
+
self.clock: Callable[[], int] = clock or wall
|
|
406
|
+
self._injected_cfg: Optional[Mapping[str, Any] | Callable[[], Mapping[str, Any]]] = cfg
|
|
407
|
+
self._injected_says: Optional[Any] = says
|
|
408
|
+
self.refusals: Refusals = Refusals(self.name, clock=lambda: int(self.clock()))
|
|
409
|
+
# One container per declared store, each bound under its key. A
|
|
410
|
+
# subclass that assigns `self.<key>` afterwards has replaced the
|
|
411
|
+
# container with something unbounded; Registry.add refuses that.
|
|
412
|
+
self.holdings: stores_mod.Holdings = stores_mod.Holdings(self.STORES)
|
|
413
|
+
for key, container in self.holdings.items():
|
|
414
|
+
setattr(self, key, container)
|
|
415
|
+
self._every_last: dict[str, int] = {}
|
|
416
|
+
self._stop_event: threading.Event = threading.Event()
|
|
417
|
+
self._worker_threads: dict[str, threading.Thread] = {}
|
|
418
|
+
|
|
419
|
+
if db is not None:
|
|
420
|
+
self.db: Any = db
|
|
421
|
+
elif getattr(self, 'db', None) is not None:
|
|
422
|
+
pass # Already initialized by subclass
|
|
423
|
+
elif self.SCHEMA and self.STORE_CLS is None:
|
|
424
|
+
self.db = type(self).open_store(getattr(node, 'locator', None), getattr(node, 'dir', None),
|
|
425
|
+
self.clock)
|
|
426
|
+
else:
|
|
427
|
+
self.db = None
|
|
428
|
+
|
|
429
|
+
@classmethod
|
|
430
|
+
def open_store(cls, locator: Any, directory: Optional[str], clock: Callable[[], int]) -> Any:
|
|
431
|
+
"""This compartment's store, where the node keeps it. The one place a
|
|
432
|
+
compartment's database is opened: `__init__` and `from_context` each
|
|
433
|
+
had a copy of this, and both ran for one build (audit D5).
|
|
434
|
+
|
|
435
|
+
STORE_CLS when named, else the generic adapter over SCHEMA. Through
|
|
436
|
+
the node's locator when it has one, its directory when not, and in
|
|
437
|
+
memory for a compartment built with neither, as a suite builds it.
|
|
438
|
+
"""
|
|
439
|
+
from ..storage.adapter import SqliteAdapter, adapter_from_locator
|
|
440
|
+
from ..storage.locator import Locator
|
|
441
|
+
if locator is None and directory:
|
|
442
|
+
locator = Locator.from_config(directory)
|
|
443
|
+
name = cls.name or 'store'
|
|
444
|
+
if cls.STORE_CLS is not None:
|
|
445
|
+
return locator.open(cls.STORE_CLS, name) if locator is not None else cls.STORE_CLS(':memory:')
|
|
446
|
+
if locator is not None:
|
|
447
|
+
return adapter_from_locator(locator, name, schema=cls.SCHEMA, migrations=cls.MIGRATIONS, clock=clock)
|
|
448
|
+
return SqliteAdapter(':memory:', schema=cls.SCHEMA, migrations=cls.MIGRATIONS, clock=clock)
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def stop_event(self) -> threading.Event:
|
|
452
|
+
return self._stop_event
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def stopping(self) -> bool:
|
|
456
|
+
return self._stop_event.is_set()
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def from_context(cls: type[T], ctx: Any) -> T:
|
|
460
|
+
"""Build from a NodeContext. When NEEDS is declared, constructs from declarations."""
|
|
461
|
+
declared: tuple[Any, ...] = tuple(getattr(cls, 'NEEDS', ()) or ())
|
|
462
|
+
if not declared and not cls.SCHEMA:
|
|
463
|
+
return cls(node=ctx.node)
|
|
464
|
+
kwargs = {}
|
|
465
|
+
for need in declared:
|
|
466
|
+
if not getattr(need, 'late', False):
|
|
467
|
+
kwargs[need.key] = need.resolve(ctx, cls)
|
|
468
|
+
import inspect
|
|
469
|
+
sig = inspect.signature(cls.__init__)
|
|
470
|
+
params = sig.parameters
|
|
471
|
+
has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
472
|
+
if ('node' in params or has_varkw) and 'node' not in kwargs:
|
|
473
|
+
kwargs['node'] = getattr(ctx, 'node', None)
|
|
474
|
+
if ('clock' in params or has_varkw) and 'clock' not in kwargs:
|
|
475
|
+
kwargs['clock'] = getattr(ctx, 'clock', None)
|
|
476
|
+
if ('cfg' in params or has_varkw) and 'cfg' not in kwargs:
|
|
477
|
+
kwargs['cfg'] = getattr(ctx, 'cfg_getter', lambda: getattr(ctx, 'cfg', None))()
|
|
478
|
+
if ('says' in params or has_varkw) and 'says' not in kwargs:
|
|
479
|
+
kwargs['says'] = getattr(ctx, 'says', None) or getattr(getattr(ctx, 'node', None), 'says', None)
|
|
480
|
+
# Opened only for a constructor that takes it. One that does not builds
|
|
481
|
+
# its own, and a store opened here for it was a handle nobody closed.
|
|
482
|
+
if cls.SCHEMA and 'db' not in kwargs and ('db' in params or has_varkw):
|
|
483
|
+
node = getattr(ctx, 'node', None)
|
|
484
|
+
kwargs['db'] = cls.open_store(getattr(ctx, 'locator', None) or getattr(node, 'locator', None),
|
|
485
|
+
getattr(ctx, 'dir', None) or getattr(node, 'dir', None),
|
|
486
|
+
ctx.clock)
|
|
487
|
+
return cls(**kwargs)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def settings(self) -> Any:
|
|
491
|
+
"""This compartment's own config block, typed, as the file says now.
|
|
492
|
+
|
|
493
|
+
`config_cls` names the Block; its `key` is read through `config()`
|
|
494
|
+
so the read is counted against `reads` like any other. Parsed on
|
|
495
|
+
each call, so a compartment that lists its key in `live` sees an
|
|
496
|
+
edit without a restart; the others call it once at construction.
|
|
497
|
+
"""
|
|
498
|
+
if self.config_cls is None:
|
|
499
|
+
raise TypeError('%s declares no config_cls' % type(self).__name__)
|
|
500
|
+
# `or {}` turned `key: false` into `{}`, which a SWITCHABLE block reads
|
|
501
|
+
# as present - so on (audit 2026-10-15, B6). from_dict already handles
|
|
502
|
+
# a bare bool, and anything else that is not a mapping, itself; only
|
|
503
|
+
# an absent key becomes {} here, as it always did.
|
|
504
|
+
raw = self.config(self.config_cls.key)
|
|
505
|
+
return self.config_cls.from_dict({} if raw is None else raw)
|
|
506
|
+
|
|
507
|
+
# ---- lifecycle ----------------------------------------------------------
|
|
508
|
+
def wire(self, registry: 'Registry', ctx: Any) -> None:
|
|
509
|
+
"""Reach the compartments built after this one, once all exist.
|
|
510
|
+
|
|
511
|
+
The assembler builds in tick order and some edges run against it:
|
|
512
|
+
link records into strain, which ticks later; peering refuses through
|
|
513
|
+
capacity, built after budget. Each compartment names those edges
|
|
514
|
+
here, with `registry.get(name)`, and nowhere else - the node used to
|
|
515
|
+
hold every one of them in one block that nothing checked. Called
|
|
516
|
+
once, after the whole manifest is built and before `start()`.
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
def start_workers(self) -> None:
|
|
520
|
+
"""Start all declared WORKERS that are not already running."""
|
|
521
|
+
if not hasattr(self, '_stop_event'):
|
|
522
|
+
self._stop_event = threading.Event()
|
|
523
|
+
self._stop_event.clear()
|
|
524
|
+
if not hasattr(self, '_worker_threads'):
|
|
525
|
+
self._worker_threads = {}
|
|
526
|
+
import inspect
|
|
527
|
+
for w in getattr(self, 'WORKERS', ()):
|
|
528
|
+
if not isinstance(w, Worker):
|
|
529
|
+
continue
|
|
530
|
+
if not w.should_run(self):
|
|
531
|
+
continue
|
|
532
|
+
existing = self._worker_threads.get(w.name)
|
|
533
|
+
if existing is not None and existing.is_alive():
|
|
534
|
+
continue
|
|
535
|
+
fn = getattr(self, w.run, None)
|
|
536
|
+
if not callable(fn):
|
|
537
|
+
continue
|
|
538
|
+
sig = inspect.signature(fn)
|
|
539
|
+
target = (lambda f=fn: f(self._stop_event)) if len(sig.parameters) > 0 else fn
|
|
540
|
+
t = threading.Thread(target=target, name=w.name or f"{self.name or 'compartment'}-{w.run}", daemon=w.daemon)
|
|
541
|
+
self._worker_threads[w.name] = t
|
|
542
|
+
t.start()
|
|
543
|
+
|
|
544
|
+
def stop_workers(self) -> None:
|
|
545
|
+
"""Signal and join all running worker threads."""
|
|
546
|
+
if hasattr(self, '_stop_event'):
|
|
547
|
+
self._stop_event.set()
|
|
548
|
+
stop_attr = getattr(self, '_stop', None)
|
|
549
|
+
if stop_attr is not None and isinstance(stop_attr, threading.Event):
|
|
550
|
+
stop_attr.set()
|
|
551
|
+
threads = getattr(self, '_worker_threads', {})
|
|
552
|
+
for w in getattr(self, 'WORKERS', ()):
|
|
553
|
+
if not isinstance(w, Worker):
|
|
554
|
+
continue
|
|
555
|
+
t = threads.get(w.name)
|
|
556
|
+
if t is not None and t.is_alive():
|
|
557
|
+
t.join(timeout=w.timeout)
|
|
558
|
+
# A thread still running after its timeout keeps its handle. Its
|
|
559
|
+
# handle used to be dropped regardless, so the next start_workers
|
|
560
|
+
# - a restart after quarantine - found no thread and started a
|
|
561
|
+
# second copy beside the one still running (audit 2026-10-15, B7).
|
|
562
|
+
# Kept, start_workers sees it alive and leaves it be; report()
|
|
563
|
+
# keeps saying it is alive.
|
|
564
|
+
if t is None or not t.is_alive():
|
|
565
|
+
threads.pop(w.name, None)
|
|
566
|
+
|
|
567
|
+
def start(self) -> None:
|
|
568
|
+
"""Everything that needs the rest of the node to exist already."""
|
|
569
|
+
self.start_workers()
|
|
570
|
+
|
|
571
|
+
def run_every(self, now: int) -> list[tuple[Any, ...]]:
|
|
572
|
+
"""Run each declared EVERY job that is due at `now`, recording last run time."""
|
|
573
|
+
did: list[tuple[Any, ...]] = []
|
|
574
|
+
if not hasattr(self, '_every_last'):
|
|
575
|
+
self._every_last = {}
|
|
576
|
+
# Each job on its own: one that raised used to end the tick for every
|
|
577
|
+
# job after it (audit 2026-10-15, B10). The first raise is kept and
|
|
578
|
+
# raised once the rest have run, so the registry still counts a failed
|
|
579
|
+
# tick and its breaker still trips. A job that raised is stamped like
|
|
580
|
+
# one that ran, so it waits its interval rather than retrying hot.
|
|
581
|
+
failed: Optional[BaseException] = None
|
|
582
|
+
for job in getattr(self, 'EVERY', ()):
|
|
583
|
+
if not isinstance(job, Every):
|
|
584
|
+
continue
|
|
585
|
+
if not job.due(self, now):
|
|
586
|
+
continue
|
|
587
|
+
self._every_last[job.name] = now
|
|
588
|
+
try:
|
|
589
|
+
res = job.execute(self, now)
|
|
590
|
+
except Exception as e:
|
|
591
|
+
if failed is None:
|
|
592
|
+
failed = e
|
|
593
|
+
continue
|
|
594
|
+
if res is None:
|
|
595
|
+
continue
|
|
596
|
+
if isinstance(res, (list, tuple)):
|
|
597
|
+
if res and isinstance(res[0], tuple):
|
|
598
|
+
did.extend(res)
|
|
599
|
+
elif res and not isinstance(res[0], tuple):
|
|
600
|
+
did.append(tuple(res))
|
|
601
|
+
elif isinstance(res, dict):
|
|
602
|
+
rep = None
|
|
603
|
+
if job.report is not None:
|
|
604
|
+
if callable(job.report):
|
|
605
|
+
rep = job.report(self, res)
|
|
606
|
+
else:
|
|
607
|
+
rep = getattr(self, job.report)(res)
|
|
608
|
+
else:
|
|
609
|
+
reporter = getattr(self, f'_report_{job.run}', None)
|
|
610
|
+
if reporter is not None:
|
|
611
|
+
rep = reporter(res)
|
|
612
|
+
if rep:
|
|
613
|
+
if isinstance(rep, (list, tuple)):
|
|
614
|
+
did.extend(rep)
|
|
615
|
+
else:
|
|
616
|
+
did.append(rep)
|
|
617
|
+
if failed is not None:
|
|
618
|
+
raise failed
|
|
619
|
+
return did
|
|
620
|
+
|
|
621
|
+
_tick_runs_every: bool = False
|
|
622
|
+
|
|
623
|
+
def tick(self, now: int) -> list[tuple[Any, ...]]:
|
|
624
|
+
"""Periodic work. Runs due EVERY jobs when not overridden."""
|
|
625
|
+
return self.run_every(now)
|
|
626
|
+
|
|
627
|
+
def run_tick(self, now: int) -> list[tuple[Any, ...]]:
|
|
628
|
+
"""Run periodic work (EVERY) and any custom tick logic."""
|
|
629
|
+
if self.__class__.tick is Compartment.tick:
|
|
630
|
+
return self.run_every(now)
|
|
631
|
+
if self._tick_runs_every:
|
|
632
|
+
return list(self.tick(now) or [])
|
|
633
|
+
return self.run_every(now) + list(self.tick(now) or [])
|
|
634
|
+
|
|
635
|
+
def stop(self) -> Any:
|
|
636
|
+
"""Anything that must be said before the node goes. Must not raise."""
|
|
637
|
+
self.stop_workers()
|
|
638
|
+
db = getattr(self, 'db', None)
|
|
639
|
+
if db is not None:
|
|
640
|
+
self.db = None
|
|
641
|
+
if hasattr(db, 'close'):
|
|
642
|
+
try:
|
|
643
|
+
db.close()
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
def __del__(self) -> None:
|
|
649
|
+
"""Close the database handle of a compartment nobody stopped. Only that.
|
|
650
|
+
|
|
651
|
+
This called stop(), and a subclass's stop() is where a compartment
|
|
652
|
+
says goodbye - the budget's is the farewell receipt to every peer. So
|
|
653
|
+
an instance the collector found, one `assemble()` had thrown away,
|
|
654
|
+
could speak for a node that never ran it (audit 2026-10-15, B8). The
|
|
655
|
+
handle is what the collector must not leak; the rest is stop()'s, and
|
|
656
|
+
stop() is the registry's to call.
|
|
657
|
+
|
|
658
|
+
Not wrapped: a raise here cannot propagate, and Python reports it
|
|
659
|
+
through sys.unraisablehook - which is the one way a finalizer can say
|
|
660
|
+
a close failed (check-swallowed counts `close` as a call that matters).
|
|
661
|
+
"""
|
|
662
|
+
db = self.__dict__.get('db')
|
|
663
|
+
if db is not None and hasattr(db, 'close'):
|
|
664
|
+
db.close()
|
|
665
|
+
|
|
666
|
+
# ---- being watched ------------------------------------------------------
|
|
667
|
+
def state(self) -> Mapping[str, Any]:
|
|
668
|
+
"""What this compartment is, as plain data. Never raises. A
|
|
669
|
+
compartment with a shape worth naming returns its TypedDict."""
|
|
670
|
+
return {}
|
|
671
|
+
|
|
672
|
+
def trouble_with(
|
|
673
|
+
self,
|
|
674
|
+
message: str,
|
|
675
|
+
*,
|
|
676
|
+
code: str = 'fault',
|
|
677
|
+
severity: str = 'trouble',
|
|
678
|
+
since: Optional[int] = None,
|
|
679
|
+
hint: Optional[str] = None,
|
|
680
|
+
**metadata: Any,
|
|
681
|
+
) -> Trouble:
|
|
682
|
+
"""Create a typed Trouble instance associated with this compartment."""
|
|
683
|
+
return Trouble(
|
|
684
|
+
message,
|
|
685
|
+
code=code,
|
|
686
|
+
compartment=self.name or '',
|
|
687
|
+
severity=severity,
|
|
688
|
+
since=since or self.clock(),
|
|
689
|
+
hint=hint,
|
|
690
|
+
**metadata,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def trouble(self) -> list[str]:
|
|
694
|
+
"""What an operator should be told without having to ask for it.
|
|
695
|
+
|
|
696
|
+
Everything here is WRONG: a watcher reds on it and a deploy fails on
|
|
697
|
+
it. Something worth reading that is not a fault goes in `notes()`.
|
|
698
|
+
May return a list of strings or typed Trouble instances.
|
|
699
|
+
"""
|
|
700
|
+
return TroubleList()
|
|
701
|
+
|
|
702
|
+
def note_with(
|
|
703
|
+
self,
|
|
704
|
+
message: str,
|
|
705
|
+
*,
|
|
706
|
+
topic: str = 'general',
|
|
707
|
+
level: str = 'info',
|
|
708
|
+
since: Optional[int] = None,
|
|
709
|
+
**metadata: Any,
|
|
710
|
+
) -> Note:
|
|
711
|
+
"""Create a typed Note instance associated with this compartment."""
|
|
712
|
+
return Note(
|
|
713
|
+
message,
|
|
714
|
+
topic=topic,
|
|
715
|
+
compartment=self.name or '',
|
|
716
|
+
level=level,
|
|
717
|
+
since=since or self.clock(),
|
|
718
|
+
**metadata,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
def notes(self) -> list[str]:
|
|
722
|
+
"""What an operator would want to read, that is not a fault.
|
|
723
|
+
|
|
724
|
+
A relay cooling after a rate-limit, a peer that announced it is
|
|
725
|
+
leaving: the node behaving, and the reason a quiet mesh is quiet.
|
|
726
|
+
Kept apart from `trouble()` so no reader has to classify prose.
|
|
727
|
+
May return a list of strings or typed Note instances.
|
|
728
|
+
"""
|
|
729
|
+
return NoteList()
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def report(self) -> Mapping[str, Any]:
|
|
733
|
+
"""state plus refusals, which is what health and the CLI both render.
|
|
734
|
+
Plus, for a compartment that declares stores, what each one holds now
|
|
735
|
+
(sizes and counts only - a report never carries the entries)."""
|
|
736
|
+
s = dict(self.state())
|
|
737
|
+
s['refusals'] = self.refusals.state()
|
|
738
|
+
holdings = getattr(self, 'holdings', None)
|
|
739
|
+
if holdings is not None and len(holdings):
|
|
740
|
+
s['stores'] = holdings.live()
|
|
741
|
+
s.update(self.sections())
|
|
742
|
+
if getattr(self, 'EVERY', ()):
|
|
743
|
+
if not hasattr(self, '_every_last'):
|
|
744
|
+
self._every_last = {}
|
|
745
|
+
every_rep: dict[str, EveryJobReport] = {}
|
|
746
|
+
for job in self.EVERY:
|
|
747
|
+
if not isinstance(job, Every):
|
|
748
|
+
continue
|
|
749
|
+
last = self._every_last.get(job.name)
|
|
750
|
+
if job.is_off(self):
|
|
751
|
+
# Off is null, not a number: there is no next run.
|
|
752
|
+
every_rep[job.name] = {'seconds': None, 'last_run': last, 'next_run': None}
|
|
753
|
+
continue
|
|
754
|
+
sec = job.get_seconds(self)
|
|
755
|
+
sec_val = int(sec) if sec == int(sec) else sec
|
|
756
|
+
next_run = (last + int(sec)) if last is not None else None
|
|
757
|
+
every_rep[job.name] = {
|
|
758
|
+
'seconds': sec_val,
|
|
759
|
+
'last_run': last,
|
|
760
|
+
'next_run': next_run,
|
|
761
|
+
}
|
|
762
|
+
s['every'] = every_rep
|
|
763
|
+
if getattr(self, 'WORKERS', ()):
|
|
764
|
+
threads = getattr(self, '_worker_threads', {})
|
|
765
|
+
s['workers'] = {
|
|
766
|
+
w.name: {
|
|
767
|
+
'alive': bool(threads.get(w.name) is not None and threads[w.name].is_alive())
|
|
768
|
+
}
|
|
769
|
+
for w in self.WORKERS if isinstance(w, Worker)
|
|
770
|
+
}
|
|
771
|
+
return s
|
|
772
|
+
|
|
773
|
+
def sections(self) -> Mapping[str, Any]:
|
|
774
|
+
"""What a KIND of compartment adds to every report of its kind - a
|
|
775
|
+
bridge, its link - so that a subclass's own state() stays its own."""
|
|
776
|
+
return {}
|
|
777
|
+
|
|
778
|
+
# ---- across a process restart ----------------------------------------------
|
|
779
|
+
def snapshot(self) -> Mapping[str, Any]:
|
|
780
|
+
"""What this compartment hands the next process: every SNAPSHOT store,
|
|
781
|
+
by key. Override only for state that is not in a store, and then
|
|
782
|
+
override restore() with it."""
|
|
783
|
+
return self.holdings.snapshot() if self.holdings is not None else {}
|
|
784
|
+
|
|
785
|
+
def restore(self, state: Mapping[str, Any]) -> None:
|
|
786
|
+
"""Take back what snapshot() handed on. All or nothing: every store
|
|
787
|
+
checks its part before any is loaded, so a raise leaves it fresh."""
|
|
788
|
+
if self.holdings is not None:
|
|
789
|
+
self.holdings.restore(state)
|
|
790
|
+
|
|
791
|
+
def get_command(self, name: str) -> Optional[Any]:
|
|
792
|
+
"""Look up a declared command by key, bound to this compartment."""
|
|
793
|
+
cmd = next((c for c in getattr(self, 'COMMANDS', ()) if getattr(c, 'key', None) == name), None)
|
|
794
|
+
if cmd is not None and hasattr(cmd, 'bind'):
|
|
795
|
+
return cmd.bind(self)
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
@property
|
|
799
|
+
def bound_commands(self) -> tuple[Any, ...]:
|
|
800
|
+
"""All declared commands bound to this compartment instance."""
|
|
801
|
+
return tuple(c.bind(self) for c in getattr(self, 'COMMANDS', ()) if hasattr(c, 'bind'))
|
|
802
|
+
|
|
803
|
+
# ---- being operated -----------------------------------------------------
|
|
804
|
+
def command(self, name: str, args: Mapping[str, Any]) -> Any:
|
|
805
|
+
"""Answer a declared command: its arguments checked against the
|
|
806
|
+
declaration, then what its `run` names. A KeyError for a name this
|
|
807
|
+
compartment does not declare; a CommandError for arguments it does
|
|
808
|
+
not take.
|
|
809
|
+
|
|
810
|
+
The KeyError is the declared contract and a dozen suites assert it, so
|
|
811
|
+
it stays. `Registry.dispatch` is what turns this one shape into a
|
|
812
|
+
ControlError on the way to the control socket - see the note there
|
|
813
|
+
(audit I14)."""
|
|
814
|
+
cmd = next((c for c in self.COMMANDS if isinstance(c, Command) and c.key == name), None)
|
|
815
|
+
if cmd is None:
|
|
816
|
+
raise KeyError(name)
|
|
817
|
+
return cmd.execute(self, args)
|
|
818
|
+
|
|
819
|
+
def handle(self, inbound: Any, *args: Any, **kw: Any) -> Any:
|
|
820
|
+
"""Answer an inbound wire protocol message according to HANDLERS declarations."""
|
|
821
|
+
if not self.HANDLERS:
|
|
822
|
+
return None
|
|
823
|
+
msg = getattr(inbound, 'message', inbound)
|
|
824
|
+
if not isinstance(msg, dict):
|
|
825
|
+
return None
|
|
826
|
+
t = msg.get('type', '')
|
|
827
|
+
if not isinstance(t, str):
|
|
828
|
+
return None
|
|
829
|
+
for h in self.HANDLERS:
|
|
830
|
+
if hasattr(h, 'matches') and h.matches(t, self.piuris):
|
|
831
|
+
return h.execute(self, inbound, *args, **kw)
|
|
832
|
+
return None
|
|
833
|
+
|
|
834
|
+
def config(self, key: str, default: Any = None) -> Any:
|
|
835
|
+
|
|
836
|
+
"""A config key, or `default`. Safe on a compartment with no node."""
|
|
837
|
+
cfg = self._injected_cfg
|
|
838
|
+
if callable(cfg):
|
|
839
|
+
cfg = cfg()
|
|
840
|
+
if cfg is None and self.node is not None:
|
|
841
|
+
cfg = getattr(self.node, 'cfg', None)
|
|
842
|
+
return (cfg or {}).get(key, default)
|
|
843
|
+
|
|
844
|
+
def say(self, name: str, *args: Any, **kw: Any) -> Any:
|
|
845
|
+
"""Raise a declared channel to the node log, if a log is reachable.
|
|
846
|
+
|
|
847
|
+
In a running node, delegates to the injected `says` logger or `self.node.says.say()`.
|
|
848
|
+
In tests or standalone runs without a full node log, formats the channel's declared
|
|
849
|
+
line and prints to `journal.line`, preserving the existing format and avoiding regressions.
|
|
850
|
+
"""
|
|
851
|
+
log = self._injected_says
|
|
852
|
+
if log is None and self.node is not None:
|
|
853
|
+
log = getattr(self.node, 'says', None)
|
|
854
|
+
if log is not None and hasattr(log, 'say'):
|
|
855
|
+
return log.say(name, *args, **kw)
|
|
856
|
+
ch = next((c for c in getattr(self, 'CHANNELS', ()) if c.name == name), None)
|
|
857
|
+
if ch is not None:
|
|
858
|
+
journal.line(' %s' % (self.name or ''), ch.said(*args, **kw))
|
|
859
|
+
return None
|
|
860
|
+
|
|
861
|
+
# ---- capability advertisement -------------------------------------------
|
|
862
|
+
def capability_protocols(self) -> Sequence[str]:
|
|
863
|
+
"""Protocols (PIURIs or dialects) this compartment advertises in kind 11801 descriptor."""
|
|
864
|
+
return ()
|
|
865
|
+
|
|
866
|
+
def descriptor_tags(self) -> Sequence[Sequence[str]]:
|
|
867
|
+
"""Extra Nostr tags this compartment adds to the kind 11801 descriptor."""
|
|
868
|
+
return ()
|