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,282 @@
1
+ """Resource limits and the forked job sandbox (rlimits, wall-clock kill).
2
+
3
+ Separate because the sandbox state (_IN_SANDBOX_CHILD, the availability probe) is module-global and mutated in the forked child, so it must live in exactly one module.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ import os
9
+ import signal as signal_mod
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from typing import Any, Callable, Iterator, Optional
14
+
15
+ import types
16
+
17
+ try:
18
+ import resource as _resource_mod
19
+ except ImportError: # pragma: no cover - resource is POSIX-only
20
+ _resource_mod = None # type: ignore[assignment] # stub types the module as always present; it is absent on Windows
21
+ resource: types.ModuleType | None = _resource_mod
22
+
23
+ from .common import ComputeError
24
+
25
+ class ComputeLimits:
26
+ """Resource bounds for compute execution."""
27
+
28
+ def __init__(
29
+ self,
30
+ max_fuel: int = 2500000000,
31
+ max_memory_bytes: int = 1073741824,
32
+ timeout_seconds: int = 60,
33
+ max_tokens: int = 4096,
34
+ max_steps: int = 25,
35
+ ) -> None:
36
+ self.max_fuel = max_fuel
37
+ self.max_memory_bytes = max_memory_bytes
38
+ self.timeout_seconds = timeout_seconds
39
+ self.max_tokens = max_tokens
40
+ self.max_steps = max_steps
41
+
42
+
43
+ # Operator ceilings: a job request can ask for less, never more. Override with
44
+ # FLEETMESH_COMPUTE_MAX_TIMEOUT_S / FLEETMESH_COMPUTE_MAX_MEMORY_BYTES.
45
+ NODE_MAX_TIMEOUT_S: int = int(os.environ.get('FLEETMESH_COMPUTE_MAX_TIMEOUT_S', '300'))
46
+ NODE_MAX_MEMORY_BYTES: int = int(os.environ.get('FLEETMESH_COMPUTE_MAX_MEMORY_BYTES', str(1 << 30)))
47
+ _SANDBOX_KILL_GRACE_S: float = 2.0
48
+
49
+ # How much of a dead child's stderr travels in the error. A panic prints a few
50
+ # lines; a loop printing forever must not become the error message.
51
+ _CHILD_STDERR_CAP: int = 2048
52
+
53
+ # Signals that really do mean a limit was hit. Everything else - SIGSEGV,
54
+ # SIGABRT, SIGBUS, SIGILL - is a crash in the job or the runtime, and saying
55
+ # "CPU or memory limit exceeded" for those names a cause that has not been
56
+ # established (audit 2026-10-07, I7). SIGKILL is ambiguous on purpose: it is
57
+ # the OOM killer, or this node's own kill on the way out, and the message says
58
+ # both rather than picking one.
59
+ _LIMIT_SIGNALS: dict[int, str] = {
60
+ getattr(signal_mod, 'SIGXCPU', -1): 'the CPU limit was exceeded',
61
+ getattr(signal_mod, 'SIGKILL', -1):
62
+ 'killed - the kernel out-of-memory killer, or this node killing it on the way out',
63
+ }
64
+
65
+
66
+ def _signal_name(sig: Optional[int]) -> str:
67
+ """'SIGABRT (6)' where the number is a signal, else just the number."""
68
+ if sig is None:
69
+ return 'unknown'
70
+ try:
71
+ return '%s (%d)' % (signal_mod.Signals(sig).name, sig)
72
+ except (ValueError, AttributeError):
73
+ return str(sig)
74
+
75
+
76
+ def _read_child_stderr(fh: Any) -> str:
77
+ """The last _CHILD_STDERR_CAP bytes the child wrote, and close the file.
78
+
79
+ The tail rather than the head: a panic's reason is the last thing printed,
80
+ and a job that logged for a while before dying would otherwise push it out.
81
+ """
82
+ try:
83
+ fh.seek(0, os.SEEK_END)
84
+ size = fh.tell()
85
+ fh.seek(max(0, size - _CHILD_STDERR_CAP))
86
+ raw = fh.read()
87
+ except Exception:
88
+ return ''
89
+ finally:
90
+ with contextlib.suppress(Exception):
91
+ fh.close()
92
+ text = raw.decode('utf-8', 'replace') if isinstance(raw, bytes) else str(raw)
93
+ if len(text) >= _CHILD_STDERR_CAP:
94
+ text = '…' + text[-_CHILD_STDERR_CAP:]
95
+ return text
96
+
97
+
98
+ def _died_message(sig: Optional[int], stderr: str) -> str:
99
+ """What is actually known about a child that died without answering.
100
+
101
+ Before this, every signal got "CPU or memory limit exceeded" stapled to
102
+ it. Observed in practice: signal 6 is SIGABRT - a wasmtime panic, with the
103
+ reason printed one line earlier ("cannot use Wasmtime in a forked process
104
+ when mach ports are configured") - and a SIGSEGV in an executor and a
105
+ kernel OOM kill read identically. The signal number was always there; the
106
+ diagnosis beside it was wrong, and it is the half an operator reads.
107
+ """
108
+ out = 'job sandbox died (signal/exit %s)' % _signal_name(sig)
109
+ if sig is not None and sig in _LIMIT_SIGNALS:
110
+ out += ': %s' % _LIMIT_SIGNALS[sig]
111
+ if stderr:
112
+ out += '; it said: %s' % stderr.strip()
113
+ return out
114
+ # Address space the wasm runtime needs around a job (sigaltstack, JIT pages,
115
+ # guard regions), granted on top of the job's own ceiling; see sandbox_limits.
116
+ _RUNTIME_SLACK_BYTES: int = 64 << 20
117
+
118
+ # True only inside a forked job child; rlimits are never touched in the node process.
119
+ _IN_SANDBOX_CHILD: bool = False
120
+
121
+
122
+ def clamp_limits(limits: ComputeLimits) -> ComputeLimits:
123
+ """Clamp requester-supplied limits to the node operator's ceilings."""
124
+ def _c(v: int, cap: int) -> int:
125
+ v = int(v) if v else cap
126
+ return max(1, min(v, cap))
127
+ return ComputeLimits(
128
+ max_fuel=limits.max_fuel,
129
+ max_memory_bytes=_c(limits.max_memory_bytes, NODE_MAX_MEMORY_BYTES),
130
+ timeout_seconds=_c(limits.timeout_seconds, NODE_MAX_TIMEOUT_S),
131
+ max_tokens=limits.max_tokens,
132
+ max_steps=limits.max_steps,
133
+ )
134
+
135
+
136
+ def _apply_rlimits(limits: ComputeLimits) -> None:
137
+ """Set process-wide rlimits. Only ever called in a disposable job child."""
138
+ if resource is None:
139
+ return
140
+ if limits.timeout_seconds and hasattr(resource, 'RLIMIT_CPU'):
141
+ try:
142
+ used = int(time.process_time()) # child inherits parent's CPU accounting
143
+ soft, hard = resource.getrlimit(resource.RLIMIT_CPU)
144
+ want = used + int(limits.timeout_seconds) + 1
145
+ if hard != resource.RLIM_INFINITY:
146
+ want = min(want, hard)
147
+ resource.setrlimit(resource.RLIMIT_CPU, (want, hard))
148
+ except (ValueError, OSError):
149
+ pass
150
+ if limits.max_memory_bytes and hasattr(resource, 'RLIMIT_AS'):
151
+ try:
152
+ soft, hard = resource.getrlimit(resource.RLIMIT_AS)
153
+ # RLIMIT_AS counts the whole address space, and a child forked
154
+ # from a running node inherits the node's (thread stacks, sqlite
155
+ # maps, loaded libraries - often most of a gigabyte). An absolute
156
+ # cap left a job almost nothing; grant the job its bytes on top
157
+ # of what it was born with.
158
+ # The runtime itself also needs room the job did not ask for:
159
+ # wasmtime allocates a sigaltstack per thread, JIT code pages
160
+ # and guard regions before the module runs a single
161
+ # instruction. Without this slack a small ceiling aborted the
162
+ # child with "failed to allocate memory for sigaltstack" and
163
+ # the job died at the rlimit instead of trapping cleanly at the
164
+ # store limit - and whether it did depended on how much address
165
+ # space the parent happened to hold at fork, which made the
166
+ # outcome flake with unrelated code. The wasm store limit (set
167
+ # from max_memory_bytes by the executor) is what bounds the
168
+ # module; this rlimit only backstops the runtime around it.
169
+ want = _current_address_space() + int(limits.max_memory_bytes) + _RUNTIME_SLACK_BYTES
170
+ if hard != resource.RLIM_INFINITY:
171
+ want = min(want, hard)
172
+ resource.setrlimit(resource.RLIMIT_AS, (want, hard))
173
+ except (ValueError, OSError):
174
+ pass
175
+
176
+
177
+ def _current_address_space() -> int:
178
+ try:
179
+ with open('/proc/self/statm') as f:
180
+ return int(f.read().split()[0]) * os.sysconf('SC_PAGE_SIZE')
181
+ except (OSError, ValueError, IndexError):
182
+ return 0
183
+
184
+
185
+ @contextlib.contextmanager
186
+ def sandbox_limits(limits: Optional[ComputeLimits] = None) -> Iterator[None]:
187
+ """Apply rlimits for the duration of the block -- inside a job child only.
188
+
189
+ rlimits are process-wide: setting them in the node process let one job's
190
+ RLIMIT_CPU (measured against the node's total CPU time) SIGXCPU-kill the
191
+ whole node, and RLIMIT_AS cap every thread. Outside a sandbox child this
192
+ is therefore a no-op; use run_sandboxed() for real isolation.
193
+ """
194
+ if resource is None or limits is None or not _IN_SANDBOX_CHILD:
195
+ yield
196
+ return
197
+ _apply_rlimits(limits)
198
+ yield
199
+
200
+
201
+ def _sandbox_available() -> bool:
202
+ import multiprocessing
203
+ return resource is not None and 'fork' in multiprocessing.get_all_start_methods()
204
+
205
+
206
+ def run_sandboxed(fn: Callable[[], Any], limits: ComputeLimits, *, allow_unsandboxed: bool = False) -> Any:
207
+ """Run fn() in a forked child with CPU/memory rlimits and a wall-clock kill.
208
+
209
+ Limit breaches kill only the child and surface as ComputeError. The return
210
+ value must be picklable. Where fork/rlimits are unavailable the job is
211
+ refused unless ``allow_unsandboxed`` is set, in which case it runs
212
+ in-process with no resource limits: an untrusted module must never get
213
+ that silently, because the operator would believe it was contained.
214
+ """
215
+ limits = clamp_limits(limits)
216
+ if not _sandbox_available():
217
+ if not allow_unsandboxed:
218
+ raise ComputeError('sandbox unavailable on this host (no fork/rlimits); '
219
+ 'refusing to run the job in-process without allow_unsandboxed=True')
220
+ return fn()
221
+ import multiprocessing
222
+ ctx = multiprocessing.get_context('fork')
223
+ parent_conn, child_conn = ctx.Pipe(duplex=False)
224
+
225
+ # The child's stderr goes to a file so that a native crash - which sends
226
+ # nothing down the pipe, because it never reaches Python again - still
227
+ # leaves its reason somewhere the parent can read. Nothing is swallowed:
228
+ # on success the parent copies whatever was written back to the real
229
+ # stderr, so a job's own warnings still reach the journal.
230
+ err_file = tempfile.TemporaryFile()
231
+
232
+ def _child() -> None:
233
+ global _IN_SANDBOX_CHILD
234
+ _IN_SANDBOX_CHILD = True
235
+ try:
236
+ os.dup2(err_file.fileno(), 2)
237
+ except OSError:
238
+ pass # no stderr to redirect: carry on, the pipe still works
239
+ try:
240
+ _apply_rlimits(limits)
241
+ child_conn.send(('ok', fn()))
242
+ except MemoryError:
243
+ child_conn.send(('err', 'job exceeded memory limit (%d bytes)' % limits.max_memory_bytes))
244
+ except BaseException as e: # noqa: BLE001 - report anything to the parent
245
+ try:
246
+ child_conn.send(('err', '%s: %s' % (type(e).__name__, e)))
247
+ except Exception:
248
+ pass
249
+ finally:
250
+ child_conn.close()
251
+
252
+ proc = ctx.Process(target=_child, daemon=True)
253
+ proc.start()
254
+ child_conn.close()
255
+ try:
256
+ deadline = limits.timeout_seconds + _SANDBOX_KILL_GRACE_S
257
+ if parent_conn.poll(deadline):
258
+ try:
259
+ status, value = parent_conn.recv()
260
+ except EOFError:
261
+ status, value = 'err', None
262
+ else:
263
+ status, value = 'timeout', None
264
+ finally:
265
+ if proc.is_alive():
266
+ proc.kill()
267
+ proc.join(5)
268
+ parent_conn.close()
269
+ said = _read_child_stderr(err_file)
270
+ if status == 'ok':
271
+ if said:
272
+ # The job ran and had something to say. It is not an error, so it
273
+ # goes where it would have gone without the redirection.
274
+ with contextlib.suppress(Exception):
275
+ sys.stderr.write(said)
276
+ return value
277
+ if status == 'timeout':
278
+ raise ComputeError('job exceeded wall-clock limit (%ss)' % limits.timeout_seconds)
279
+ if value is None:
280
+ sig = -proc.exitcode if proc.exitcode and proc.exitcode < 0 else proc.exitcode
281
+ raise ComputeError(_died_message(sig, said))
282
+ raise ComputeError('job failed in sandbox: %s' % value)
@@ -0,0 +1,84 @@
1
+ """Types and records for the Compute compartment."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Mapping, Optional, TypedDict
5
+
6
+
7
+ class JobRunResult(TypedDict, total=False):
8
+ """What run_job hands back to receipt builders and the wire layer.
9
+
10
+ result_cid is absent on the custom_runner path (no blob store there); all
11
+ other keys are always present.
12
+ """
13
+ result_hash: str
14
+ result_cid: Optional[str]
15
+ raw_output: bytes
16
+ fuel_used: int
17
+ engine: str
18
+ wasi_profile_digest: str
19
+ counters: Mapping[str, int | float]
20
+ verification_mode: str
21
+
22
+
23
+ class ChatMessage(TypedDict, total=False):
24
+ """Chat completion message."""
25
+ role: str
26
+ content: str
27
+ name: Optional[str]
28
+
29
+
30
+ class OpenAIChatRequest(TypedDict, total=False):
31
+ """Normalized OpenAI chat completions payload."""
32
+ model: str
33
+ messages: list[ChatMessage]
34
+ max_tokens: int
35
+ temperature: float
36
+ top_p: float
37
+ stream: bool
38
+ seed: Optional[int]
39
+ response_format: Optional[Mapping[str, Any]]
40
+ guided_regex: Optional[str]
41
+
42
+
43
+ class BacalhauJobSummary(TypedDict, total=False):
44
+ """Summary of a Bacalhau job specification."""
45
+ status: str
46
+ engine: str
47
+ image: str
48
+ entrypoint: str | list[str]
49
+ parameters: list[str]
50
+ mounted_paths: list[str]
51
+ outputs: list[str]
52
+ bytes_processed: int
53
+ results: Optional[Mapping[str, Any]]
54
+
55
+
56
+ class AgentTraceStep(TypedDict, total=False):
57
+ """Step in an autonomous agent execution trace."""
58
+ step: int
59
+ thought: str
60
+ action: str
61
+ observation: str
62
+ output: Optional[str | Mapping[str, str | int | float | bool]]
63
+ sub_ask_id: Optional[str]
64
+
65
+
66
+ class AgentSubAsk(TypedDict, total=False):
67
+ """Sub-delegation ask in an agent orchestration tree."""
68
+ ask_id: str
69
+ task: str
70
+ budget_msat: int
71
+ status: str
72
+
73
+
74
+ class ComputeReceiptPayload(TypedDict, total=False):
75
+ """Payload of a decrypted compute receipt (kind 30811)."""
76
+ result_hash: str
77
+ result_cid: Optional[str]
78
+ raw_output: bytes | str
79
+ fuel_used: int
80
+ engine: str
81
+ wasi_profile_digest: str
82
+ counters: Mapping[str, int | float]
83
+ verification_mode: str
84
+ blinding: Optional[str]
@@ -0,0 +1,58 @@
1
+ """compute/1.0: Wire protocol, DIDComm transport lifecycle, accountable settlement, and dispute automation.
2
+
3
+ Implements the complete compute market wire lifecycle:
4
+ - DIDComm compute/1.0 message types (quote, accept, sealed, settled, refute)
5
+ - Cryptographic sealing under Lightning payment preimage k (NIP-44 v2 / ChaCha20+HMAC)
6
+ - Accountable delivery verification and fault classification (sealed-key-does-not-open,
7
+ result-hash-mismatch-after-decrypt, rerun-divergence, proof-invalid)
8
+ - Automated bilateral negotiation state machine (buyer and seller sessions)
9
+ - Conformance report generation (Kind 30802) citing signed sealed evidence under fleetmesh_core/reports.py
10
+ """
11
+ from __future__ import annotations
12
+
13
+
14
+ from .messages import (
15
+ PIURI,
16
+ PIURI_V11,
17
+ QUOTE_TYPE,
18
+ ACCEPT_TYPE,
19
+ SEALED_TYPE,
20
+ SETTLED_TYPE,
21
+ REFUTE_TYPE,
22
+ STREAM_TYPE,
23
+ PROGRESS_TYPE,
24
+ CANCEL_TYPE,
25
+ MESSAGES,
26
+ ComputeWireError,
27
+ make_quote,
28
+ make_accept,
29
+ make_sealed,
30
+ make_settled,
31
+ make_refute,
32
+ make_stream_chunk,
33
+ make_progress,
34
+ make_cancel,
35
+ )
36
+ from .sealing import seal_result, open_sealed, encrypt_stream_frame, decrypt_stream_frame
37
+ from .verify import vk_is_trusted, evaluate_job_evidence, build_compute_dispute_report
38
+ from .types import (
39
+ ComputeDisputeRecord,
40
+ ComputeSessionSummary,
41
+ ComputeWireStateReport,
42
+ EvidenceDetail,
43
+ SealedResult,
44
+ )
45
+ from .session import ComputeSession
46
+ from .compartment import ComputeWire
47
+
48
+ __all__ = [
49
+ 'PIURI', 'PIURI_V11', 'QUOTE_TYPE', 'ACCEPT_TYPE', 'SEALED_TYPE', 'SETTLED_TYPE',
50
+ 'REFUTE_TYPE', 'STREAM_TYPE', 'PROGRESS_TYPE', 'CANCEL_TYPE', 'MESSAGES',
51
+ 'ComputeWireError', 'make_quote', 'make_accept', 'make_sealed', 'make_settled',
52
+ 'make_refute', 'make_stream_chunk', 'make_progress', 'make_cancel',
53
+ 'seal_result', 'open_sealed', 'encrypt_stream_frame', 'decrypt_stream_frame',
54
+ 'vk_is_trusted', 'evaluate_job_evidence', 'build_compute_dispute_report',
55
+ 'ComputeSession', 'ComputeWire',
56
+ 'ComputeDisputeRecord', 'ComputeSessionSummary', 'ComputeWireStateReport',
57
+ 'EvidenceDetail', 'SealedResult',
58
+ ]
@@ -0,0 +1,256 @@
1
+ """ComputeWire: the subsystem that routes compute/1.0 envelopes to ComputeSession.
2
+
3
+ Separate so the subsystem wiring stays a thin adapter over the session and message modules.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Mapping, Optional
8
+
9
+ from node.config import ComputeConfig
10
+ from node.compartment import Command, Handler, Need, Outbound, WireProtocol
11
+ from node import channels as ch_mod
12
+ from node import metrics as mx_mod
13
+ from node.compartment import stores as st
14
+ from node.planes.services.compute import executors as compute_executors
15
+ from .messages import PIURI, PIURI_V11
16
+ from .session import ComputeSession
17
+
18
+
19
+ from .types import (
20
+ ComputeDisputeRecord,
21
+ ComputeSessionSummary,
22
+ ComputeWireStateReport,
23
+ )
24
+
25
+
26
+ # -------------------------------------------------------------------------
27
+ # Compute Wire Compartment (Transport Compartment)
28
+ # -------------------------------------------------------------------------
29
+ class ComputeWire(WireProtocol):
30
+ """The compute/1.0 and compute/1.1 wire transport compartment for meshnode."""
31
+
32
+ optional: bool = True
33
+ enabled_by: Optional[str] = 'compute'
34
+ name: str = 'compute_wire'
35
+ reads: tuple[str, ...] = ('compute', 'relays', 'transport')
36
+ config_cls = ComputeConfig
37
+ piuris: tuple[str, ...] = (PIURI, PIURI_V11)
38
+ OUTBOUND = (
39
+ Outbound('quote', reply=('accept', 'cancel'), doc='quote price and terms for compute task'),
40
+ Outbound('accept', reply=('sealed', 'cancel'), doc='accept quote and escrow payment'),
41
+ Outbound('sealed', reply=('settled', 'refute'), doc='deliver encrypted execution payload'),
42
+ Outbound('settled', doc='reveal payment preimage upon verified settlement'),
43
+ Outbound('refute', doc='refute execution proof or result divergence'),
44
+ Outbound('stream', doc='stream output tokens or chunk deltas'),
45
+ Outbound('progress', doc='stream execution progress heartbeat'),
46
+ Outbound('cancel', doc='cancel compute task execution'),
47
+ )
48
+ COMMANDS = (
49
+ Command(
50
+ key='sessions',
51
+ name='Compute Sessions',
52
+ description='list active bilateral compute sessions',
53
+ tags=('compute', 'sessions'),
54
+ permission='compute_wire/sessions',
55
+ run='list_sessions',
56
+ reply='dict',
57
+ ),
58
+ Command(
59
+ key='disputes',
60
+ name='Compute Disputes',
61
+ description='list compute refutations and dispute reports filed',
62
+ tags=('compute', 'disputes'),
63
+ permission='compute_wire/disputes',
64
+ run='list_disputes',
65
+ reply='list',
66
+ ),
67
+ )
68
+
69
+ HANDLERS = (
70
+ Handler('quote', run='handle_quote', doc='receive compute quotation'),
71
+ Handler('accept', run='handle_accept', doc='receive compute job acceptance'),
72
+ Handler('sealed', run='handle_sealed', doc='receive sealed compute payload'),
73
+ Handler('settled', run='handle_settled', doc='receive compute settlement preimage'),
74
+ Handler('refute', run='handle_refute', writes=('disputes',), doc='receive compute refutation dispute'),
75
+ Handler('stream', run='handle_stream', doc='receive real-time streaming output chunk'),
76
+ Handler('progress', run='handle_progress', doc='receive long-running progress heartbeat'),
77
+ Handler('cancel', run='handle_cancel', doc='receive compute job cancellation'),
78
+ )
79
+
80
+ # What it keeps. Each refute a peer sends is kept whole, and any peer
81
+ # can send one; this was a list that grew by one per refute for the life
82
+ # of the process. The newest DISPUTES_KEPT now, and `disputes_filed`
83
+ # still counts every one (the ring's inserts).
84
+ DISPUTES_KEPT = 256
85
+ STORES = (
86
+ st.Store(key='disputes', description='refutes received, as {peer, refute}, newest last',
87
+ kind=st.RING, shape=dict, bound=DISPUTES_KEPT),
88
+ )
89
+ CHANNELS = (
90
+ ch_mod.Channel('refute_received', 'A compute refutation dispute was received from a peer',
91
+ level=ch_mod.TROUBLE, say='compute dispute from {peer} for receipt {receipt}',
92
+ tags=('compute', 'wire', 'dispute')),
93
+ ch_mod.Channel('quote_received', 'A compute quotation was received from an executor peer',
94
+ level=ch_mod.NOTE, say='compute quote from {peer} price {price} msat',
95
+ tags=('compute', 'wire')),
96
+ )
97
+ METRICS = (
98
+ mx_mod.Metric(name='compute_wire_disputes', description='Compute disputes and refutations received.',
99
+ kind=mx_mod.COUNTER, counts='refute_received', tags=('compute', 'wire', 'disputes')),
100
+ mx_mod.Metric(name='compute_wire_quotes', description='Compute quotes received from peers.',
101
+ kind=mx_mod.COUNTER, counts='quote_received', tags=('compute', 'wire', 'quotes')),
102
+ )
103
+
104
+ NEEDS = (
105
+ Need('ks', 'ks'),
106
+ )
107
+
108
+ def __init__(self, node: Any = None, ks: Any = None) -> None:
109
+ super().__init__(node)
110
+ self.ks: Any = ks if ks is not None else getattr(node, 'ks', None)
111
+ self.sessions: dict[str, ComputeSession] = {} # not a store: never written - nothing constructs a ComputeSession yet; the sessions command reads it
112
+ self.disputes: st.Ring[ComputeDisputeRecord] # built and bound by the base (see STORES)
113
+ self.registry: compute_executors.ExecutorRegistry = compute_executors.DEFAULT_REGISTRY
114
+
115
+ def pinned_vk_digests(self) -> list[str]:
116
+ """Operator-pinned Groth16 verification-key digests (cfg compute.zk_pinned_vks)."""
117
+ return list(ComputeConfig.from_dict(self.config('compute') or {}).zk_pinned_vks)
118
+
119
+ def state(self) -> ComputeWireStateReport:
120
+ return {
121
+ 'active_sessions': len(self.sessions),
122
+ 'disputes_filed': self.disputes.inserts,
123
+ 'refusals': self.refusals.total(),
124
+ }
125
+
126
+ def list_sessions(self) -> dict[str, ComputeSessionSummary]:
127
+ return {
128
+ sid: {'role': s.role, 'peer': s.peer_pk, 'state': s.state}
129
+ for sid, s in self.sessions.items()
130
+ }
131
+
132
+ def list_disputes(self) -> list[ComputeDisputeRecord]:
133
+ return list(self.disputes)
134
+
135
+ def _my_sk(self) -> str:
136
+ ks = self.ks
137
+ if ks is not None and getattr(ks, 'node_sk', None):
138
+ return str(ks.node_sk)
139
+ return '11' * 32
140
+
141
+ def _peer_pk(self, sender: str) -> str:
142
+ s = str(sender or '').strip()
143
+ if s.startswith('did:nostr:'):
144
+ s = s[len('did:nostr:'):]
145
+ if len(s) == 64:
146
+ return s.lower()
147
+ return '22' * 32
148
+
149
+ def open_buyer_session(
150
+ self,
151
+ ask_id: str,
152
+ peer_pk: str,
153
+ job_payload: Optional[Mapping[str, Any]] = None,
154
+ ) -> ComputeSession:
155
+ sess = ComputeSession(
156
+ role='buyer',
157
+ my_sk=self._my_sk(),
158
+ peer_pk=self._peer_pk(peer_pk),
159
+ ask_id=ask_id,
160
+ job_payload=job_payload,
161
+ )
162
+ self.sessions[ask_id] = sess
163
+ return sess
164
+
165
+ def open_seller_session(
166
+ self,
167
+ ask_id: str,
168
+ peer_pk: str,
169
+ ) -> ComputeSession:
170
+ sess = ComputeSession(
171
+ role='seller',
172
+ my_sk=self._my_sk(),
173
+ peer_pk=self._peer_pk(peer_pk),
174
+ ask_id=ask_id,
175
+ )
176
+ self.sessions[ask_id] = sess
177
+ return sess
178
+
179
+ def handle_quote(self, inbound: Any, sender: str) -> tuple[Any, ...]:
180
+ msg = getattr(inbound, 'message', inbound)
181
+ body = msg.get('body') or {}
182
+ price = body.get('price_msat', 0)
183
+ thid = str(msg.get('thid') or body.get('ask') or msg.get('id') or '')
184
+ if thid:
185
+ sess = self.sessions.get(thid)
186
+ if sess is None:
187
+ sess = self.open_buyer_session(thid, sender)
188
+ sess.quote_msg = dict(msg)
189
+ sess.payment_hash = body.get('payment_hash')
190
+ sess.state = 'QUOTED'
191
+ self.say('quote_received', peer=sender, price=price)
192
+ return ('quote_received', sender, price)
193
+
194
+ def handle_accept(self, inbound: Any, sender: str) -> tuple[Any, ...]:
195
+ msg = getattr(inbound, 'message', inbound)
196
+ thid = msg.get('thid') or msg.get('id')
197
+ if thid:
198
+ sid = str(thid)
199
+ sess = self.sessions.get(sid)
200
+ if sess is None:
201
+ sess = self.open_seller_session(sid, sender)
202
+ sess.accept_msg = dict(msg)
203
+ sess.state = 'ACCEPTED'
204
+ return ('accept_received', sender, thid)
205
+
206
+ def handle_sealed(self, inbound: Any, sender: str) -> tuple[Any, ...]:
207
+ msg = getattr(inbound, 'message', inbound)
208
+ body = msg.get('body') or {}
209
+ thid = str(msg.get('thid') or msg.get('id') or '')
210
+ if thid:
211
+ sess = self.sessions.get(thid)
212
+ if sess is None:
213
+ sess = self.open_buyer_session(thid, sender)
214
+ sess.sealed_msg = dict(msg)
215
+ sess.payment_hash = body.get('payment_hash')
216
+ sess.result_hash = body.get('result')
217
+ sess.state = 'SEALED'
218
+ return ('sealed_received', sender, body.get('payment_hash'))
219
+
220
+ def handle_settled(self, inbound: Any, sender: str) -> tuple[Any, ...]:
221
+ msg = getattr(inbound, 'message', inbound)
222
+ thid = msg.get('thid') or msg.get('id')
223
+ if thid and str(thid) in self.sessions:
224
+ self.sessions[str(thid)].settled_msg = dict(msg)
225
+ self.sessions[str(thid)].state = 'SETTLED'
226
+ return ('settled_received', sender, thid)
227
+
228
+ def handle_refute(self, inbound: Any, sender: str) -> tuple[Any, ...]:
229
+ msg = getattr(inbound, 'message', inbound)
230
+ self.disputes.append({'peer': sender, 'refute': msg})
231
+ receipt = (msg.get('body') or {}).get('job_receipt', '')
232
+ thid = msg.get('thid') or msg.get('id')
233
+ if thid and str(thid) in self.sessions:
234
+ self.sessions[str(thid)].state = 'REFUTED'
235
+ self.say('refute_received', peer=sender, receipt=str(receipt)[:16])
236
+ return ('refute_received', sender, receipt)
237
+
238
+ def handle_stream(self, inbound: Any, sender: str) -> tuple[Any, ...]:
239
+ msg = getattr(inbound, 'message', inbound)
240
+ thid = msg.get('thid') or msg.get('id')
241
+ return ('stream_received', sender, thid, (msg.get('body') or {}).get('seq'))
242
+
243
+ def handle_progress(self, inbound: Any, sender: str) -> tuple[Any, ...]:
244
+ msg = getattr(inbound, 'message', inbound)
245
+ thid = msg.get('thid') or msg.get('id')
246
+ if thid and str(thid) in self.sessions:
247
+ self.sessions[str(thid)].progress_history.append(dict(msg))
248
+ return ('progress_received', sender, thid, (msg.get('body') or {}).get('progress'))
249
+
250
+ def handle_cancel(self, inbound: Any, sender: str) -> tuple[Any, ...]:
251
+ msg = getattr(inbound, 'message', inbound)
252
+ thid = msg.get('thid') or msg.get('id')
253
+ if thid and str(thid) in self.sessions:
254
+ self.sessions[str(thid)].state = 'CANCELLED'
255
+ return ('cancel_received', sender, thid, (msg.get('body') or {}).get('reason'))
256
+