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,932 @@
1
+ """VideoStream & Transit Reflector Compartment (node.planes.services.video.compartment).
2
+
3
+ Integrates VideoStreamPackager, TransitReflectorManager, and TransitLedgerStore
4
+ into the MeshNode runtime:
5
+ - Mounts `/video/streams` and `/video/live/<stream_id>/...` onto ProtocolHost.
6
+ - Routes `https://fleetmesh.org/video-transit/1.0/*` envelopes on ProtocolRouter.
7
+ - Evaluates Capacity shed handovers (`narrow_scope` / `amend_down` vs `restore_below`)
8
+ on every node tick.
9
+ - Exposes declarative control-socket commands (`video.start_stream`, `video.status`,
10
+ `video.settle`, `video.ingest_segment`, `video.solicit_transit`) and the `stream`
11
+ CLI verb for plugin/operator dispatch.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import dataclasses
17
+ import os
18
+ from typing import Any, Callable, Mapping, Optional
19
+
20
+ from fleetmesh_core.wire.video_transit import (
21
+ TYPE_TRANSIT_CLOSE,
22
+ TYPE_TRANSIT_GRANT,
23
+ TYPE_TRANSIT_RECEIPT,
24
+ TYPE_TRANSIT_RECEIPT_ACK,
25
+ TYPE_TRANSIT_SOLICITATION,
26
+ VIDEO_TRANSIT_PIURI,
27
+ TransitGrantBody,
28
+ check_grant,
29
+ check_solicitation,
30
+ check_transit_close,
31
+ check_transit_receipt,
32
+ check_transit_receipt_ack,
33
+ make_transit_envelope,
34
+ )
35
+ from node import channels as ch_mod
36
+ from node import metrics as mx_mod
37
+ from node.compartment import Command, Compartment, Need, stores as st
38
+ from node.config import parameter as par
39
+ from node.config.parameter import ParamBlock, Parameter
40
+ from node.protocols.p2panda.store import P2PandaLogStore
41
+ from node.protocols.rhio.store import RhioBlobSync
42
+
43
+ from .barter import BarterHookResult, BarterSettlementBridge
44
+ from .cli import register_stream_cli
45
+ from .ingest import LiveMediaIngestBridge
46
+ from .ledger import TransitLedgerStore
47
+ from .packager import VideoStreamPackager
48
+ from .player import mount_player_routes
49
+ from .transit import TransitReflectorManager
50
+
51
+
52
+ @dataclasses.dataclass(frozen=True)
53
+ class VideoConfig(ParamBlock):
54
+ """Configuration block for the Video Streaming & Transit Reflector compartment."""
55
+
56
+ key = 'video'
57
+ enabled: bool = True
58
+ ring_capacity: int = 256
59
+ default_debt_ceiling_bytes: int = 500 * 1024 * 1024
60
+
61
+ PARAMS = (
62
+ Parameter[bool](
63
+ key='enabled',
64
+ name='Video Streaming & Transit',
65
+ description='Enable live CMAF/LL-HLS packaging and Transit Reflector routing.',
66
+ type=bool,
67
+ default=True,
68
+ must='be a boolean',
69
+ live=True,
70
+ tags=('video', 'transit'),
71
+ sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST),
72
+ ),
73
+ Parameter[int](
74
+ key='ring_capacity',
75
+ name='MoQ Ring Buffer Capacity',
76
+ description='Maximum frames retained per stream in the real-time MoQ ring buffer.',
77
+ type=int,
78
+ default=256,
79
+ must='be a positive integer',
80
+ live=True,
81
+ tags=('video', 'moq'),
82
+ sources=(par.ARG, par.ENV, par.FILE),
83
+ ),
84
+ Parameter[int](
85
+ key='default_debt_ceiling_bytes',
86
+ name='Default Pro-Bono Transit Debt Ceiling (Bytes)',
87
+ description='Unsettled transit byte threshold before soft audio-only degradation.',
88
+ type=int,
89
+ default=500 * 1024 * 1024,
90
+ must='be a positive integer',
91
+ live=True,
92
+ tags=('video', 'accounting'),
93
+ sources=(par.ARG, par.ENV, par.FILE),
94
+ ),
95
+ )
96
+
97
+ @classmethod
98
+ def from_dict(cls, raw: Any) -> VideoConfig:
99
+ if isinstance(raw, bool):
100
+ return cls(enabled=raw)
101
+ if not isinstance(raw, Mapping):
102
+ return cls()
103
+ return cls(
104
+ enabled=bool(raw.get('enabled', True)),
105
+ ring_capacity=int(raw.get('ring_capacity', cls.ring_capacity)),
106
+ default_debt_ceiling_bytes=int(
107
+ raw.get('default_debt_ceiling_bytes', cls.default_debt_ceiling_bytes)
108
+ ),
109
+ )
110
+
111
+
112
+ class VideoTransitCompartment(Compartment):
113
+ """MeshNode compartment orchestrating live video packaging, transit reflection,
114
+ and bilateral IOU / barter settlement.
115
+ """
116
+
117
+ name: str = 'video'
118
+ optional: bool = True
119
+ enabled_by: Optional[str] = 'video'
120
+ # The three late needs below are compartments, so they are declared here:
121
+ # all three are core and tick before this one.
122
+ requires: tuple[str, ...] = ('strain', 'capacity', 'paidfetch', 'blob_replication')
123
+ # The utilities a transit debt can be repaid in. Optional, so they are
124
+ # `uses` and resolve to None when the operator has not configured them.
125
+ uses: tuple[str, ...] = ('compute', 'dvm')
126
+ reads: tuple[str, ...] = ('video',)
127
+ live: tuple[str, ...] = ('video',)
128
+ config_cls = VideoConfig
129
+ piuris: tuple[str, ...] = (VIDEO_TRANSIT_PIURI,)
130
+ cli = (('stream', register_stream_cli),)
131
+
132
+ STORES = (
133
+ st.Store(
134
+ key='recent_events',
135
+ description='recent video packaging and transit events, newest last',
136
+ kind=st.RING,
137
+ shape=tuple,
138
+ bound=128,
139
+ ),
140
+ )
141
+ recent_events: st.Ring[tuple[Any, ...]]
142
+
143
+ CHANNELS = (
144
+ ch_mod.Channel(
145
+ 'video_grant_undelivered',
146
+ 'A signed transit grant was recorded but could not be sent to the peer',
147
+ level=ch_mod.TROUBLE,
148
+ say='grant {grant} for {peer} is in the ledger and did not reach the peer',
149
+ tags=('video', 'transit'),
150
+ ),
151
+ ch_mod.Channel(
152
+ 'video_ack_undelivered',
153
+ 'An inbound receipt was recorded but its acknowledgement could not be sent',
154
+ level=ch_mod.TROUBLE,
155
+ say='receipt-ack for grant {grant} did not reach {peer}; it will resend',
156
+ tags=('video', 'transit'),
157
+ ),
158
+ ch_mod.Channel(
159
+ 'video_store_close_failed',
160
+ 'A video store could not be closed at shutdown',
161
+ level=ch_mod.TROUBLE,
162
+ say='video store(s) would not close: {stores}',
163
+ tags=('video', 'storage'),
164
+ ),
165
+ ch_mod.Channel(
166
+ 'video_not_announced',
167
+ 'A stream started on a node with no HTTP face, so it was not announced',
168
+ level=ch_mod.NOTE,
169
+ say='stream {stream_id} started but not announced: this node has no http face',
170
+ tags=('video', 'stream'),
171
+ ),
172
+ ch_mod.Channel(
173
+ 'video_stream_started',
174
+ 'A live video stream genesis manifest was initialized',
175
+ level=ch_mod.INFO,
176
+ say='started live stream {stream_id} ({title})',
177
+ tags=('video', 'stream'),
178
+ ),
179
+ ch_mod.Channel(
180
+ 'video_segment_ingested',
181
+ 'A CMAF / LL-HLS video segment was packaged and logged',
182
+ level=ch_mod.INFO,
183
+ say='packaged segment #{seq} for stream {stream_id} ({size} bytes)',
184
+ tags=('video', 'segment'),
185
+ ),
186
+ ch_mod.Channel(
187
+ 'video_transit_handover',
188
+ 'Capacity shed triggered or released a Transit Reflector handover',
189
+ level=ch_mod.INFO,
190
+ say='stream {stream_id} transit handover {state}',
191
+ tags=('video', 'transit'),
192
+ ),
193
+ ch_mod.Channel(
194
+ 'video_transit_settled',
195
+ 'Bilateral transit debt was reduced via bartered network utility',
196
+ level=ch_mod.INFO,
197
+ say='settled {credited_bytes} transit bytes for peer {peer} via {utility_kind}',
198
+ tags=('video', 'accounting'),
199
+ ),
200
+ )
201
+
202
+ METRICS = (
203
+ mx_mod.Metric(
204
+ name='video_streams_started',
205
+ description='Live video streams initialized on this node.',
206
+ kind=mx_mod.COUNTER,
207
+ counts='video_stream_started',
208
+ tags=('video', 'stream'),
209
+ ),
210
+ mx_mod.Metric(
211
+ name='video_segments_ingested',
212
+ description='Video segments packaged into RhioBaoTree and P2PandaLogStore.',
213
+ kind=mx_mod.COUNTER,
214
+ counts='video_segment_ingested',
215
+ tags=('video', 'segment'),
216
+ ),
217
+ mx_mod.Metric(
218
+ name='video_transit_handovers',
219
+ description='Automated capacity shed handovers to Transit Reflectors.',
220
+ kind=mx_mod.COUNTER,
221
+ counts='video_transit_handover',
222
+ tags=('video', 'transit'),
223
+ ),
224
+ mx_mod.Metric(
225
+ name='video_transit_settlements',
226
+ description='Bartered utility settlements applied to transit debt.',
227
+ kind=mx_mod.COUNTER,
228
+ counts='video_transit_settled',
229
+ tags=('video', 'accounting'),
230
+ ),
231
+ )
232
+
233
+ NEEDS = (
234
+ Need('blobs', 'stores.blobs'),
235
+ Need('ks'),
236
+ Need('clock'),
237
+ Need('dir'),
238
+ Need('cfg', block=VideoConfig),
239
+ Need('host'),
240
+ Need('transport'),
241
+ Need('capacity', late=True),
242
+ Need('strain', late=True),
243
+ Need('paid_fetch', 'paidfetch', late=True),
244
+ # Barter counterparties, read on the tick. These were soft reaches
245
+ # through the node object, which the contract refuses: a coupling
246
+ # nothing declares is one nobody can see.
247
+ Need('blob_replication', late=True),
248
+ Need('compute', late=True),
249
+ Need('dvm', late=True),
250
+ Need('events'),
251
+ )
252
+
253
+ COMMANDS = (
254
+ Command(
255
+ key='start_stream',
256
+ name='Start Video Stream',
257
+ description='initialize a new live video stream and append its p2panda genesis manifest',
258
+ tags=('video', 'stream'),
259
+ parameters=(
260
+ Parameter(key='stream_id', name='stream_id', description='unique stream identifier', type=str, default='', required=True),
261
+ Parameter(key='title', name='title', description='human-readable stream title', type=str, default='Live Stream', required=False),
262
+ Parameter(key='price_msat_per_mb', name='price_msat_per_mb', description='commercial price per MB in msat', type=int, default=0, required=False),
263
+ Parameter(key='account_fee_msat', name='account_fee_msat', description='creator P2PK fee in msat', type=int, default=0, required=False),
264
+ ),
265
+ permission='video/start_stream',
266
+ run='start_stream_cmd',
267
+ reply='{stream_id, broadcaster, title, genesis_op_id, seq_num, announcements}',
268
+ ),
269
+ Command(
270
+ key='status',
271
+ name='Video & Transit Status',
272
+ description='inspect active video streams, reflector sessions, and bilateral transit debt',
273
+ tags=('video', 'status'),
274
+ parameters=(
275
+ Parameter(key='peer', name='peer', description='optional peer pubkey filter', type=str, default='', required=False),
276
+ ),
277
+ permission='video/status',
278
+ run='status_cmd',
279
+ reply='{streams, active_streams, reflector_sessions, debts, utility_credits}',
280
+ ),
281
+ Command(
282
+ key='settle',
283
+ name='Settle Transit Debt via Barter',
284
+ description='credit bartered network utility (blob_shoring, verified_compute, dvm, mediation) against transit debt',
285
+ tags=('video', 'settle'),
286
+ parameters=(
287
+ Parameter(key='peer', name='peer', description='broadcaster peer pubkey', type=str, default='', required=True),
288
+ Parameter(key='utility_kind', name='utility_kind', description='blob_shoring, verified_compute, dvm, or mediation', type=str, default='blob_shoring', required=True),
289
+ Parameter(key='units', name='units', description='number of utility units to credit', type=int, default=1, required=True),
290
+ Parameter(key='grant_id', name='grant_id', description='optional grant_id to credit against', type=str, default='', required=False),
291
+ ),
292
+ permission='video/settle',
293
+ run='settle_cmd',
294
+ reply='BarterCreditRecord with updated debt status',
295
+ ),
296
+ Command(
297
+ key='ingest_segment',
298
+ name='Ingest Video Segment',
299
+ description='package a base64-encoded CMAF/LL-HLS media segment into RhioBaoTree and P2PandaLogStore',
300
+ tags=('video', 'ingest'),
301
+ parameters=(
302
+ Parameter(key='stream_id', name='stream_id', description='target stream identifier', type=str, default='', required=True),
303
+ Parameter(key='data_b64', name='data_b64', description='base64-encoded segment bytes', type=str, default='', required=True),
304
+ Parameter(key='duration_ms', name='duration_ms', description='segment duration in milliseconds', type=int, default=2000, required=False),
305
+ Parameter(key='is_keyframe', name='is_keyframe', description='whether segment begins with an IDR keyframe', type=bool, default=True, required=False),
306
+ Parameter(key='is_audio_only', name='is_audio_only', description='whether segment is an audio-only track', type=bool, default=False, required=False),
307
+ ),
308
+ permission='video/ingest_segment',
309
+ run='ingest_segment_cmd',
310
+ reply='{stream_id, segment_index, sha256, tree_root, cid, size, op_id, seq_num}',
311
+ ),
312
+ Command(
313
+ key='solicit_transit',
314
+ name='Solicit Supernode Transit Reflection',
315
+ description='build and optionally send a transit-solicitation for a stream',
316
+ tags=('video', 'transit'),
317
+ parameters=(
318
+ Parameter(key='stream_id', name='stream_id', description='stream identifier to offload', type=str, default='', required=True),
319
+ Parameter(key='peer', name='peer', description='optional target supernode pubkey', type=str, default='', required=False),
320
+ Parameter(key='mode', name='mode', description='pro-bono-barter or commercial-paid', type=str, default='pro-bono-barter', required=False),
321
+ Parameter(key='expected_bitrate_kbps', name='expected_bitrate_kbps', description='expected stream bitrate in kbps', type=int, default=2500, required=False),
322
+ ),
323
+ permission='video/solicit_transit',
324
+ run='solicit_transit_cmd',
325
+ reply='{solicitation, sent}',
326
+ ),
327
+ )
328
+
329
+ def __init__(
330
+ self,
331
+ blobs: Optional[Any] = None,
332
+ ks: Optional[Any] = None,
333
+ clock: Optional[Callable[[], int]] = None,
334
+ dir: Optional[str] = None,
335
+ cfg: Optional[VideoConfig] = None,
336
+ host: Optional[Any] = None,
337
+ capacity: Optional[Any] = None,
338
+ strain: Optional[Any] = None,
339
+ paid_fetch: Optional[Any] = None,
340
+ transport: Optional[Any] = None,
341
+ events: Optional[Any] = None,
342
+ node: Optional[Any] = None,
343
+ ) -> None:
344
+ super().__init__(node, clock)
345
+ # Backing fields for the write-through properties below. Set before
346
+ # anything that could trigger a setter.
347
+ self._strain: Optional[Any] = None
348
+ self._paid_fetch: Optional[Any] = None
349
+ self._events: Optional[Any] = None
350
+ self.blobs = blobs
351
+ self.ks = ks
352
+ self.dir = dir
353
+ self.cfg: VideoConfig = cfg or VideoConfig()
354
+ self.host = host
355
+ self.capacity = capacity
356
+ self.strain = strain
357
+ self.paid_fetch = paid_fetch
358
+ self.transport = transport
359
+ self.events = events
360
+ # Late needs: the registry sets these after assembly. Declared here so
361
+ # a bare suite that builds this compartment alone still has them.
362
+ self.blob_replication: Optional[Any] = None
363
+ self.compute: Optional[Any] = None
364
+ self.dvm: Optional[Any] = None
365
+
366
+ if dir:
367
+ os.makedirs(dir, exist_ok=True)
368
+ ledger_db = os.path.join(dir, 'video_transit_v1.db')
369
+ p2p_db = os.path.join(dir, 'p2panda_v1.db')
370
+ rhio_db = os.path.join(dir, 'rhio_v1.db')
371
+ rhio_cas: Optional[str] = os.path.join(dir, 'rhio_cas')
372
+ else:
373
+ ledger_db = ':memory:'
374
+ p2p_db = ':memory:'
375
+ rhio_db = ':memory:'
376
+ rhio_cas = None
377
+
378
+ self.p2panda_store = P2PandaLogStore(p2p_db)
379
+ self.rhio_store = RhioBlobSync(rhio_db, cas_dir=rhio_cas)
380
+ # One clock for the whole subsystem: the compartment's, so a suite that
381
+ # moves time moves the ledger, packager, reflector and bridges with it.
382
+ self.ledger = TransitLedgerStore(ledger_db, clock=self.clock)
383
+ self.packager = VideoStreamPackager(
384
+ p2panda_store=self.p2panda_store,
385
+ rhio_store=self.rhio_store,
386
+ blob_store=self.blobs,
387
+ strain_ledger=self.strain,
388
+ paid_fetch=self.paid_fetch,
389
+ clock=self.clock,
390
+ )
391
+ self.reflector = TransitReflectorManager(
392
+ ledger=self.ledger,
393
+ packager=self.packager,
394
+ ring_capacity=self.cfg.ring_capacity,
395
+ strain_ledger=self.strain,
396
+ clock=self.clock,
397
+ )
398
+ node_sk = getattr(self.ks, 'node_sk', None)
399
+ node_pk = getattr(self.ks, 'node_pk', None) or ('b' * 64)
400
+ self.ingest_bridge = LiveMediaIngestBridge(
401
+ packager=self.packager,
402
+ reflector=self.reflector,
403
+ broadcaster_sk=node_sk,
404
+ broadcaster_pk=node_pk,
405
+ clock=self.clock,
406
+ )
407
+ self.barter_bridge = BarterSettlementBridge(
408
+ ledger=self.ledger,
409
+ creditor_pk=node_pk,
410
+ blob_store=self.blobs,
411
+ rhio_store=self.rhio_store,
412
+ on_settled=self._on_auto_barter_settled,
413
+ clock=self.clock,
414
+ )
415
+ if events is not None:
416
+ self.barter_bridge.attach_event_bus(events)
417
+ self._received_grants: dict[str, TransitGrantBody] = {} # not a store: active per-stream transit grants
418
+
419
+ def _on_auto_barter_settled(self, res: BarterHookResult) -> None:
420
+ now_val = self.clock()
421
+ self.recent_events.append((
422
+ 'auto-barter-settled',
423
+ res['debtor'],
424
+ res['utility_kind'],
425
+ res['equivalent_transit_bytes'],
426
+ now_val,
427
+ ))
428
+ self.say(
429
+ 'video_transit_settled',
430
+ credited_bytes=res['equivalent_transit_bytes'],
431
+ peer=res['debtor'][:16],
432
+ utility_kind=res['utility_kind'],
433
+ )
434
+
435
+ def mount_ingress(self, host: Any) -> None:
436
+ """Mount HTTP live playlist, segment, HTTP push ingest, and web player routes onto ProtocolHost."""
437
+ self.host = host
438
+ if host is not None:
439
+ self.packager.mount_ingress(host)
440
+ self.ingest_bridge.mount_http_ingest(host)
441
+ mount_player_routes(host, self.packager, reflector=self.reflector, ledger=self.ledger)
442
+
443
+ # The registry resolves a `late=True` need with setattr, so these setters
444
+ # are where a late value reaches the objects that actually hold it. This is
445
+ # what `bind_runtime` did when NodeApp built this compartment by hand, and
446
+ # what a `wire()` override would do - which check-needs refuses, rightly:
447
+ # one declared way in, not three.
448
+ @property
449
+ def strain(self) -> Optional[Any]:
450
+ return self._strain
451
+
452
+ @strain.setter
453
+ def strain(self, value: Optional[Any]) -> None:
454
+ self._strain = value
455
+ if value is not None:
456
+ if getattr(self, 'packager', None) is not None:
457
+ self.packager.strain_ledger = value
458
+ if getattr(self, 'reflector', None) is not None:
459
+ self.reflector.strain_ledger = value
460
+
461
+ @property
462
+ def paid_fetch(self) -> Optional[Any]:
463
+ return self._paid_fetch
464
+
465
+ @paid_fetch.setter
466
+ def paid_fetch(self, value: Optional[Any]) -> None:
467
+ self._paid_fetch = value
468
+ if value is not None and getattr(self, 'packager', None) is not None:
469
+ self.packager.paid_fetch = value
470
+
471
+ @property
472
+ def events(self) -> Optional[Any]:
473
+ return self._events
474
+
475
+ @events.setter
476
+ def events(self, value: Optional[Any]) -> None:
477
+ self._events = value
478
+ if value is not None and getattr(self, 'barter_bridge', None) is not None:
479
+ self.barter_bridge.attach_event_bus(value)
480
+
481
+ def start(self) -> None:
482
+ if self.host is not None:
483
+ self.mount_ingress(self.host)
484
+
485
+ def stop(self) -> None:
486
+ super().stop()
487
+
488
+ def close(self) -> None:
489
+ """Close the three stores, counting and saying any that will not.
490
+
491
+ This was `except Exception: pass` over all three, which is audit L5
492
+ verbatim - the finding that `NodeApp.close()` told nobody when a store
493
+ could not close. A store that will not close is a locked database or a
494
+ disk full at commit, and at restart it is exactly the one an operator
495
+ needs told about. The exception is still caught, because one store
496
+ failing must not leave the other two open.
497
+ """
498
+ failed: list[str] = []
499
+ for name in ('ledger', 'p2panda_store', 'rhio_store'):
500
+ st_obj = getattr(self, name, None)
501
+ if st_obj is None:
502
+ continue
503
+ try:
504
+ st_obj.close()
505
+ except Exception as e:
506
+ failed.append('%s: %s' % (name, e))
507
+ self.refusals.refuse('store', 'close failed', name,
508
+ {'error': '%s: %s' % (type(e).__name__, e)})
509
+ if failed:
510
+ self.say('video_store_close_failed', stores=', '.join(failed))
511
+
512
+ def state(self) -> Mapping[str, Any]:
513
+ return self.status_cmd()
514
+
515
+ def tick(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
516
+ """Evaluate capacity shed handovers and reconcile cross-compartment barter proofs."""
517
+ now_val = self.clock() if now is None else int(now)
518
+ did: list[tuple[Any, ...]] = []
519
+ if any((self.blob_replication, self.compute, self.dvm)):
520
+ settled = self.barter_bridge.reconcile_compartments(
521
+ blob_replication=self.blob_replication,
522
+ compute=self.compute,
523
+ dvm=self.dvm,
524
+ now=now_val,
525
+ )
526
+ for item in settled:
527
+ did.append(('video-barter-settled', item['debtor'], item['utility_kind'], item['equivalent_transit_bytes']))
528
+ if self.capacity is None or not self._received_grants:
529
+ return did
530
+ sk_hex = getattr(self.ks, 'node_sk', None)
531
+ if not sk_hex:
532
+ return did
533
+ for stream_id, grant in list(self._received_grants.items()):
534
+ prev_active = self.reflector.is_handover_active(stream_id)
535
+ window_epoch = now_val // 600
536
+ new_state = self.reflector.evaluate_capacity_handover(
537
+ self.capacity,
538
+ stream_id=stream_id,
539
+ standby_grant=grant,
540
+ window=window_epoch,
541
+ sk_hex=sk_hex,
542
+ now=now_val,
543
+ )
544
+ now_active = self.reflector.is_handover_active(stream_id)
545
+ if now_active != prev_active:
546
+ if new_state == 'released' and self.transport is not None:
547
+ rel_pair = getattr(self.reflector, '_last_release', {}).get(stream_id)
548
+ operator_peer = str(grant.get('operator', ''))
549
+ sender_pk = getattr(self.ks, 'node_pk', '') or str(grant.get('broadcaster', ''))
550
+ if rel_pair is not None and operator_peer:
551
+ rec_msg, close_msg = rel_pair
552
+ try:
553
+ env_r = make_transit_envelope(
554
+ TYPE_TRANSIT_RECEIPT, rec_msg, sender=sender_pk, recipient=operator_peer, created_time=now_val
555
+ )
556
+ self.transport.send(env_r, operator_peer, stored=False)
557
+ env_c = make_transit_envelope(
558
+ TYPE_TRANSIT_CLOSE, close_msg, sender=sender_pk, recipient=operator_peer, created_time=now_val
559
+ )
560
+ self.transport.send(env_c, operator_peer, stored=False)
561
+ except Exception as e:
562
+ self.refusals.refuse(
563
+ 'close',
564
+ 'not delivered',
565
+ operator_peer,
566
+ {'grant': grant['grant_id'], 'error': '%s: %s' % (type(e).__name__, e)},
567
+ )
568
+ self.recent_events.append(('handover', stream_id, new_state, now_val))
569
+ self.say('video_transit_handover', stream_id=stream_id, state=new_state)
570
+ did.append(('video-handover', stream_id, new_state))
571
+ return did
572
+
573
+ # ---- Wire Protocol Handler (video-transit/1.0) --------------------------
574
+
575
+ def handle(self, inbound: Any) -> Optional[tuple[Any, ...]]:
576
+ """Handle inbound `https://fleetmesh.org/video-transit/1.0/*` messages."""
577
+ msg = getattr(inbound, 'message', inbound)
578
+ sender = getattr(inbound, 'sender', '')
579
+ if not isinstance(msg, Mapping):
580
+ return None
581
+ msg_type = str(msg.get('type', ''))
582
+ body = msg.get('body')
583
+ if not isinstance(body, Mapping):
584
+ return None
585
+ now_val = self.clock()
586
+
587
+ if msg_type == TYPE_TRANSIT_SOLICITATION:
588
+ err = check_solicitation(body)
589
+ if err:
590
+ self.refusals.refuse('solicitation', err, sender, {})
591
+ return ('refused', 'transit-solicitation', err)
592
+ sol_body: Any = body
593
+ op_pk = getattr(self.ks, 'node_pk', '') or sol_body['broadcaster']
594
+ op_sk = getattr(self.ks, 'node_sk', None)
595
+ if not op_sk:
596
+ self.refusals.refuse('solicitation', 'no signing key', sender, {})
597
+ return ('refused', 'transit-solicitation', 'no signing key')
598
+ face_url = ''
599
+ if self.host is not None and hasattr(self.host, 'http_url'):
600
+ face_url = f"{self.host.http_url}/video/live/{sol_body['stream_id']}"
601
+ if not face_url:
602
+ # A grant names where to fetch from. Without an HTTP face there
603
+ # is nowhere, and inventing `node.fleetmesh.local` published a
604
+ # host no viewer can reach - and one that mDNS may resolve to
605
+ # something else entirely on a LAN. Refuse, as the branch above
606
+ # refuses a missing signing key.
607
+ self.refusals.refuse('solicitation', 'no http face to reflect from', sender, {})
608
+ return ('refused', 'transit-solicitation', 'no http face to reflect from')
609
+ endpoints = [face_url]
610
+ grant = self.reflector.accept_solicitation(
611
+ sol_body,
612
+ operator_pk=op_pk,
613
+ operator_sk=op_sk,
614
+ reflector_endpoints=endpoints,
615
+ max_unsettled_transit_bytes=self.cfg.default_debt_ceiling_bytes,
616
+ now=now_val,
617
+ )
618
+ delivered = True
619
+ if self.transport is not None and sender:
620
+ try:
621
+ env = make_transit_envelope(TYPE_TRANSIT_GRANT, grant, sender=op_pk, recipient=sender, created_time=now_val)
622
+ self.transport.send(env, sender, stored=False)
623
+ except Exception as e:
624
+ # The grant is signed and already in the ledger
625
+ # (Record-Then-Answer), so it is not withdrawn - but the
626
+ # peer does not have it. Reporting 'accepted' and saying
627
+ # nothing left the ledger claiming a grant the other side
628
+ # had never seen, with nothing for an operator to act on.
629
+ delivered = False
630
+ self.refusals.refuse('grant', 'not delivered', sender,
631
+ {'grant': grant['grant_id'],
632
+ 'error': '%s: %s' % (type(e).__name__, e)})
633
+ self.say('video_grant_undelivered', grant=grant['grant_id'][:16],
634
+ peer=(sender or '')[:16])
635
+ self.recent_events.append(
636
+ ('solicitation-accepted' if delivered else 'solicitation-accepted-undelivered',
637
+ sol_body['stream_id'], grant['grant_id'], now_val))
638
+ return ('transit-solicitation', sender, sol_body['stream_id'])
639
+
640
+ if msg_type == TYPE_TRANSIT_GRANT:
641
+ err = check_grant(body)
642
+ if err:
643
+ self.refusals.refuse('grant', err, sender, {})
644
+ return ('refused', 'transit-grant', err)
645
+ grant_body: Any = body
646
+ if sender and str(grant_body.get('operator', '')).lower() != sender.lower():
647
+ self.refusals.refuse('grant', 'operator does not match sender', sender, {})
648
+ return ('refused', 'transit-grant', 'operator does not match sender')
649
+ my_pk = getattr(self.ks, 'node_pk', None)
650
+ if my_pk and str(grant_body.get('broadcaster', '')).lower() != str(my_pk).lower():
651
+ self.refusals.refuse('grant', 'grant broadcaster is not this node', sender, {})
652
+ return ('refused', 'transit-grant', 'grant broadcaster is not this node')
653
+ try:
654
+ self.ledger.save_grant(grant_body, status='live')
655
+ except ValueError as e:
656
+ self.refusals.refuse('grant', str(e), sender, {})
657
+ return ('refused', 'transit-grant', str(e))
658
+ self._received_grants[grant_body['stream_id']] = grant_body
659
+ self.recent_events.append(('grant-received', grant_body['stream_id'], grant_body['grant_id'], now_val))
660
+ return ('transit-grant', sender, grant_body['grant_id'])
661
+
662
+ if msg_type == TYPE_TRANSIT_RECEIPT:
663
+ err = check_transit_receipt(body)
664
+ if err:
665
+ self.refusals.refuse('receipt', err, sender, {})
666
+ return ('refused', 'transit-receipt', err)
667
+ rec_body: Any = body
668
+ signer_pk = sender or getattr(self.ks, 'node_pk', '')
669
+ ack = self.ledger.on_inbound_receipt(signer_pk, rec_body, now=now_val)
670
+ if self.transport is not None and sender:
671
+ try:
672
+ op_pk = getattr(self.ks, 'node_pk', '')
673
+ env = make_transit_envelope(TYPE_TRANSIT_RECEIPT_ACK, ack, sender=op_pk, recipient=sender, created_time=now_val)
674
+ self.transport.send(env, sender, stored=False)
675
+ except Exception as e:
676
+ # The receipt was recorded; the acknowledgement was not
677
+ # sent. The peer cannot tell its receipt landed, so it
678
+ # resends or the chain stalls - and this said nothing.
679
+ self.refusals.refuse('receipt-ack', 'not delivered', sender,
680
+ {'grant': rec_body['grant'],
681
+ 'error': '%s: %s' % (type(e).__name__, e)})
682
+ self.say('video_ack_undelivered', grant=str(rec_body['grant'])[:16],
683
+ peer=(sender or '')[:16])
684
+ self.recent_events.append(('receipt', rec_body['grant'], ack['status'], now_val))
685
+ return ('transit-receipt', sender, ack['status'])
686
+
687
+ if msg_type == TYPE_TRANSIT_CLOSE:
688
+ err = check_transit_close(body)
689
+ if err:
690
+ self.refusals.refuse('close', err, sender, {})
691
+ return ('refused', 'transit-close', err)
692
+ close_body: Any = body
693
+ self.ledger.on_inbound_close(close_body, now=now_val)
694
+ self.recent_events.append(('close', close_body['stream_id'], close_body['grant'], now_val))
695
+ return ('transit-close', sender, close_body['grant'])
696
+
697
+ if msg_type == TYPE_TRANSIT_RECEIPT_ACK:
698
+ err = check_transit_receipt_ack(body)
699
+ if err:
700
+ self.refusals.refuse('receipt-ack', err, sender, {})
701
+ return ('refused', 'transit-receipt-ack', err)
702
+ ack_body: Any = body
703
+ self.recent_events.append(('receipt-ack', ack_body['grant'], ack_body['status'], now_val))
704
+ return ('transit-receipt-ack', sender, ack_body['status'])
705
+
706
+ return None
707
+
708
+ # ---- Control-Socket Command Handlers ------------------------------------
709
+
710
+ def start_stream_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
711
+ stream_id = str(args.get('stream_id') or '').strip()
712
+ if not stream_id:
713
+ raise ValueError('stream_id is required')
714
+ title = str(args.get('title') or 'Live Stream')
715
+ price_msat = int(args.get('price_msat_per_mb') or 0)
716
+ account_fee = int(args.get('account_fee_msat') or 0)
717
+ broadcaster = getattr(self.ks, 'node_pk', None) or ('a' * 64)
718
+ now_val = self.clock()
719
+
720
+ genesis, op = self.packager.create_stream(
721
+ stream_id=stream_id,
722
+ broadcaster=broadcaster,
723
+ title=title,
724
+ price_msat_per_mb=price_msat,
725
+ account_fee_msat=account_fee,
726
+ now=now_val,
727
+ )
728
+ # The announcement carries the manifest URL to six protocols. With no
729
+ # HTTP face there is no URL to carry, and the fabricated fallback meant
730
+ # a faceless node advertised `node.fleetmesh.local` to every network it
731
+ # spoke. The stream still starts; it is simply not announced.
732
+ base_url = (
733
+ self.host.http_url
734
+ if (self.host is not None and hasattr(self.host, 'http_url'))
735
+ else ''
736
+ )
737
+ if base_url:
738
+ announcements = self.packager.announce_stream(stream_id, base_url=base_url)
739
+ else:
740
+ announcements = {}
741
+ self.say('video_not_announced', stream_id=stream_id)
742
+ self.recent_events.append(('stream-started', stream_id, op.id, now_val))
743
+ self.say('video_stream_started', stream_id=stream_id, title=title)
744
+ return {
745
+ 'status': 'started',
746
+ 'stream_id': genesis.stream_id,
747
+ 'broadcaster': genesis.broadcaster,
748
+ 'title': genesis.title,
749
+ 'genesis_op_id': op.id,
750
+ 'seq_num': op.seq_num,
751
+ 'announcements': sorted(announcements.keys()),
752
+ }
753
+
754
+ TROUBLE_WINDOW: int = 900
755
+
756
+ def trouble(self) -> list[str]:
757
+ """What an operator has to act on, not merely what state() records.
758
+
759
+ The subsystem had no trouble() at all, so every condition below was
760
+ visible only to someone who read `video status` and knew to look -
761
+ which is audit I13 exactly: the deaf inbox was in state() and not in
762
+ trouble(), so health and `meshctl watch` stayed green while live wraps
763
+ were being lost.
764
+ """
765
+ out: list[str] = []
766
+ if self.packager.unpublished:
767
+ out.append('%d video segment(s) are in the manifest and were never published '
768
+ 'to the blob server, so no viewer can fetch them: %s'
769
+ % (self.packager.unpublished, self.packager.last_publish_error))
770
+ recent = self.refusals.within(self.TROUBLE_WINDOW, self.clock())
771
+ undelivered = [r for r in recent if r.detail.get('grant') and 'not delivered' in r.why]
772
+ if undelivered:
773
+ out.append('%d transit message(s) recorded in the ledger never reached the peer '
774
+ 'in the last %ds: %s'
775
+ % (len(undelivered), self.TROUBLE_WINDOW,
776
+ '; '.join(sorted({(r.subject or '?')[:16] for r in undelivered}))))
777
+ if self.barter_bridge.skipped:
778
+ out.append('barter reconciliation could not credit %s: %s'
779
+ % (', '.join('%d %s' % (n, k) for k, n in
780
+ sorted(self.barter_bridge.skipped.items())),
781
+ '; '.join('%s: %s' % (k, v) for k, v in
782
+ sorted(self.barter_bridge.last_skip.items()))))
783
+ return out
784
+
785
+ def status_cmd(self, args: Optional[Mapping[str, Any]] = None) -> Mapping[str, Any]:
786
+ peer_filter = str((args or {}).get('peer') or '').strip() or None
787
+ with self.packager._lock:
788
+ streams = [
789
+ {
790
+ 'stream_id': sid,
791
+ 'broadcaster': st_rec['genesis'].broadcaster,
792
+ 'title': st_rec['genesis'].title,
793
+ 'segments_count': len(st_rec['segments']),
794
+ 'latest_seq': st_rec['latest_seq'],
795
+ 'transit_endpoints': list(st_rec['transit_endpoints']),
796
+ 'handover_active': self.reflector.is_handover_active(sid),
797
+ }
798
+ for sid, st_rec in self.packager._streams.items()
799
+ ]
800
+ summary = self.ledger.summary()
801
+ debts = (
802
+ [self.ledger.debt_summary(peer_filter)]
803
+ if peer_filter
804
+ else summary['debts']
805
+ )
806
+ # What the subsystem could not do, where an operator reads it. Each of
807
+ # these was `except Exception: pass` and so was invisible: segments in
808
+ # the manifest no viewer can fetch, credits a peer earned and never
809
+ # got, and frames a transport would not take. Present only when
810
+ # non-zero, so a healthy node's status stays short.
811
+ return {
812
+ 'streams': streams,
813
+ 'active_streams': len(streams),
814
+ 'reflector_sessions': self.reflector.active_sessions(),
815
+ 'debts': debts,
816
+ 'utility_credits': summary['utility_credits'],
817
+ **({'unpublished_segments': self.packager.unpublished,
818
+ 'last_publish_error': self.packager.last_publish_error}
819
+ if self.packager.unpublished else {}),
820
+ **({'frames_dropped': dict(self.reflector.frames_dropped)}
821
+ if self.reflector.frames_dropped else {}),
822
+ **({'barter_skipped': dict(self.barter_bridge.skipped),
823
+ 'barter_last_skip': dict(self.barter_bridge.last_skip)}
824
+ if self.barter_bridge.skipped else {}),
825
+ }
826
+
827
+ def settle_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
828
+ peer = str(args.get('peer') or '').strip()
829
+ if not peer:
830
+ raise ValueError('peer is required')
831
+ utility_kind: Any = str(args.get('utility_kind') or 'blob_shoring').strip()
832
+ units = int(args.get('units') or 1)
833
+ grant_id = str(args.get('grant_id') or '').strip() or 'manual-proof'
834
+ creditor = getattr(self.ks, 'node_pk', None) or ('b' * 64)
835
+ now_val = self.clock()
836
+ rec = self.ledger.credit_barter(
837
+ debtor=peer,
838
+ creditor=creditor,
839
+ utility_kind=utility_kind,
840
+ units=units,
841
+ proof_ref=grant_id,
842
+ now=now_val,
843
+ )
844
+ debt_after = self.ledger.debt_summary(peer)
845
+ credited_bytes = int(rec['equivalent_transit_bytes'])
846
+ self.recent_events.append(('settled', peer, utility_kind, credited_bytes, now_val))
847
+ self.say(
848
+ 'video_transit_settled',
849
+ credited_bytes=credited_bytes,
850
+ peer=peer[:16],
851
+ utility_kind=utility_kind,
852
+ )
853
+ return {
854
+ 'credit': rec,
855
+ 'credited_bytes': credited_bytes,
856
+ 'peer': peer,
857
+ 'utility_kind': utility_kind,
858
+ 'units': units,
859
+ 'debt_after': debt_after,
860
+ }
861
+
862
+ def ingest_segment_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
863
+ stream_id = str(args.get('stream_id') or '').strip()
864
+ data_b64 = str(args.get('data_b64') or '').strip()
865
+ if not stream_id or not data_b64:
866
+ raise ValueError('stream_id and data_b64 are required')
867
+ segment_bytes = base64.b64decode(data_b64)
868
+ duration_ms = int(args.get('duration_ms') or 2000)
869
+ is_keyframe = bool(args.get('is_keyframe', True))
870
+ is_audio_only = bool(args.get('is_audio_only', False))
871
+ now_val = self.clock()
872
+
873
+ entry, op, addr = self.packager.ingest_segment(
874
+ stream_id=stream_id,
875
+ segment_data=segment_bytes,
876
+ duration_ms=duration_ms,
877
+ is_keyframe=is_keyframe,
878
+ is_audio_only=is_audio_only,
879
+ now=now_val,
880
+ )
881
+ self.recent_events.append(('segment-ingested', stream_id, entry.segment_index, addr['size'], now_val))
882
+ self.say('video_segment_ingested', seq=entry.segment_index, stream_id=stream_id, size=addr['size'])
883
+ return {
884
+ 'stream_id': stream_id,
885
+ 'segment_index': entry.segment_index,
886
+ 'sha256': addr['sha256'],
887
+ 'tree_root': addr['tree_root'],
888
+ 'cid': addr['cid'],
889
+ 'size': addr['size'],
890
+ 'op_id': op.id,
891
+ 'seq_num': op.seq_num,
892
+ }
893
+
894
+ def solicit_transit_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
895
+ stream_id = str(args.get('stream_id') or '').strip()
896
+ if not stream_id:
897
+ raise ValueError('stream_id is required')
898
+ peer = str(args.get('peer') or '').strip()
899
+ raw_mode = str(args.get('mode') or 'pro-bono').strip()
900
+ mode: Any = 'commercial' if 'commercial' in raw_mode else 'pro-bono'
901
+ bitrate = int(args.get('expected_bitrate_kbps') or 2500)
902
+ broadcaster = getattr(self.ks, 'node_pk', None) or ('a' * 64)
903
+ now_val = self.clock()
904
+
905
+ sol = self.reflector.build_solicitation(
906
+ stream_id=stream_id,
907
+ broadcaster=broadcaster,
908
+ mode=mode,
909
+ bitrate_kbps=bitrate,
910
+ now=now_val,
911
+ )
912
+ sent = False
913
+ if peer and self.transport is not None:
914
+ try:
915
+ env = make_transit_envelope(
916
+ TYPE_TRANSIT_SOLICITATION,
917
+ sol,
918
+ sender=broadcaster,
919
+ recipient=peer,
920
+ created_time=now_val,
921
+ )
922
+ self.transport.send(env, peer, stored=False)
923
+ sent = True
924
+ except Exception:
925
+ sent = False
926
+ return {'solicitation': sol, 'sent': sent}
927
+
928
+
929
+ __all__ = [
930
+ 'VideoConfig',
931
+ 'VideoTransitCompartment',
932
+ ]