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,444 @@
1
+ """Who holds each agent: the app sockets registered in this process, per scope, and the line."""
2
+
3
+ import time
4
+ from collections.abc import Awaitable, Callable, Sequence
5
+ from dataclasses import dataclass, replace
6
+ from uuid import uuid4
7
+
8
+ from pydantic import TypeAdapter
9
+
10
+ from pinecall.domain.agent import AgentConfig
11
+ from pinecall.domain.errors import DeclarationRefused
12
+ from pinecall.domain.names import PRODUCTION, Env, JsonObject
13
+ from pinecall.domain.scope import THE_ORGS_OWN, Scope
14
+ from pinecall.log.logs import Logs
15
+ from pinecall.process.shared import Assigned, Shared, newest
16
+ from pinecall.wire.commands import AgentRegister
17
+ from pinecall.wire.events import (
18
+ AgentConfigured,
19
+ AgentDetached,
20
+ AgentDraining,
21
+ AgentRegistered,
22
+ )
23
+ from pinecall.wire.frames import Entry
24
+
25
+ # Minted, never id(websocket): CPython reuses addresses, and the id travels back as `?app=`.
26
+ type SocketId = str
27
+
28
+
29
+ # A slug is one org's: the scope names the org, the world and whose copy it is.
30
+ type Held = tuple[Scope, str]
31
+
32
+
33
+ AN_APP = "app_"
34
+
35
+
36
+ NOT_HOLDING = "agent {slug} is not held in {env} by an app of yours that answers an unclaimed call"
37
+
38
+
39
+ ANOTHER_ORGS = "agent {slug} belongs to another org: a slug is one org's"
40
+
41
+
42
+ NOT_REGISTERED = "agent {slug} is not registered on this socket: register it first"
43
+
44
+
45
+ NO_AGENT = "no app is holding agent {slug}"
46
+
47
+
48
+ NOT_THAT_APP = "app {app} is not holding agent {slug}: it disconnected, or it never held it"
49
+
50
+
51
+ NO_UNCLAIMED = (
52
+ "agent {slug} is held only by apps that take no call they did not open: run `pinecall start`"
53
+ )
54
+
55
+
56
+ NO_DEV_ANSWERER = (
57
+ "agent {slug} is held, but nothing answers the console for it: "
58
+ "run `pinecall start` in its directory"
59
+ )
60
+
61
+
62
+ type Stop = Callable[[str], Awaitable[None]]
63
+
64
+ # Every gateway's sockets, lines and phones, said on this channel and merged by each.
65
+ HELD_CHANNEL = "held"
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Registration:
70
+ """One agent as one app socket holds it."""
71
+
72
+ slug: str
73
+ scope: Scope
74
+ owner: SocketId
75
+ config: AgentConfig
76
+ sdk: str | None = None
77
+ # False for a console: it takes only the calls that name it.
78
+ takes_unclaimed: bool = True
79
+ # True for the CLI's companion: the console's dev verbs go to it, never to the class's socket.
80
+ answers_dev: bool = False
81
+ # When it was claimed, on any gateway, so the newest holder is known across all of them.
82
+ claimed: float = 0.0
83
+ # Set by agent.drain: its tools still answer, it takes no new call.
84
+ draining: bool = False
85
+
86
+ @property
87
+ def held_as(self) -> Held:
88
+ """The registry's key for this agent in its scope."""
89
+ return (self.scope, self.slug)
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Share:
94
+ """What one gateway holds: its sockets' agents, and the lines and phones it set."""
95
+
96
+ registrations: tuple[Registration, ...] = ()
97
+ lines: tuple[Assigned, ...] = ()
98
+ phones: tuple[Assigned, ...] = ()
99
+
100
+
101
+ # Durable facts are in the log; this is which sockets are open now, on every gateway of the box:
102
+ # each says its own share on the signal and reads the merged view. The line is where a ring
103
+ # lands: the org's own scope in production, one developer's in the sandbox, which an org shares.
104
+ class Sockets:
105
+ """The agents the app sockets hold, per scope, the line of each, and the developers' phones."""
106
+
107
+ def __init__(self, logs: Logs) -> None:
108
+ """Nobody holds anything yet."""
109
+ self.logs = logs
110
+ # This gateway's own sockets, and the lines and phones it set.
111
+ self.mine: dict[Held, list[Registration]] = {}
112
+ self.mine_lines: dict[tuple[str, str], Assigned] = {}
113
+ self.mine_phones: dict[tuple[str, str], Assigned] = {}
114
+ # The merged view every read answers from: every gateway's.
115
+ self.holders: dict[Held, list[Registration]] = {}
116
+ self.lines: dict[tuple[str, str], str] = {}
117
+ self.phones: dict[tuple[str, str], str] = {}
118
+ self.owned: dict[SocketId, set[Held]] = {}
119
+ self.shared = Shared(logs.relay.signal, HELD_CHANNEL, _SHARE, Share(), self._merged)
120
+ self._last_claim = 0.0
121
+
122
+ async def start(self) -> None:
123
+ """Hear the other gateways' sockets, and say this one's."""
124
+ await self.shared.start()
125
+
126
+ async def close(self) -> None:
127
+ """Stop hearing and saying."""
128
+ await self.shared.close()
129
+
130
+ # ── reading ──
131
+
132
+ def of(self, scope: Scope, slug: str) -> Registration | None:
133
+ """The newest holder in this scope, else in the org's own."""
134
+ return self._newest((scope, slug)) or self._newest((orgs_own(scope), slug))
135
+
136
+ def serving(self, scope: Scope, slug: str, app: SocketId | None) -> Registration | None:
137
+ """The socket a call opened in this scope goes to: the one named, else the newest taker."""
138
+ if app is not None:
139
+ found = self.on(app, scope.env, slug)
140
+ return found if found is not None and found.scope.org == scope.org else None
141
+ return self._taker((scope, slug)) or self._taker((orgs_own(scope), slug))
142
+
143
+ def answering_dev(self, scope: Scope, slug: str) -> Registration | None:
144
+ """The socket the console's dev verbs go to: the newest that answers them, scope first."""
145
+ return self._answerer((scope, slug)) or self._answerer((orgs_own(scope), slug))
146
+
147
+ # A caller's registered phone reaches their own scope first; then the line.
148
+ def taking(self, scope: Scope, slug: str, caller: str | None) -> Registration | None:
149
+ """The socket a ring at the agent lands in."""
150
+ if caller is not None and (holder := self.phones.get((scope.env, caller))) is not None:
151
+ theirs = self._taker((replace(scope, holder=holder), slug))
152
+ if theirs is not None:
153
+ return theirs
154
+ holder = self.lines.get((scope.env, slug))
155
+ if holder is None:
156
+ return None
157
+ return self._taker((replace(scope, holder=holder), slug))
158
+
159
+ def on(self, app: SocketId, env: Env, slug: str) -> Registration | None:
160
+ """The agent as this socket holds it in this world."""
161
+ for found in self.owned.get(app, ()):
162
+ if found[0].env == env and found[1] == slug:
163
+ return next(
164
+ (item for item in self.holders.get(found, ()) if item.owner == app), None
165
+ )
166
+ return None
167
+
168
+ def owned_by(self, app: SocketId) -> list[Registration]:
169
+ """Every agent one socket holds, in claim order."""
170
+ found = (item for name in self.owned.get(app, ()) for item in self.holders.get(name, ()))
171
+ return sorted(
172
+ (holder for holder in found if holder.owner == app),
173
+ key=lambda item_found: item_found.claimed,
174
+ )
175
+
176
+ # One row per slug, the reader's scope over the org's; every scope for a team reader.
177
+ def holding(self, scope: Scope, *, every_corner: bool) -> list[Registration]:
178
+ """The org's held agents in the world, as this reader sees them."""
179
+ seen: dict[str | Held, Registration] = {}
180
+ for (owner, slug), holding in self.holders.items():
181
+ if owner.org != scope.org or owner.env != scope.env or not holding:
182
+ continue
183
+ mine = owner.holder in (THE_ORGS_OWN, scope.holder)
184
+ if not every_corner and not mine:
185
+ continue
186
+ under: str | Held = (owner, slug) if every_corner else slug
187
+ if under not in seen or owner.holder == scope.holder:
188
+ seen[under] = holding[-1]
189
+ return list(seen.values())
190
+
191
+ def slugs(self, org: str) -> frozenset[str]:
192
+ """Every slug the org holds, in any world and scope: the agents quota counts these."""
193
+ return frozenset(
194
+ slug
195
+ for (owner_scope, slug), kept in self.holders.items()
196
+ if owner_scope.org == org and kept
197
+ )
198
+
199
+ # A log reader knows the slug alone: production's declaration, else any world's.
200
+ def declared(self, slug: str) -> AgentConfig | None:
201
+ """The agent's declared config, production's first."""
202
+ found = sorted(
203
+ (kept[-1] for (_, name), kept in self.holders.items() if name == slug and kept),
204
+ key=lambda item_found: (item_found.scope.env != PRODUCTION, -item_found.claimed),
205
+ )
206
+ return found[0].config if found else None
207
+
208
+ # ── the line ──
209
+
210
+ def line_of(self, env: Env, slug: str) -> str | None:
211
+ """The scope holding the agent's line; None when nobody does."""
212
+ return self.lines.get((env, slug))
213
+
214
+ # Taking the line is said out loud: starting later never takes a colleague's calls.
215
+ def take_the_line(self, scope: Scope, slug: str) -> None:
216
+ """Give the agent's line to this scope, which must hold an app that answers a ring."""
217
+ if self._taker((scope, slug)) is None:
218
+ raise DeclarationRefused(NOT_HOLDING.format(slug=slug, env=scope.env))
219
+ self._assigned(self.mine_lines, scope.env, slug, scope.holder)
220
+
221
+ def drop_the_line(self, scope: Scope, slug: str) -> bool:
222
+ """Let the line go, to the newest other scope that could take it."""
223
+ if self.lines.get((scope.env, slug)) != scope.holder:
224
+ return False
225
+ self._assigned(self.mine_lines, scope.env, slug, None)
226
+ self._next_takes_the_line(scope, slug, leaving=scope.holder)
227
+ return True
228
+
229
+ def waiting_for_the_line(self, scope: Scope, slug: str) -> list[Registration]:
230
+ """Every scope of the org that could take the line, newest first."""
231
+ taking = [
232
+ item
233
+ for (owner_scope, name) in self.holders
234
+ if name == slug and owner_scope.org == scope.org and owner_scope.env == scope.env
235
+ if (item := self._taker((owner_scope, name))) is not None
236
+ ]
237
+ return sorted(taking, key=lambda item: item.claimed, reverse=True)
238
+
239
+ # ── the developers' phones ──
240
+
241
+ def calls_from(self, env: Env, number: str, holder: str) -> None:
242
+ """Send rings from this phone to this person's scope; the last one said wins."""
243
+ self._assigned(self.mine_phones, env, number, holder)
244
+
245
+ def forget_calls_from(self, env: Env, holder: str) -> list[str]:
246
+ """Forget every phone of this person, and say which."""
247
+ gone = sorted(
248
+ n
249
+ for (world, n), owner_scope in self.phones.items()
250
+ if world == env and owner_scope == holder
251
+ )
252
+ for number in gone:
253
+ self._assigned(self.mine_phones, env, number, None)
254
+ return gone
255
+
256
+ def phone_of(self, env: Env, number: str) -> str | None:
257
+ """Whose own phone the number is in the world, or None."""
258
+ return self.phones.get((env, number.strip()))
259
+
260
+ def calling(self, env: Env, holder: str) -> list[str]:
261
+ """The phones this person rings from."""
262
+ return sorted(
263
+ n
264
+ for (world, n), owner_scope in self.phones.items()
265
+ if world == env and owner_scope == holder
266
+ )
267
+
268
+ # ── claiming ──
269
+
270
+ async def register(
271
+ self, owner: SocketId, scope: Scope, slug: str, wanted: AgentRegister
272
+ ) -> Entry:
273
+ """This socket holds the agent from now on; agent.registered is written on its log."""
274
+ store = self.logs.store
275
+ owner_org = await store.owner(slug)
276
+ if owner_org is not None and owner_org != scope.org:
277
+ raise DeclarationRefused(ANOTHER_ORGS.format(slug=slug))
278
+ await store.claim(None, slug, scope.org)
279
+ # A call that arrives before agent.configure still runs on what the agent declared.
280
+ registration = self.on(owner, scope.env, slug) or self.of(scope, slug)
281
+ config = registration.config if registration is not None else AgentConfig(slug=slug)
282
+ self._replace(
283
+ Registration(
284
+ slug=slug,
285
+ scope=scope,
286
+ owner=owner,
287
+ config=config,
288
+ sdk=wanted.sdk,
289
+ takes_unclaimed=wanted.takes_unclaimed,
290
+ answers_dev=wanted.answers_dev,
291
+ )
292
+ )
293
+ data = AgentRegistered(routes=[], app=owner, sdk=wanted.sdk, env=scope.env)
294
+ return await self._written(scope.env, slug, "agent.registered", data.written())
295
+
296
+ async def configure(
297
+ self, owner: SocketId, env: Env, slug: str, config: AgentConfig, changed: Sequence[str]
298
+ ) -> Entry:
299
+ """The declaration this socket sent; agent.configured names what changed."""
300
+ found = self.on(owner, env, slug)
301
+ if found is None:
302
+ raise DeclarationRefused(NOT_REGISTERED.format(slug=slug))
303
+ self._replace(replace(found, config=config))
304
+ data = AgentConfigured(changed=list(changed))
305
+ return await self._written(env, slug, "agent.configured", data.written())
306
+
307
+ # A deploy: the socket keeps the agent so its tools still answer, and takes no new call.
308
+ def drain(self, owner: SocketId, env: Env, slug: str) -> None:
309
+ """Mark the socket as draining the agent."""
310
+ found = self.on(owner, env, slug)
311
+ if found is None:
312
+ raise DeclarationRefused(NOT_REGISTERED.format(slug=slug))
313
+ self._replace(replace(found, draining=True))
314
+
315
+ async def drained(
316
+ self, owner: SocketId, env: Env, slug: str, *, handed: int, parked: int
317
+ ) -> Entry:
318
+ """agent.draining, once the socket's calls moved."""
319
+ data = AgentDraining(app=owner, env=env, handed=handed, parked=parked)
320
+ return await self._written(env, slug, "agent.draining", data.written())
321
+
322
+ async def release(self, owner: SocketId) -> None:
323
+ """A socket closed: its agents let go, the line handed on, agent.detached for each."""
324
+ theirs = [key for key, kept in self.mine.items() if any(i.owner == owner for i in kept)]
325
+ for found in theirs:
326
+ kept = [item for item in self.mine[found] if item.owner != owner]
327
+ if kept:
328
+ self.mine[found] = kept
329
+ else:
330
+ del self.mine[found]
331
+ self._merged()
332
+ left = self.holders.get(found, [])
333
+ scope, slug = found
334
+ if self.lines.get((scope.env, slug)) == scope.holder and not left:
335
+ self._assigned(self.mine_lines, scope.env, slug, None)
336
+ self._next_takes_the_line(scope, slug)
337
+ data = AgentDetached(app=owner, env=scope.env, left=not left)
338
+ await self._written(scope.env, slug, "agent.detached", data.written())
339
+ self._say()
340
+
341
+ # Claimed at the gateway's clock, never earlier than its last claim: the newest is the last.
342
+ def _replace(self, registration: Registration) -> None:
343
+ self._last_claim = max(time.time(), self._last_claim + 1e-6)
344
+ claim = replace(registration, claimed=self._last_claim)
345
+ holding = self.mine.setdefault(claim.held_as, [])
346
+ at = next(
347
+ (n for n, item_found in enumerate(holding) if item_found.owner == claim.owner), None
348
+ )
349
+ if at is None:
350
+ holding.append(claim)
351
+ else:
352
+ holding[at] = claim
353
+ self._merged()
354
+ # The first scope able to take a ring gets the line; later ones claim it.
355
+ taking = claim.takes_unclaimed and not claim.draining
356
+ if taking and (claim.scope.env, claim.slug) not in self.lines:
357
+ self._assigned(self.mine_lines, claim.scope.env, claim.slug, claim.scope.holder)
358
+ self._say()
359
+
360
+ # A line or a phone set here: said to every gateway, the newest setting of a key standing.
361
+ def _assigned(
362
+ self, table: dict[tuple[str, str], Assigned], env: Env, key: str, holder: str | None
363
+ ) -> None:
364
+ table[(env, key)] = Assigned(env, key, holder, time.time())
365
+ self._merged()
366
+ self._say()
367
+
368
+ def _say(self) -> None:
369
+ self.shared.put(
370
+ Share(
371
+ registrations=tuple(item for kept in self.mine.values() for item in kept),
372
+ lines=tuple(self.mine_lines.values()),
373
+ phones=tuple(self.mine_phones.values()),
374
+ )
375
+ )
376
+
377
+ # Every gateway's sockets, oldest claim first; each line and phone as its newest setting says.
378
+ def _merged(self) -> None:
379
+ theirs = [heard.share for heard in self.shared.theirs.values()]
380
+ holders: dict[Held, list[Registration]] = {k: list(v) for k, v in self.mine.items()}
381
+ for share in theirs:
382
+ for item in share.registrations:
383
+ holders.setdefault(item.held_as, []).append(item)
384
+ owned: dict[SocketId, set[Held]] = {}
385
+ for key, kept in holders.items():
386
+ kept.sort(key=lambda item: item.claimed)
387
+ for item in kept:
388
+ owned.setdefault(item.owner, set()).add(key)
389
+ self.holders, self.owned = holders, owned
390
+ self.lines = newest(
391
+ self.mine_lines, (setting for share in theirs for setting in share.lines)
392
+ )
393
+ self.phones = newest(
394
+ self.mine_phones, (setting for share in theirs for setting in share.phones)
395
+ )
396
+
397
+ def _newest(self, holding_found: Held) -> Registration | None:
398
+ holding = self.holders.get(holding_found)
399
+ return holding[-1] if holding else None
400
+
401
+ def _taker(self, holding_found: Held) -> Registration | None:
402
+ holding = reversed(self.holders.get(holding_found, ()))
403
+ return next((item for item in holding if item.takes_unclaimed and not item.draining), None)
404
+
405
+ def _answerer(self, holding_found: Held) -> Registration | None:
406
+ holding = reversed(self.holders.get(holding_found, ()))
407
+ return next((item for item in holding if item.answers_dev and not item.draining), None)
408
+
409
+ def _next_takes_the_line(self, scope: Scope, slug: str, *, leaving: str | None = None) -> None:
410
+ waiting = [
411
+ item for item in self.waiting_for_the_line(scope, slug) if item.scope.holder != leaving
412
+ ]
413
+ if waiting:
414
+ self._assigned(self.mine_lines, scope.env, slug, waiting[0].scope.holder)
415
+
416
+ # A laptop restarts often: in the sandbox these entries are forgettable, or they flood the log.
417
+ async def _written(self, env: Env, slug: str, kind: str, data: JsonObject) -> Entry:
418
+ forgettable = None if env == PRODUCTION else True
419
+ return await self.logs.agent(slug).append(kind, data, ephemeral=forgettable)
420
+
421
+
422
+ @dataclass(frozen=True)
423
+ class Process:
424
+ """One connected app socket, as the org's list of processes shows it."""
425
+
426
+ app: SocketId
427
+ scope: Scope
428
+ address: str | None
429
+ connected_at: float
430
+ # Sends `error stopped` and closes: the SDK exits instead of reconnecting.
431
+ stop: Stop
432
+ host: str | None = None
433
+
434
+
435
+ _SHARE: TypeAdapter[Share] = TypeAdapter(Share)
436
+
437
+
438
+ def orgs_own(scope: Scope) -> Scope:
439
+ return replace(scope, holder=THE_ORGS_OWN)
440
+
441
+
442
+ def new_socket_id() -> SocketId:
443
+ """A new id for an app socket."""
444
+ return f"{AN_APP}{uuid4().hex[:12]}"
@@ -0,0 +1,83 @@
1
+ """Server-sent events: the frames, the pings that keep a stream alive, its end on shutdown."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+
7
+ from fastapi.responses import StreamingResponse
8
+
9
+ from pinecall.domain.names import JsonObject
10
+
11
+ SSE = "text/event-stream"
12
+
13
+ # Short: a reconnect resumes losslessly from Last-Event-ID.
14
+ RETRY_MS = 1000
15
+
16
+ # Under the 30 s a proxy lets a quiet connection idle.
17
+ PING_S = 25.0
18
+
19
+ PING = ": ping\n\n"
20
+
21
+ # x-accel-buffering: nginx would hold the stream in its buffer.
22
+ SSE_HEADERS = {"cache-control": "no-store", "connection": "keep-alive", "x-accel-buffering": "no"}
23
+
24
+
25
+ def wants_sse(accept: str | None) -> bool:
26
+ """Whether the Accept header asks for the stream rather than a page."""
27
+ return SSE in (accept or "")
28
+
29
+
30
+ def frame(event: str, data: JsonObject, *, seq: int | None = None) -> str:
31
+ """One SSE frame: its id (the seq), its event, its data as one line of JSON."""
32
+ head = "" if seq is None else f"id: {seq}\n"
33
+ return f"{head}event: {event}\ndata: {json.dumps(data, separators=(',', ':'))}\n\n"
34
+
35
+
36
+ def streamed(frames: AsyncIterator[str], closing: asyncio.Event) -> StreamingResponse:
37
+ """Frames as an SSE response, ended when they run out or the process stops."""
38
+ return StreamingResponse(until(closing, frames), media_type=SSE, headers=SSE_HEADERS)
39
+
40
+
41
+ # One pending __anext__ across pings: a new one after a ping would lose the item the first waits
42
+ # for.
43
+ async def paced[T](coming: AsyncIterator[T], every_s: float = PING_S) -> AsyncIterator[T | None]:
44
+ """The items as they come, and None after every quiet stretch."""
45
+ pending: asyncio.Task[T] | None = None
46
+ try:
47
+ while True:
48
+ if pending is None:
49
+ pending = asyncio.ensure_future(anext(coming))
50
+ done, _ = await asyncio.wait({pending}, timeout=every_s)
51
+ if not done:
52
+ yield None
53
+ continue
54
+ finished, pending = pending, None
55
+ try:
56
+ yield finished.result()
57
+ except StopAsyncIteration:
58
+ return
59
+ finally:
60
+ if pending is not None:
61
+ pending.cancel()
62
+
63
+
64
+ async def until[T](closing: asyncio.Event, coming: AsyncIterator[T]) -> AsyncIterator[T]:
65
+ """The items until they end or the process starts closing."""
66
+ stopping = asyncio.ensure_future(closing.wait())
67
+ pending: asyncio.Task[T] | None = None
68
+ try:
69
+ while True:
70
+ if pending is None:
71
+ pending = asyncio.ensure_future(anext(coming))
72
+ await asyncio.wait({pending, stopping}, return_when=asyncio.FIRST_COMPLETED)
73
+ if not pending.done():
74
+ return
75
+ finished, pending = pending, None
76
+ try:
77
+ yield finished.result()
78
+ except StopAsyncIteration:
79
+ return
80
+ finally:
81
+ stopping.cancel()
82
+ if pending is not None:
83
+ pending.cancel()