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,396 @@
1
+ """The calls this gateway serves: handed to an app socket, parked, told their commands, counted."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Awaitable, Callable, Sequence
6
+ from dataclasses import dataclass, field, replace
7
+
8
+ from pydantic import TypeAdapter
9
+
10
+ from pinecall.domain.agent import AgentConfig
11
+ from pinecall.domain.call import CallContext, Opener
12
+ from pinecall.domain.errors import NotAvailable
13
+ from pinecall.domain.names import Env
14
+ from pinecall.domain.scope import Scope
15
+ from pinecall.gateway._sockets import Process, SocketId
16
+ from pinecall.gateway.calls.commands import COMMANDS_CHANNEL, SUPERVISOR_VERB
17
+ from pinecall.gateway.calls.inbox import (
18
+ ANSWER_CHANNEL,
19
+ Bound,
20
+ Elsewhere,
21
+ ForApp,
22
+ SocketRow,
23
+ answered_back,
24
+ told_app,
25
+ told_bound,
26
+ )
27
+ from pinecall.gateway.calls.owners import Owners
28
+ from pinecall.gateway.calls.pump import Send, pumped
29
+ from pinecall.log.logs import Log, Logs
30
+ from pinecall.process.connections import Connections
31
+ from pinecall.process.metrics import Counters
32
+ from pinecall.process.shared import Shared
33
+ from pinecall.process.signal import LocalSignal, Signal
34
+ from pinecall.retrieval.embed import Embedder
35
+ from pinecall.session.call import with_app_fields
36
+ from pinecall.session.session import Session
37
+ from pinecall.session.tools import ToolCalls
38
+ from pinecall.tenancy.prompts import Prompts
39
+ from pinecall.wire.commands import DevAnswer, SessionConfigure, SupervisorVerb, command_of
40
+ from pinecall.wire.frames import Command, Entry, WireModel
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # Entries a taking-over socket is rebuilt from; the prompt is not, the log keeps its hash alone.
46
+ STARTED = "call.started"
47
+
48
+ # Where each gateway says how many calls each org runs on it, and how often.
49
+ RUNNING_CHANNEL = "running"
50
+ RUNNING_EVERY_S = 1.0
51
+ _RUNNING: TypeAdapter[dict[str, int]] = TypeAdapter(dict[str, int])
52
+
53
+ # How long a call another gateway opened stays served here with no door asking for it.
54
+ FIRST_SEEN_IDLE_S = 600.0
55
+
56
+
57
+ CLAIMED = "call.claimed"
58
+
59
+
60
+ # A call belongs to its agent, not to a socket: `app` is None while it waits for one.
61
+ @dataclass(frozen=True)
62
+ class Served:
63
+ """A call this gateway serves: the socket it reaches, its log, its commands, its tools."""
64
+
65
+ agent: str
66
+ scope: Scope
67
+ app: SocketId | None
68
+ log: Log
69
+ # A None ends the worker's command stream.
70
+ commands: asyncio.Queue[Command | None]
71
+ context: CallContext
72
+ config: AgentConfig
73
+ tools: ToolCalls
74
+ # A written call runs here; a voice call runs in a worker.
75
+ session: Session | None = None
76
+ # Held while the call is sealed, so its seal runs once however many times it is asked for.
77
+ sealing: asyncio.Lock = field(default_factory=asyncio.Lock)
78
+ # False for a call another gateway opened, served here from what was kept when it opened:
79
+ # it counts on its opener's gateway, and no socket here takes it as parked.
80
+ opened_here: bool = True
81
+ # Who opened it: the worker doors of the fleet's call take the fleet's key alone, a written
82
+ # call's take nobody's. None for a call opened before the head said.
83
+ opened_by: Opener | None = None
84
+
85
+ @property
86
+ def call(self) -> str:
87
+ """The call's id."""
88
+ return self.context.call
89
+
90
+
91
+ # Not durable on purpose: it says which sockets are open now; the log is the record.
92
+ class ServedCalls:
93
+ """The open app sockets and the calls served to them."""
94
+
95
+ def __init__(self, signal: Signal | None = None) -> None:
96
+ """Nothing open; bindings to other gateways' sockets told on the signal."""
97
+ self.signal = signal or LocalSignal()
98
+ # Which written calls and threads run here, and on the other gateways; their sockets.
99
+ self.owners = Owners(self.signal)
100
+ self.elsewhere = Elsewhere(self.signal)
101
+ # Each org's calls at once in each world, as every gateway counts its own each second.
102
+ self.counted = Shared(self.signal, RUNNING_CHANNEL, _RUNNING, {}, lambda: None)
103
+ self.counted.every_s = RUNNING_EVERY_S
104
+ self.counted.gathered = self._running_here
105
+ # Each dev.request asked here, listening for its answer from the socket's gateway.
106
+ self.answers: dict[str, asyncio.Task[None]] = {}
107
+ self.sockets: dict[SocketId, Send] = {}
108
+ self.processes: dict[SocketId, Process] = {}
109
+ self.calls: dict[str, Served] = {}
110
+ # The calls first seen here, by when a door last asked for one: let go once idle.
111
+ self.seen: dict[str, float] = {}
112
+ self.pending_answers: dict[str, asyncio.Future[DevAnswer]] = {}
113
+ # Each bound call's pump to its socket, and each call's listener for its commands, by call:
114
+ # held here, since asyncio keeps weak references to tasks, and cancelled when it ends.
115
+ self.pumps: dict[str, asyncio.Task[None]] = {}
116
+ self.listening: dict[str, tuple[asyncio.Event, asyncio.Task[None]]] = {}
117
+
118
+ async def listen(self) -> None:
119
+ """Hear the other gateways' owners and sockets, and say this one's."""
120
+ await self.owners.start()
121
+ await self.elsewhere.start()
122
+ await self.counted.start()
123
+
124
+ async def quiet(self) -> None:
125
+ """Stop hearing and saying."""
126
+ await self.owners.close()
127
+ await self.elsewhere.close()
128
+ await self.counted.close()
129
+
130
+ def connect(self, process: Process, send: Send) -> None:
131
+ """An app socket opened."""
132
+ self.sockets[process.app] = send
133
+ self.processes[process.app] = process
134
+ self._say_sockets()
135
+
136
+ def disconnect(self, app: SocketId) -> None:
137
+ """An app socket closed; its calls go on until another socket takes them."""
138
+ self.sockets.pop(app, None)
139
+ self.processes.pop(app, None)
140
+ self._say_sockets()
141
+
142
+ def named(self, app: SocketId, host: str | None) -> None:
143
+ """The machine the app said it runs on."""
144
+ process = self.processes.get(app)
145
+ if process is not None and host is not None:
146
+ self.processes[app] = replace(process, host=host)
147
+ self._say_sockets()
148
+
149
+ # The other gateways' sockets are stopped through their inbox, as any message to them is.
150
+ def processes_of(self, org: str, env: Env) -> list[Process]:
151
+ """The org's sockets in the world on every gateway, oldest first."""
152
+ mine = [
153
+ value
154
+ for value in self.processes.values()
155
+ if value.scope.org == org and value.scope.env == env
156
+ ]
157
+ theirs = [
158
+ Process(
159
+ row.app, row.scope, row.address, row.connected_at, self._stop(row.app), row.host
160
+ )
161
+ for row in self.elsewhere.rows_of(org, env)
162
+ ]
163
+ return sorted([*mine, *theirs], key=lambda one: one.connected_at)
164
+
165
+ # dev.request is never stored: it goes straight down the socket, wherever it is held.
166
+ async def tell(self, app: SocketId, entry: Entry) -> bool:
167
+ """Send an entry to one socket; False when no gateway holds it."""
168
+ send = self.sockets.get(app)
169
+ if send is None:
170
+ return await told_app(self.signal, app, ForApp(entry=entry))
171
+ await send(entry)
172
+ return True
173
+
174
+ async def ask(self, params: str) -> asyncio.Future[DevAnswer]:
175
+ """A future for the answer to a dev.request, answered here or by the socket's gateway."""
176
+ answer: asyncio.Future[DevAnswer] = asyncio.get_running_loop().create_future()
177
+ self.pending_answers[params] = answer
178
+ heard = asyncio.Event()
179
+ task = asyncio.create_task(self._answer_from_there(params, answer, heard))
180
+ self.answers[params] = task
181
+
182
+ # Answered, or given up on (the asker cancels it): the listener goes with it.
183
+ def over(_: asyncio.Future[DevAnswer]) -> None:
184
+ self.answers.pop(params, None)
185
+ task.cancel()
186
+
187
+ answer.add_done_callback(over)
188
+ await heard.wait()
189
+ return answer
190
+
191
+ async def dev_answered(self, answer: DevAnswer) -> bool:
192
+ """Hand an app's dev.answer to the door waiting for it; False when nobody waits."""
193
+ waiting = self.pending_answers.pop(answer.id, None)
194
+ if waiting is None:
195
+ return await answered_back(self.signal, answer)
196
+ if waiting.done():
197
+ return False
198
+ waiting.set_result(answer)
199
+ return True
200
+
201
+ def serve(self, served: Served) -> None:
202
+ """Serve the call to the socket its door chose; a call already served stays as it is."""
203
+ if served.call in self.calls:
204
+ return
205
+ self.calls[served.call] = served
206
+ self.pumped(served.call, after=0)
207
+ heard = asyncio.Event()
208
+ self.listening[served.call] = (
209
+ heard,
210
+ asyncio.create_task(self._commands(served.call, heard)),
211
+ )
212
+
213
+ async def commands_heard(self, call: str) -> None:
214
+ """Return once the call's commands are listened for here."""
215
+ listening = self.listening.get(call)
216
+ if listening is not None:
217
+ await listening[0].wait()
218
+
219
+ # Synchronous, so two sockets taking one parked call cannot both have it. The new socket's pump
220
+ # starts once call.attached is written (`attach`), from it.
221
+ def attach(self, call: str, app: SocketId | None) -> Served | None:
222
+ """Move the call to this socket (None parks it); None when nothing moved."""
223
+ served = self.calls.get(call)
224
+ if served is None or served.app == app:
225
+ return None
226
+ self._stopped(call)
227
+ moved = replace(served, app=app)
228
+ self.calls[call] = moved
229
+ return moved
230
+
231
+ # What any gateway writes to the call reaches its socket here, in seq order, from `after`;
232
+ # `then` goes down right after the first entry (the tools still waiting, after call.attached).
233
+ def pumped(self, call: str, *, after: int, then: Sequence[Entry] = ()) -> None:
234
+ """Start sending the call's entries to its socket, if the socket is open here."""
235
+ served = self.calls.get(call)
236
+ if served is None or served.app is None:
237
+ return
238
+ send = self.sockets.get(served.app)
239
+ if send is None:
240
+ told_bound(self.signal, served.app, Bound(call=call, after=after))
241
+ return
242
+ self._stopped(call)
243
+ self.pumps[call] = asyncio.ensure_future(pumped(served.log, after, send, then))
244
+
245
+ def opened_by(self, call: str, opener: Opener | None) -> None:
246
+ """Say who opened the call, once it is served here."""
247
+ served = self.calls.get(call)
248
+ if served is not None:
249
+ self.calls[call] = replace(served, opened_by=opener)
250
+
251
+ def bound_to(self, app: SocketId) -> list[str]:
252
+ """The calls this socket serves."""
253
+ return [call for call, served in self.calls.items() if served.app == app]
254
+
255
+ def parked(self, scope: Scope, agent: str) -> list[str]:
256
+ """The calls of the agent in the scope that wait for a socket."""
257
+ return [
258
+ call
259
+ for call, served in self.calls.items()
260
+ if served.app is None
261
+ and served.opened_here
262
+ and served.agent == agent
263
+ and served.scope == scope
264
+ ]
265
+
266
+ # The concurrent calls quota counts these, never head rows: a dead worker's row would count
267
+ # for ever. Per world, as every quota is: the sandbox's calls never close production.
268
+ # The concurrent-calls quota reads it: every gateway's count, the others' a second old at most,
269
+ # so two gateways admitting at once may go past the limit by what they opened that second.
270
+ def running(self, org: str, env: Env) -> int:
271
+ """How many of the org's calls are open in the world, on every gateway of the box."""
272
+ named = f"{org}|{env}"
273
+ theirs = sum(heard.share.get(named, 0) for heard in self.counted.theirs.values())
274
+ return self._running_here().get(named, 0) + theirs
275
+
276
+ def in_use(self, call: str, now: float) -> None:
277
+ """A door asked for a call first seen here: it stays while it is asked for."""
278
+ if call in self.seen:
279
+ self.seen[call] = now
280
+
281
+ # A call first seen here is a cache of what was kept: let go when idle, and read again later.
282
+ def idle(self, now: float) -> None:
283
+ """Let go of the calls first seen here that no door asked for in FIRST_SEEN_IDLE_S."""
284
+ idle = [call for call, at in self.seen.items() if now - at > FIRST_SEEN_IDLE_S]
285
+ for call in idle:
286
+ if call not in self.pumps:
287
+ self.close(call)
288
+
289
+ def close(self, call: str) -> None:
290
+ """Forget a finished call: its entries end, and its worker's command stream."""
291
+ self.seen.pop(call, None)
292
+ served = self.calls.pop(call, None)
293
+ if served is None:
294
+ return
295
+ self._stopped(call)
296
+ served.commands.put_nowait(None)
297
+ self.owners.running(call, here=False)
298
+ listening = self.listening.pop(call, None)
299
+ if listening is not None:
300
+ listening[1].cancel()
301
+
302
+ # A voice call's commands wait here for its worker's stream; a written call's session takes
303
+ # them at once. Heard whichever gateway the app or the desk sent them to.
304
+ async def _commands(self, call: str, heard: asyncio.Event) -> None:
305
+ try:
306
+ listening = await self.signal.subscribe(COMMANDS_CHANNEL.format(call=call))
307
+ except NotAvailable:
308
+ logger.warning("call %s: its commands are not heard here, the signal is down", call)
309
+ return
310
+ finally:
311
+ heard.set()
312
+ try:
313
+ async for data in listening:
314
+ await self._applied(call, Command.model_validate_json(data))
315
+ finally:
316
+ listening.close()
317
+
318
+ async def _applied(self, call: str, command: Command) -> None:
319
+ served = self.calls.get(call)
320
+ if served is None or served.agent != command.agent:
321
+ return
322
+ if served.session is None:
323
+ served.commands.put_nowait(command)
324
+ elif command.type == SUPERVISOR_VERB:
325
+ await served.session.supervise(SupervisorVerb.model_validate(command.data))
326
+ else:
327
+ model = command_of(command)
328
+ declared_for_the_call(served, model)
329
+ await served.session.apply(model)
330
+
331
+ def _running_here(self) -> dict[str, int]:
332
+ counts: dict[str, int] = {}
333
+ for served in self.calls.values():
334
+ if served.opened_here:
335
+ named = f"{served.scope.org}|{served.scope.env}"
336
+ counts[named] = counts.get(named, 0) + 1
337
+ return counts
338
+
339
+ def _say_sockets(self) -> None:
340
+ self.elsewhere.shared.put(
341
+ tuple(
342
+ SocketRow(item.app, item.scope, item.address, item.connected_at, item.host)
343
+ for item in self.processes.values()
344
+ )
345
+ )
346
+
347
+ def _stop(self, app: SocketId) -> Callable[[str], Awaitable[None]]:
348
+ async def stop(why: str) -> None:
349
+ await told_app(self.signal, app, ForApp(stop=why))
350
+
351
+ return stop
352
+
353
+ async def _answer_from_there(
354
+ self, params: str, answer: asyncio.Future[DevAnswer], heard: asyncio.Event
355
+ ) -> None:
356
+ try:
357
+ listening = await self.signal.subscribe(ANSWER_CHANNEL.format(id=params))
358
+ except NotAvailable:
359
+ return
360
+ finally:
361
+ heard.set()
362
+ try:
363
+ async for data in listening:
364
+ if not answer.done():
365
+ answer.set_result(DevAnswer.model_validate_json(data))
366
+ return
367
+ finally:
368
+ listening.close()
369
+
370
+ def _stopped(self, call: str) -> None:
371
+ pump = self.pumps.pop(call, None)
372
+ if pump is not None:
373
+ pump.cancel()
374
+
375
+
376
+ @dataclass(frozen=True)
377
+ class Serving:
378
+ """What serves a call: the process's connections, the logs, and the calls live in it."""
379
+
380
+ connections: Connections
381
+ logs: Logs
382
+ live: ServedCalls
383
+ # None when the box embeds nothing: every lookup finds nothing and no hang-up remembers.
384
+ embedder: Embedder | None
385
+ # The blocks of prompt this process has kept, so a call's prompt is read back whole.
386
+ prompts: Prompts = field(default_factory=Prompts)
387
+ # What the process counts, for /metrics: the seal holds up an org's unusual spend there.
388
+ counters: Counters = field(default_factory=Counters)
389
+
390
+
391
+ def declared_for_the_call(served: Served, model: WireModel) -> None:
392
+ """A written call's own declaration of what is private, as the app configured it."""
393
+ privacy = served.log.privacy
394
+ if isinstance(model, SessionConfigure) and model.config is not None and privacy is not None:
395
+ declared = with_app_fields(privacy.config, model.config)
396
+ served.log.privacy = replace(privacy, config=declared)