pinecall-runtime 0.1.6__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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,534 @@
1
+ """Settings: every variable the gateway and the worker read, and the three places it comes from."""
2
+
3
+ import os
4
+ from collections.abc import Iterator, Mapping
5
+ from pathlib import Path
6
+ from typing import Literal, Self
7
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
8
+
9
+ from dotenv import dotenv_values
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
11
+
12
+ from pinecall.domain.errors import SettingsRefused
13
+ from pinecall.domain.names import SANDBOX, Env
14
+ from pinecall.postgres.pool import POOL_SIZE
15
+
16
+ # Checked in each directory up to the repository root; `runtime/.env` serves a process started
17
+ # from the checkout root. When both exist the later wins.
18
+ ENV_FILES = (".env", "runtime/.env")
19
+
20
+ # Slug-shaped: it ends up in Twilio usernames and SFU trunk names. Never empty: an empty
21
+ # agent_name makes LiveKit dispatch to every room (livekit worker.py:219).
22
+ A_FLEET_NAME = r"^[a-z0-9][a-z0-9-]{0,62}$"
23
+
24
+ UNREADABLE_ENV = "cannot read {file}: {why}. A .env that is there is never skipped in silence"
25
+
26
+ NO_STORE = (
27
+ "PINECALL_RECORDINGS_BUCKET is a bucket of the object store, which is named by "
28
+ "PINECALL_S3_ENDPOINT, PINECALL_S3_REGION, PINECALL_S3_ACCESS_KEY_ID and the "
29
+ "PINECALL_S3_SECRET_ACCESS_KEY credential together; missing: {missing}"
30
+ )
31
+
32
+ # Spoken by the overflow agent when every worker is full, before hanging up.
33
+ OVERFLOW_SAYS = (
34
+ "En este momento todas nuestras líneas están ocupadas. Hemos tomado nota de su número "
35
+ "y le devolveremos la llamada en cuanto se libere una. Gracias por su paciencia."
36
+ )
37
+
38
+
39
+ class Settings(BaseModel):
40
+ """Typed, frozen process settings; `load()` builds them."""
41
+
42
+ model_config = ConfigDict(
43
+ frozen=True, extra="ignore", validate_by_name=True, validate_by_alias=True
44
+ )
45
+
46
+ # ── the media plane ──
47
+ livekit_url: str = Field(
48
+ "ws://127.0.0.1:7880",
49
+ alias="LIVEKIT_URL",
50
+ description="LiveKit, which both processes talk to. One port serves ws and http.",
51
+ )
52
+ # A world's rooms live on its own LiveKit, so a sandbox call never shares a machine with a
53
+ # production one; a worker is given its world's in LIVEKIT_URL.
54
+ livekit_sandbox_url: str | None = Field(
55
+ None,
56
+ alias="LIVEKIT_SANDBOX_URL",
57
+ description="The sandbox's own LiveKit, as the gateway reaches it. Unset: LIVEKIT_URL's.",
58
+ )
59
+ livekit_api_key: str | None = Field(
60
+ None, alias="LIVEKIT_API_KEY", description="The LiveKit API key, as its own config says."
61
+ )
62
+ # Also signs call tokens, which are LiveKit room tokens. Without the pair, no tokens.
63
+ livekit_api_secret: str | None = Field(
64
+ None, alias="LIVEKIT_API_SECRET", repr=False, description="Its secret."
65
+ )
66
+ livekit_public_url: str | None = Field(
67
+ None,
68
+ alias="LIVEKIT_PUBLIC_URL",
69
+ description="The LiveKit URL a browser joins when the box has no name. Unset: LIVEKIT_URL.",
70
+ )
71
+ # One name serves both worlds: a request's world is its key's or its header's, never the name
72
+ # it came in by (tenancy/keys.py, world_of).
73
+ domain: str | None = Field(
74
+ None,
75
+ alias="PINECALL_DOMAIN",
76
+ description="The box's name: where the public, the SDKs and a carrier reach it.",
77
+ )
78
+ # Where a carrier sends a world's calls, when that is not the box's name: in a cluster the
79
+ # name is Google's HTTPS load balancer, which carries no SIP, and each world's SIP node has
80
+ # an address of its own (infra/README.md).
81
+ sip_domain: str | None = Field(
82
+ None,
83
+ alias="PINECALL_SIP_DOMAIN",
84
+ description="The name a carrier sends production's calls to. Unset: the box's name.",
85
+ )
86
+ sandbox_sip_domain: str | None = Field(
87
+ None,
88
+ alias="PINECALL_SANDBOX_SIP_DOMAIN",
89
+ description="The name a carrier sends the sandbox's calls to. Unset: production's.",
90
+ )
91
+
92
+ # ── Postgres ──
93
+ database_url: str = Field(
94
+ "postgresql://pinecall:pinecall@127.0.0.1:5432/pinecall",
95
+ alias="DATABASE_URL",
96
+ repr=False,
97
+ description="Postgres 17 with pgvector and pg_textsearch: the one stateful service.",
98
+ )
99
+ # Where the gateway connects as a role that reads and writes rows and changes no table: the
100
+ # migrations, run as the owner, grant it that and no more each time they run.
101
+ db_app_role: str | None = Field(
102
+ None,
103
+ alias="PINECALL_DB_APP_ROLE",
104
+ description="The role `migrate up` grants rows to; unset, the owner is the app's role too.",
105
+ )
106
+ db_pool: int = Field(
107
+ POOL_SIZE,
108
+ alias="PINECALL_DB_POOL",
109
+ ge=3,
110
+ description="Connections to Postgres: two its writer holds, the rest the doors share.",
111
+ )
112
+
113
+ # ── the signal between gateways ──
114
+ # A URL may carry a password. Unset, the gateway is alone on its box and keeps it in memory.
115
+ redis_url: str | None = Field(
116
+ None,
117
+ alias="PINECALL_REDIS_URL",
118
+ repr=False,
119
+ description="The Redis the gateways tell each other what just happened on. Unset: one.",
120
+ )
121
+
122
+ # ── recordings ──
123
+ recordings_root: str = Field(
124
+ "recordings",
125
+ alias="PINECALL_RECORDINGS",
126
+ description="Where a kept recording lands, absolute or relative to the working directory.",
127
+ )
128
+ # A bucket of the object store below; never the backup's bucket, whose 35-day lifecycle rule
129
+ # would forget a recording its org keeps longer.
130
+ recordings_bucket: str | None = Field(
131
+ None,
132
+ alias="PINECALL_RECORDINGS_BUCKET",
133
+ description="The bucket a finished recording moves to, under its org. Unset: the disk.",
134
+ )
135
+
136
+ # A box's WAL spool: archive_command copied each segment there and a timer shipped it. A
137
+ # cluster's Postgres archives through its operator, and has no spool to read.
138
+ wal_spool: str | None = Field(
139
+ "/var/lib/pinecall/wal",
140
+ alias="PINECALL_WAL_SPOOL",
141
+ description="The box's WAL spool doctor reads the backlog of; empty in a cluster.",
142
+ )
143
+
144
+ # ── the object store ──
145
+ # Any S3-compatible endpoint: AWS, Google Cloud Storage by HMAC key, R2, B2, a MinIO.
146
+ s3_endpoint: str | None = Field(
147
+ None,
148
+ alias="PINECALL_S3_ENDPOINT",
149
+ description="The S3-compatible object store what leaves the disk goes to.",
150
+ )
151
+ s3_region: str | None = Field(
152
+ None,
153
+ alias="PINECALL_S3_REGION",
154
+ description="The region the store's signature names: the buckets' own, or `auto`.",
155
+ )
156
+ s3_access_key_id: str | None = Field(
157
+ None,
158
+ alias="PINECALL_S3_ACCESS_KEY_ID",
159
+ description="The key the box reads and writes the store with.",
160
+ )
161
+ s3_secret_access_key: str | None = Field(
162
+ None,
163
+ alias="PINECALL_S3_SECRET_ACCESS_KEY",
164
+ repr=False,
165
+ description="The key's secret: a sealed credential on a box, never a file.",
166
+ )
167
+
168
+ # ── the worker ──
169
+ # Read from the environment because livekit runs each job in a child process that inherits
170
+ # only the environment. The gateway also binds this URL's host and port.
171
+ gateway_url: str = Field(
172
+ "http://127.0.0.1:8080",
173
+ alias="PINECALL_GATEWAY_URL",
174
+ description="The instance's gateway: where it binds, on loopback, and what a worker asks.",
175
+ )
176
+ # A box's gateway binds PINECALL_GATEWAY_URL's loopback address, behind Caddy; a pod's binds
177
+ # its own network, behind the cluster's load balancer, and says so here (infra/charts).
178
+ gateway_listen: str | None = Field(
179
+ None,
180
+ alias="PINECALL_GATEWAY_LISTEN",
181
+ description="host:port a pod's gateway binds (0.0.0.0:8080); unset, the URL's loopback.",
182
+ )
183
+ trusted_proxies: str = Field(
184
+ "127.0.0.1",
185
+ alias="PINECALL_TRUSTED_PROXIES",
186
+ description="Addresses or networks whose X-Forwarded-For the gateway believes.",
187
+ )
188
+ metrics_from: str = Field(
189
+ "127.0.0.1,::1",
190
+ alias="PINECALL_METRICS_FROM",
191
+ description="Addresses or networks /metrics answers; never a forwarded request.",
192
+ )
193
+ agent: str | None = Field(
194
+ None, alias="PINECALL_AGENT", description="The agent a job that names none is for."
195
+ )
196
+ max_jobs: int | None = Field(
197
+ None,
198
+ alias="PINECALL_MAX_JOBS",
199
+ description="Calls this worker holds at once, measured on its machine. Unset: gate on CPU.",
200
+ )
201
+ worker_http_port: int = Field(
202
+ 8082,
203
+ alias="PINECALL_WORKER_HTTP_PORT",
204
+ description="Where the worker's own health server binds, on loopback.",
205
+ )
206
+ worker_http_host: str = Field(
207
+ "127.0.0.1",
208
+ alias="PINECALL_WORKER_HTTP_HOST",
209
+ description="The health server's address: loopback on a box, 0.0.0.0 for a pod's probes.",
210
+ )
211
+ worker_name: str | None = Field(
212
+ None,
213
+ alias="PINECALL_WORKER_NAME",
214
+ description="What this worker is called in its heartbeats. Unset: the short hostname.",
215
+ )
216
+ # Per-instance LiveKit agent name, so production and sandbox on one SFU never take each
217
+ # other's calls. Also prefixes trunk and rule names.
218
+ fleet: str = Field(
219
+ "pinecall",
220
+ alias="PINECALL_FLEET",
221
+ pattern=A_FLEET_NAME,
222
+ description="The fleet this worker unit joins: the name it registers under with LiveKit.",
223
+ )
224
+ # livekit prewarms one process per CPU, wasting RAM when two instances share a machine.
225
+ idle_processes: int | None = Field(
226
+ None,
227
+ alias="PINECALL_IDLE_PROCESSES",
228
+ description="Job processes the worker keeps warm. Unset: livekit's, one per CPU.",
229
+ )
230
+ # systemd's own, set on a Type=notify unit: a release waits for the worker to say it is ready.
231
+ notify_socket: str | None = Field(
232
+ None,
233
+ alias="NOTIFY_SOCKET",
234
+ description="Where a worker tells systemd it is registered and heard. Set by systemd.",
235
+ )
236
+ overflow_says: str = Field(
237
+ OVERFLOW_SAYS,
238
+ alias="PINECALL_OVERFLOW_SAYS",
239
+ description="What the overflow agent says when the fleet is full, before it hangs up.",
240
+ )
241
+ # A developer's worker sets this so calls reach their own app socket.
242
+ app: str | None = Field(
243
+ None,
244
+ alias="PINECALL_APP",
245
+ description="The app socket a job's call claims. Unset, the call takes the newest holder.",
246
+ )
247
+
248
+ # ── the box ──
249
+ role: Literal["all", "hub", "worker"] = Field(
250
+ "all",
251
+ alias="PINECALL_ROLE",
252
+ description="What this box runs: all · hub · worker. The doctor asks after what it has.",
253
+ )
254
+ # Brute force is limited by argon2id and rate limiting, not length.
255
+ min_password: int = Field(
256
+ 8,
257
+ alias="PINECALL_MIN_PASSWORD",
258
+ ge=0,
259
+ description="How short a member's password may be. 0 is no rule.",
260
+ )
261
+ ops_key: str | None = Field(
262
+ None,
263
+ alias="PINECALL_OPS_KEY",
264
+ repr=False,
265
+ description="The key /v1/ops/* is authenticated by. Unset, the operator API is closed.",
266
+ )
267
+ cloud: bool = Field(
268
+ default=False, alias="PINECALL_CLOUD", description="Pinecall's hosted gateway: billed."
269
+ )
270
+ signup: bool = Field(
271
+ default=False,
272
+ alias="PINECALL_SIGNUP",
273
+ description="Whether a stranger may make an org at this gateway. Off unless you say.",
274
+ )
275
+ # A number an org hooks itself proves nothing of whose it is; on, it rings nobody until the
276
+ # operator approves it (POST /v1/ops/numbers/{number}/approve). Off, it rings at once.
277
+ approve_hooked: bool = Field(
278
+ default=False,
279
+ alias="PINECALL_APPROVE_HOOKED",
280
+ description="Whether a number an org hooks itself waits for the operator. Off by default.",
281
+ )
282
+ billing_url: str | None = Field(
283
+ None,
284
+ alias="PINECALL_BILLING_URL",
285
+ description="Where this box's orgs pay, https://…; unset, the box bills nobody.",
286
+ )
287
+ # Restricts sign-up to the bot-protected site in front of it, whose X-Pinecall-Client header
288
+ # is then trusted as the client address.
289
+ signup_key: str | None = Field(
290
+ None,
291
+ alias="PINECALL_SIGNUP_KEY",
292
+ repr=False,
293
+ description="The Bearer key the sign-up doors take; unset, they take anybody.",
294
+ )
295
+ # The mobile app's two origins are always allowed. No wildcards.
296
+ app_origins: str = Field(
297
+ "",
298
+ alias="PINECALL_APP_ORIGINS",
299
+ description="Origins besides the mobile app's two that may call /v1, comma separated.",
300
+ )
301
+ # Not PINECALL_API_KEY: the CLI and the SDK read that name, and a stale export once
302
+ # registered agents into the wrong org.
303
+ worker_key: str | None = Field(
304
+ None,
305
+ alias="PINECALL_WORKER_KEY",
306
+ repr=False,
307
+ description="The org key the worker knocks its gateway with, as `keys issue` printed it.",
308
+ )
309
+ # ── the runner ──
310
+ runner_key: str | None = Field(
311
+ None,
312
+ alias="PINECALL_RUNNER_KEY",
313
+ repr=False,
314
+ description="The key a world's runner knocks with, as `keys runner` printed it.",
315
+ )
316
+ runner_image: str = Field(
317
+ "docker.io/library/node:24-slim",
318
+ alias="PINECALL_RUNNER_IMAGE",
319
+ description="The image every hosted app installs and runs in.",
320
+ )
321
+ runner_runtime_class: str = Field(
322
+ "gvisor",
323
+ alias="PINECALL_RUNNER_RUNTIME_CLASS",
324
+ description="The runtime class a hosted app's pod runs under: gvisor, GKE Sandbox's.",
325
+ )
326
+ runner_namespace: str = Field(
327
+ "pinecall-apps",
328
+ alias="PINECALL_RUNNER_NAMESPACE",
329
+ description="The namespace of the hosting cluster the runner starts every app's pod in.",
330
+ )
331
+ runner_sources_url: str = Field(
332
+ "http://127.0.0.1:8080",
333
+ alias="PINECALL_RUNNER_SOURCES_URL",
334
+ description="The runner as an app's pod reaches it, to fetch its release's sources.",
335
+ )
336
+ # Loopback unless told: in a pod, its chart says every address, which its Service reaches.
337
+ runner_listen: str = Field(
338
+ "127.0.0.1",
339
+ alias="PINECALL_RUNNER_LISTEN",
340
+ description="The address the runner serves the releases' sources on.",
341
+ )
342
+ runner_port: int = Field(
343
+ 8080,
344
+ alias="PINECALL_RUNNER_PORT",
345
+ description="The port the runner serves the releases' sources on, to its world's pods.",
346
+ )
347
+ # Kept out of the database so a stolen dump does not expose tenants' secrets. To rotate: a
348
+ # comma-separated list, the new key first; a secret seals under the first and opens under
349
+ # whichever sealed it.
350
+ vault_key: str | None = Field(
351
+ None,
352
+ alias="PINECALL_VAULT_KEY",
353
+ repr=False,
354
+ description="The Fernet key every sealed secret is under; the gateway needs it.",
355
+ )
356
+ # The gateway's own, held by no worker and no LiveKit: a token that reads a log or asks after
357
+ # a caller code is signed with it, so the LiveKit pair every worker holds mints none.
358
+ token_key: str | None = Field(
359
+ None,
360
+ alias="PINECALL_TOKEN_KEY",
361
+ repr=False,
362
+ description="The key the gateway signs its own tokens with; the gateway needs it.",
363
+ )
364
+ smtp_url: str | None = Field(
365
+ None,
366
+ alias="PINECALL_SMTP_URL",
367
+ repr=False,
368
+ description="smtp://user:pass@host:587, or smtps://…:465: what this box posts mail with.",
369
+ )
370
+ mail_from: str | None = Field(
371
+ None,
372
+ alias="PINECALL_MAIL_FROM",
373
+ description='Who the box\'s letters are from: "Pinecall <no-reply@example.com>".',
374
+ )
375
+ log_level: str = Field(
376
+ "INFO",
377
+ alias="PINECALL_LOG_LEVEL",
378
+ description="How much both processes say: DEBUG, INFO, WARNING or ERROR.",
379
+ )
380
+ # json matches the worker's own JSON logs (livekit's JsonFormatter).
381
+ log_format: Literal["text", "json"] = Field(
382
+ "text",
383
+ alias="PINECALL_LOG_FORMAT",
384
+ description="The gateway's lines: text for a terminal, json for a journal.",
385
+ )
386
+ otlp_endpoint: str | None = Field(
387
+ None,
388
+ alias="PINECALL_OTLP_ENDPOINT",
389
+ description="Where the worker sends a call's traces, OTLP over HTTP. Unset: no trace.",
390
+ )
391
+ otlp_headers: str | None = Field(
392
+ None,
393
+ alias="PINECALL_OTLP_HEADERS",
394
+ repr=False,
395
+ description="Headers on every trace export, `name=value` comma-separated: a credential.",
396
+ )
397
+ otlp_pii: bool = Field(
398
+ default=False,
399
+ alias="PINECALL_OTLP_PII",
400
+ description="Whether a trace carries what was said and what a tool got.",
401
+ )
402
+ # Calls are dated in the caller's zone, not the box's, so "tomorrow" means the right day.
403
+ timezone: str = Field(
404
+ "UTC",
405
+ alias="PINECALL_TIMEZONE",
406
+ description="The IANA zone a call's `today` is read in (Europe/Madrid). UTC unless set.",
407
+ )
408
+ # ── memory and retrieval ──
409
+ # Unset budgets use the session's defaults.
410
+ voice_lookup_budget_ms: int | None = Field(
411
+ None,
412
+ alias="PINECALL_VOICE_LOOKUP_BUDGET_MS",
413
+ description="What a spoken turn waits for recall and search, in ms: silence on the line.",
414
+ )
415
+ text_lookup_budget_ms: int | None = Field(
416
+ None,
417
+ alias="PINECALL_TEXT_LOOKUP_BUDGET_MS",
418
+ description="What a written turn waits for recall and search, in ms.",
419
+ )
420
+ remember_budget_s: float | None = Field(
421
+ None,
422
+ alias="PINECALL_REMEMBER_BUDGET_S",
423
+ description="What a hang-up waits for memory to be written, in s. Past it the call seals.",
424
+ )
425
+
426
+ # Vendor keys keep the vendor's own variable name, so the SDK that reads ANTHROPIC_API_KEY
427
+ # by itself and this runtime agree; the catalog asks for them by name.
428
+ variables: Mapping[str, str] = Field(
429
+ default_factory=dict,
430
+ repr=False,
431
+ description="Every variable read, for the vendor keys the catalog names.",
432
+ )
433
+
434
+ @field_validator("timezone")
435
+ @classmethod
436
+ def _a_zone_that_exists(cls, zone: str) -> str:
437
+ try:
438
+ ZoneInfo(zone)
439
+ except (ZoneInfoNotFoundError, ValueError):
440
+ raise ValueError(f"{zone!r} is not an IANA time zone (Europe/Madrid, UTC)") from None
441
+ return zone
442
+
443
+ # Refused where the process starts, not on the first recording a call leaves.
444
+ @model_validator(mode="after")
445
+ def _a_recordings_bucket_has_its_store(self) -> Self:
446
+ named = {
447
+ "PINECALL_S3_ENDPOINT": self.s3_endpoint,
448
+ "PINECALL_S3_REGION": self.s3_region,
449
+ "PINECALL_S3_ACCESS_KEY_ID": self.s3_access_key_id,
450
+ "PINECALL_S3_SECRET_ACCESS_KEY": self.s3_secret_access_key,
451
+ }
452
+ missing = [name for name, value in named.items() if value is None]
453
+ if self.recordings_bucket is not None and missing:
454
+ raise ValueError(NO_STORE.format(missing=", ".join(missing)))
455
+ return self
456
+
457
+ def sip_name_of(self, world: Env) -> str | None:
458
+ """The name a carrier sends that world's calls to: its SIP name, else the box's name."""
459
+ named = self.sandbox_sip_domain if world == SANDBOX else self.sip_domain
460
+ return named or self.sip_domain or self.domain
461
+
462
+ @property
463
+ def address(self) -> str | None:
464
+ """The https address of the box's name, or None where it has none."""
465
+ return None if self.domain is None else f"https://{self.domain}"
466
+
467
+ def livekit_url_of(self, world: Env) -> str:
468
+ """The LiveKit that world's rooms live on, as this process reaches it."""
469
+ if world == SANDBOX and self.livekit_sandbox_url:
470
+ return self.livekit_sandbox_url
471
+ return self.livekit_url
472
+
473
+ # The load balancer sends `/sandbox/rtc…` to the sandbox's own LiveKit, the prefix stripped.
474
+ def browser_livekit_url(self, world: Env) -> str:
475
+ """The LiveKit URL a browser is told to join for a call of the world."""
476
+ own = self.livekit_sandbox_url if world == SANDBOX else None
477
+ if self.domain:
478
+ return f"wss://{self.domain}/sandbox" if own else f"wss://{self.domain}"
479
+ return own or self.livekit_public_url or self.livekit_url
480
+
481
+
482
+ def load() -> Settings:
483
+ """Build Settings from the systemd credentials, the .env files found and the environment."""
484
+ read: dict[str, str] = {}
485
+ credentials = os.environ.get("CREDENTIALS_DIRECTORY")
486
+ if credentials:
487
+ read.update(_credentials_in(Path(credentials)))
488
+ for file in env_files():
489
+ read.update(_env_file(file))
490
+ read.update(os.environ)
491
+ # A bare `NAME=` means unset, so a copied example does not hand a vendor an empty value.
492
+ variables = {name: value for name, value in read.items() if value != ""}
493
+ try:
494
+ return Settings.model_validate({**variables, "variables": variables})
495
+ except ValidationError as refused:
496
+ raise SettingsRefused(str(refused)) from refused
497
+
498
+
499
+ def env_files() -> list[Path]:
500
+ """Return the .env files of the first directory up to the repository root that has any."""
501
+ for folder in _up_to_the_repository_root(Path.cwd()):
502
+ found = [folder / name for name in ENV_FILES if (folder / name).is_file()]
503
+ if found:
504
+ return found
505
+ return []
506
+
507
+
508
+ # Bounded at the repository root: a stray .env above the project would silently load wrong keys.
509
+ def _up_to_the_repository_root(start: Path) -> Iterator[Path]:
510
+ folder = start.resolve()
511
+ for candidate in (folder, *folder.parents):
512
+ yield candidate
513
+ if (candidate / ".git").exists():
514
+ return
515
+
516
+
517
+ # On a box, secrets arrive as systemd credentials: one file per variable name.
518
+ def _credentials_in(directory: Path) -> dict[str, str]:
519
+ return {
520
+ file.name: file.read_text(encoding="utf-8").strip()
521
+ for file in sorted(directory.iterdir())
522
+ if file.is_file()
523
+ }
524
+
525
+
526
+ # An unreadable .env fails loudly: it may hold the key that selects the right database.
527
+ def _env_file(path: Path) -> dict[str, str]:
528
+ try:
529
+ values = dotenv_values(path, encoding="utf-8")
530
+ except OSError as failed:
531
+ raise SettingsRefused(
532
+ UNREADABLE_ENV.format(file=path, why=failed.strerror or failed)
533
+ ) from failed
534
+ return {name: value for name, value in values.items() if value is not None}
@@ -0,0 +1,158 @@
1
+ """State each gateway holds a share of, told to the others on the signal and merged by each."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import time
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass
8
+ from uuid import uuid4
9
+
10
+ from pydantic import BaseModel, TypeAdapter
11
+
12
+ from pinecall.domain.errors import NotAvailable
13
+ from pinecall.postgres.pool import box_task
14
+ from pinecall.process.signal import Signal
15
+
16
+ # Each process says its whole share this often, and forgets another's share once it has been
17
+ # silent this long: a gateway that died takes its share with it within half a minute.
18
+ TOLD_EVERY_S = 10.0
19
+ SILENT_AT_MOST_S = 30.0
20
+
21
+ # A share said changes nothing past the next beat: a lost message is repaired by it.
22
+ RETRY_S = 1.0
23
+
24
+ # A key set to nobody is kept this long, so an older setting said by another process does not
25
+ # come back while that process still says it.
26
+ FORGOTTEN_AFTER_S = 60.0
27
+
28
+
29
+ class Said(BaseModel):
30
+ """One message on a share's channel: which process said it, and its share whole."""
31
+
32
+ sender: str
33
+ share: object
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Assigned:
38
+ """A key of a world as one process last set it: to a holder, or to nobody, and when."""
39
+
40
+ env: str
41
+ key: str
42
+ holder: str | None
43
+ at: float
44
+
45
+
46
+ @dataclass
47
+ class Heard[T]:
48
+ """Another process's share, and when it last said it."""
49
+
50
+ share: T
51
+ at: float
52
+
53
+
54
+ # Lossy by design, as the signal is: every share is said whole, again and again, so a message lost
55
+ # or a Redis that restarted costs at most one beat. What a share holds must be small (sockets,
56
+ # lines, phones): it travels whole every TOLD_EVERY_S.
57
+ class Shared[T]:
58
+ """One share of this process, the others' as last heard, and a callback when either moves."""
59
+
60
+ def __init__(
61
+ self,
62
+ signal: Signal,
63
+ channel: str,
64
+ adapter: TypeAdapter[T],
65
+ empty: T,
66
+ changed: Callable[[], None],
67
+ ) -> None:
68
+ """Nothing heard yet; `changed` is called whenever the merged view must be rebuilt."""
69
+ self.signal = signal
70
+ # How often the share is said whole: TOLD_EVERY_S unless its owner says sooner.
71
+ self.every_s = TOLD_EVERY_S
72
+ # Set by an owner whose share changes too often to put: read as it is said.
73
+ self.gathered: Callable[[], T] | None = None
74
+ self.channel = channel
75
+ self.id = uuid4().hex
76
+ self.mine = empty
77
+ self.theirs: dict[str, Heard[T]] = {}
78
+ self._adapter = adapter
79
+ self._changed = changed
80
+ self._tasks: list[asyncio.Task[None]] = []
81
+
82
+ def put(self, share: T) -> None:
83
+ """This process's share changed: keep it, and say it at once."""
84
+ self.mine = share
85
+ self._say()
86
+
87
+ async def start(self) -> None:
88
+ """Listen to the others and say this share every beat."""
89
+ self._tasks = [box_task(self._listened()), box_task(self._beating())]
90
+
91
+ async def close(self) -> None:
92
+ """Stop listening and saying."""
93
+ for task in self._tasks:
94
+ task.cancel()
95
+ await asyncio.gather(*self._tasks, return_exceptions=True)
96
+
97
+ def _say(self) -> None:
98
+ mine = self.mine if self.gathered is None else self.gathered()
99
+ message = Said(sender=self.id, share=self._adapter.dump_python(mine, mode="json"))
100
+ self.signal.publish(self.channel, message.model_dump_json().encode())
101
+
102
+ async def _beating(self) -> None:
103
+ while True:
104
+ self._say()
105
+ await asyncio.sleep(self.every_s)
106
+ now = time.monotonic()
107
+ silent = [gw for gw, heard in self.theirs.items() if now - heard.at > SILENT_AT_MOST_S]
108
+ for gateway in silent:
109
+ del self.theirs[gateway]
110
+ if silent:
111
+ self._changed()
112
+
113
+ async def _listened(self) -> None:
114
+ while True:
115
+ try:
116
+ listening = await self.signal.subscribe(self.channel)
117
+ except NotAvailable:
118
+ await asyncio.sleep(RETRY_S)
119
+ continue
120
+ # Said again once subscribed: a process that just started is told everyone's share.
121
+ self._say()
122
+ try:
123
+ async for data in listening:
124
+ self._heard(data)
125
+ finally:
126
+ listening.close()
127
+ with contextlib.suppress(TimeoutError):
128
+ await asyncio.wait_for(self.signal.connected(), RETRY_S)
129
+
130
+ def _heard(self, data: bytes) -> None:
131
+ message = Said.model_validate_json(data)
132
+ if message.sender == self.id:
133
+ return
134
+ new = message.sender not in self.theirs
135
+ share = self._adapter.validate_python(message.share)
136
+ self.theirs[message.sender] = Heard(share, time.monotonic())
137
+ self._changed()
138
+ if new:
139
+ self._say()
140
+
141
+
142
+ # The newest setting of each key stands, whichever process said it. A setting of ours another
143
+ # overruled is dropped, and one to nobody is kept only a while: what is said every beat stays the
144
+ # size of what is set.
145
+ def newest(
146
+ mine: dict[tuple[str, str], Assigned], theirs: Iterable[Assigned]
147
+ ) -> dict[tuple[str, str], str]:
148
+ """Each key's holder by its newest setting, ours and theirs; ours pruned of what is spent."""
149
+ latest = dict(mine)
150
+ for assigned in theirs:
151
+ key = (assigned.env, assigned.key)
152
+ if key not in latest or latest[key].at < assigned.at:
153
+ latest[key] = assigned
154
+ now = time.time()
155
+ for key, kept in list(mine.items()):
156
+ if latest[key] is not kept or (kept.holder is None and now - kept.at > FORGOTTEN_AFTER_S):
157
+ del mine[key]
158
+ return {key: setting.holder for key, setting in latest.items() if setting.holder is not None}