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,117 @@
1
+ """A call whose worker went away mid-call: its caller told once, a call back offered, drained."""
2
+
3
+ import logging
4
+
5
+ from livekit import api
6
+ from livekit.protocol.webhook import WebhookEvent
7
+
8
+ from pinecall.channels import rooms
9
+ from pinecall.channels.offers import Offering
10
+ from pinecall.channels.rooms import Dispatch
11
+ from pinecall.domain.names import Env
12
+ from pinecall.fleet import worlds
13
+ from pinecall.gateway._served import Serving
14
+ from pinecall.log import queries
15
+ from pinecall.log.queries import CallScope
16
+ from pinecall.wire.events import CallbackRequested, CallEnded
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ LEFT = "participant_left"
21
+
22
+ # The reasons livekit gives when a participant's connection was lost rather than closed: a worker
23
+ # killed, a machine gone, a job process that died. A worker that ends a call, drains or hands it on
24
+ # leaves CLIENT_INITIATED; a room deleted says so. livekit-server pkg/rtc/types ToDisconnectReason.
25
+ LOST = frozenset(
26
+ {
27
+ api.DisconnectReason.SIGNAL_CLOSE,
28
+ api.DisconnectReason.CONNECTION_TIMEOUT,
29
+ api.DisconnectReason.STATE_MISMATCH,
30
+ api.DisconnectReason.JOIN_FAILURE,
31
+ api.DisconnectReason.MEDIA_FAILURE,
32
+ api.DisconnectReason.AGENT_ERROR,
33
+ }
34
+ )
35
+
36
+ CALLBACK = "callback.requested"
37
+
38
+ STRANDED = "call %s: its worker went away (%s); the caller is told once and the call ends drained"
39
+
40
+
41
+ # The end is written first and only once: it is what tells a second delivery of the same event,
42
+ # the worker's own late end and the told job's own leaving that there is nothing left to do. The
43
+ # room is asked of the LiveKit of the world that sent the event.
44
+ async def stranded(
45
+ serving: Serving, offering: Offering, world: Env, event: WebhookEvent
46
+ ) -> str | None:
47
+ """End the call whose agent the event says was lost, and send its fleet to tell the caller."""
48
+ server = offering.servers[world]
49
+ if not _an_agent_lost(event):
50
+ return None
51
+ call = event.room.name
52
+ kept = await queries.scope_of_call(serving.connections.pool, call)
53
+ if kept is None or kept.sealed or kept.scope is None:
54
+ return None
55
+ if not await rooms.left_alone(server, call):
56
+ return None
57
+ if not await _ended(serving, call, kept):
58
+ return None
59
+ await _call_back_offered(serving, call, kept.agent)
60
+ scope = kept.scope
61
+ fleet = worlds.fleet_of(await worlds.fleets(serving.connections.pool), scope.env)
62
+ dispatch = Dispatch(
63
+ agent=kept.agent,
64
+ org=scope.org,
65
+ env=scope.env,
66
+ holder=scope.holder or None,
67
+ worker_gone=True,
68
+ entries_written=kept.written,
69
+ )
70
+ await offering.offer(call, fleet, dispatch)
71
+ reason = api.DisconnectReason.Name(event.participant.disconnect_reason)
72
+ logger.warning(STRANDED, call, reason)
73
+ return call
74
+
75
+
76
+ def _an_agent_lost(event: WebhookEvent) -> bool:
77
+ return (
78
+ event.event == LEFT
79
+ and event.participant.kind == api.ParticipantInfo.Kind.AGENT
80
+ and event.participant.disconnect_reason in LOST
81
+ and bool(event.room.name)
82
+ )
83
+
84
+
85
+ # A log this process does not serve (it restarted since the call opened) is written and let go,
86
+ # as the reaper does: the told job's own entries then find no call here, and the reaper seals it.
87
+ async def _ended(serving: Serving, call: str, kept: CallScope) -> bool:
88
+ now = serving.logs.store.clock()
89
+ started = now if kept.started_at is None else kept.started_at
90
+ ended = CallEnded(
91
+ reason="drained",
92
+ ended_by="platform",
93
+ ended_at=now,
94
+ duration_s=max(now - started, 0.0),
95
+ )
96
+ served = serving.live.calls.get(call)
97
+ if served is not None:
98
+ return await served.log.append_first("call.ended", ended.written()) is not None
99
+ log = serving.logs.writing(call, kept.agent)
100
+ try:
101
+ return await log.append_first("call.ended", ended.written()) is not None
102
+ finally:
103
+ serving.logs.forget(call)
104
+
105
+
106
+ # Only a phone has a number to call back: the caller's, or on an outbound call the one dialled.
107
+ async def _call_back_offered(serving: Serving, call: str, agent: str) -> None:
108
+ facts = (await queries.facts_of_calls(serving.connections.pool, [call])).get(call)
109
+ if facts is None or facts.channel != "phone":
110
+ return
111
+ number = facts.from_number if facts.direction == "inbound" else facts.to_number
112
+ if not number:
113
+ return
114
+ wanted = CallbackRequested(
115
+ channel="phone", number=number, via="overflow", call=call, contact=None
116
+ )
117
+ await serving.logs.agent(agent).append(CALLBACK, wanted.written())
File without changes
@@ -0,0 +1,74 @@
1
+ """A stage's seconds as a fixed histogram: counted in buckets, added across calls, read by rank."""
2
+
3
+ import math
4
+ from collections.abc import Iterable, Sequence
5
+
6
+ # Each bucket is 10 % wider than the one below it, from 10 ms to past a minute: a rank read off a
7
+ # bucket's geometric middle is within 5 % of the true value, and two histograms add bucket by
8
+ # bucket, which exact percentiles cannot.
9
+ SMALLEST_S = 0.01
10
+
11
+
12
+ GROWTH = 1.1
13
+
14
+
15
+ # Under 10 ms, then 92 buckets growing by GROWTH up to 63 s, then everything slower.
16
+ BUCKETS = 94
17
+
18
+
19
+ MEDIAN = 0.5
20
+
21
+
22
+ P95 = 0.95
23
+
24
+
25
+ # Bucket i (1 to BUCKETS - 2) holds [SMALLEST_S * GROWTH ** (i - 1), SMALLEST_S * GROWTH ** i).
26
+ UPPER: tuple[float, ...] = tuple(SMALLEST_S * GROWTH**index for index in range(BUCKETS - 1))
27
+
28
+
29
+ def empty() -> list[int]:
30
+ """A histogram that counted nothing."""
31
+ return [0] * BUCKETS
32
+
33
+
34
+ def counted(values: Iterable[float]) -> list[int]:
35
+ """A histogram of these times; a negative one (livekit's -1 for none) is not counted."""
36
+ buckets = empty()
37
+ for seconds in values:
38
+ if seconds >= 0:
39
+ buckets[_bucket_of(seconds)] += 1
40
+ return buckets
41
+
42
+
43
+ def added(first: Sequence[int], second: Sequence[int]) -> list[int]:
44
+ """Two histograms as one."""
45
+ return [mine + theirs for mine, theirs in zip(first, second, strict=True)]
46
+
47
+
48
+ # Nearest rank: the k-th smallest time, k = ceil(q * n), read as its bucket's middle.
49
+ def rank(buckets: Sequence[int], quantile: float) -> float | None:
50
+ """The time at this quantile, within 5 %; None for a histogram that counted nothing."""
51
+ total = sum(buckets)
52
+ if total == 0:
53
+ return None
54
+ wanted = max(1, math.ceil(quantile * total))
55
+ seen = 0
56
+ for index, count in enumerate(buckets):
57
+ seen += count
58
+ if seen >= wanted:
59
+ return _middle(index)
60
+ return _middle(BUCKETS - 1)
61
+
62
+
63
+ def _middle(index: int) -> float:
64
+ if index == 0:
65
+ return SMALLEST_S / 2
66
+ if index == BUCKETS - 1:
67
+ return UPPER[-1]
68
+ return math.sqrt(UPPER[index - 1] * UPPER[index])
69
+
70
+
71
+ def _bucket_of(seconds: float) -> int:
72
+ if seconds < SMALLEST_S:
73
+ return 0
74
+ return min(BUCKETS - 1, 1 + math.floor(math.log(seconds / SMALLEST_S, GROWTH)))
pinecall/log/_relay.py ADDED
@@ -0,0 +1,259 @@
1
+ """The relay: what any gateway wrote, delivered to this process's live readers in seq order."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from uuid import uuid4
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from pinecall.domain.errors import NotAvailable
12
+ from pinecall.log.store import Store, log_name
13
+ from pinecall.postgres.pool import box_task
14
+ from pinecall.process.signal import Listening, Signal
15
+ from pinecall.wire.frames import Entry
16
+
17
+ # Each log has a channel of its own, so a gateway hears only the logs something local reads;
18
+ # an org's feed in a world has one, and the box's floor one, followed without a sequencer: a
19
+ # feed spans calls and is live-only, each entry carrying its log and seq for a reader that
20
+ # needs a call's order.
21
+ LOG_PREFIX = "log:"
22
+ FEED_PREFIX = "feed:"
23
+ CHANNEL = "log:{name}"
24
+ FEED_CHANNEL = "feed:{org}:{env}"
25
+ BOX_CHANNEL = "box"
26
+
27
+ # A durable entry over this many bytes travels as its address, and the hearer reads it back.
28
+ STUB_OVER_BYTES = 64 * 1024
29
+
30
+ # How long an entry that arrived ahead of its turn waits for the ones before it, before the
31
+ # store is asked for them; and how often the store is asked for every followed log while the
32
+ # signal is down, when durable entries still arrive and ephemerals do not.
33
+ FILL_AFTER_S = 0.1
34
+ POLL_S = 1.0
35
+
36
+ # Both are told the channel: a log's (`log:{name}`), an org feed's, or the box's.
37
+ type Delivered = Callable[[str, Entry], None]
38
+ type Dropped = Callable[[str], None]
39
+
40
+
41
+ class Stored(BaseModel):
42
+ """Where a big entry is: the hearer reads it back from the store."""
43
+
44
+ log: str
45
+ seq: int
46
+
47
+
48
+ class Travelled(BaseModel):
49
+ """What a message on a channel carries: an entry whole, or the address of a big one."""
50
+
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+ # The relay that wrote it: its own delivery was local, so it skips it when heard back.
54
+ sender: str
55
+ entry: Entry | None = None
56
+ stored: Stored | None = None
57
+
58
+
59
+ @dataclass
60
+ class Following:
61
+ """One channel this process follows: its readers, the last seq delivered, what came early."""
62
+
63
+ # The channel.
64
+ name: str
65
+ # A log is delivered in seq order; a feed as it comes.
66
+ ordered: bool = True
67
+ readers: int = 1
68
+ last: int = 0
69
+ held: dict[int, Entry] = field(default_factory=dict[int, Entry])
70
+ # Set once the subscription is confirmed, or once the store is polled instead.
71
+ ready: asyncio.Event = field(default_factory=asyncio.Event)
72
+ listening: Listening | None = None
73
+ task: asyncio.Task[None] | None = None
74
+ filling: asyncio.Task[None] | None = None
75
+ closed: bool = False
76
+
77
+
78
+ # Stored before published, and every hearer resumes from the store: a message may be lost, or
79
+ # arrive out of turn, and what a reader is given is still whole and in seq order.
80
+ class Relay:
81
+ """Follows the logs something local reads, and hands each entry on once, in its turn."""
82
+
83
+ def __init__(
84
+ self, store: Store, signal: Signal, delivered: Delivered, dropped: Dropped
85
+ ) -> None:
86
+ """Hand each entry to `delivered`; tell `dropped` when a log's readers must resume."""
87
+ self.store = store
88
+ self.signal = signal
89
+ # Several relays may share one in-process signal: each skips only what it sent itself.
90
+ self.id = uuid4().hex
91
+ self._delivered = delivered
92
+ self._dropped = dropped
93
+ self._following: dict[str, Following] = {}
94
+
95
+ @property
96
+ def followed(self) -> int:
97
+ """How many logs this process follows now."""
98
+ return len(self._following)
99
+
100
+ async def follow(self, name: str, *, ordered: bool = True) -> None:
101
+ """Follow the channel for one more reader; returns once what is published next is heard."""
102
+ following = self._following.get(name)
103
+ if following is None:
104
+ following = self._following[name] = Following(name, ordered=ordered)
105
+ following.task = box_task(self._run(following))
106
+ else:
107
+ following.readers += 1
108
+ await following.ready.wait()
109
+
110
+ def unfollow(self, name: str) -> None:
111
+ """One reader left the log; the last one leaving lets the channel go."""
112
+ following = self._following.get(name)
113
+ if following is None:
114
+ return
115
+ following.readers -= 1
116
+ if following.readers <= 0:
117
+ self._let_go(following)
118
+
119
+ def local(self, channel: str, entry: Entry) -> None:
120
+ """An entry this process wrote: told to every gateway, and delivered here in its turn."""
121
+ self.signal.publish(channel, encoded(entry, self.id))
122
+ following = self._following.get(channel)
123
+ if following is None or not following.ordered or not following.ready.is_set():
124
+ self._delivered(channel, entry)
125
+ else:
126
+ self._sequenced(following, entry)
127
+
128
+ async def close(self) -> None:
129
+ """Let every log go and wait for their tasks."""
130
+ followings = list(self._following.values())
131
+ for following in followings:
132
+ self._let_go(following)
133
+ tasks = [following.task for following in followings if following.task is not None]
134
+ await asyncio.gather(*tasks, return_exceptions=True)
135
+
136
+ async def _run(self, following: Following) -> None:
137
+ try:
138
+ listening = await self.signal.subscribe(following.name)
139
+ except NotAvailable:
140
+ listening = None
141
+ if following.ordered:
142
+ following.last = await self.store.latest_seq(following.name.removeprefix(LOG_PREFIX))
143
+ following.listening = listening
144
+ following.ready.set()
145
+ if following.closed:
146
+ if listening is not None:
147
+ listening.close()
148
+ return
149
+ if listening is None:
150
+ await self._polled(following)
151
+ return
152
+ async for data in listening:
153
+ await self._heard(following, data)
154
+ self._over(following)
155
+
156
+ # The signal is down: a log's durable entries are read off the store each second (a feed
157
+ # spans calls and cannot be, so its readers hear this process alone), until it is back, when
158
+ # the readers are told to resume so the channel is followed again.
159
+ async def _polled(self, following: Following) -> None:
160
+ while not following.closed:
161
+ await asyncio.sleep(POLL_S)
162
+ if following.closed:
163
+ return
164
+ if following.ordered:
165
+ for entry in await self.store.since(
166
+ following.name.removeprefix(LOG_PREFIX), after=following.last
167
+ ):
168
+ if entry.seq > following.last:
169
+ self._handed(following, entry)
170
+ if self.signal.up:
171
+ self._over(following)
172
+ return
173
+
174
+ async def _heard(self, following: Following, data: bytes) -> None:
175
+ travelled = Travelled.model_validate_json(data)
176
+ if travelled.sender == self.id:
177
+ return
178
+ if travelled.entry is not None:
179
+ self._taken(following, travelled.entry)
180
+ elif travelled.stored is not None:
181
+ seq = travelled.stored.seq
182
+ found = await self.store.since(travelled.stored.log, after=seq - 1, limit=1)
183
+ if found and found[0].seq == seq:
184
+ self._taken(following, found[0])
185
+
186
+ def _taken(self, following: Following, entry: Entry) -> None:
187
+ if following.ordered:
188
+ self._sequenced(following, entry)
189
+ elif not following.closed:
190
+ self._delivered(following.name, entry)
191
+
192
+ def _sequenced(self, following: Following, entry: Entry) -> None:
193
+ if following.closed or entry.seq <= following.last:
194
+ return
195
+ if entry.seq == following.last + 1:
196
+ self._handed(following, entry)
197
+ while (queued := following.held.pop(following.last + 1, None)) is not None:
198
+ self._handed(following, queued)
199
+ return
200
+ following.held[entry.seq] = entry
201
+ if following.filling is None:
202
+ following.filling = box_task(self._filled(following))
203
+
204
+ # What is missing before the held ones is read from the store: by the head's lock every
205
+ # durable entry below a seq given out is committed, so a seq not found is an ephemeral, lost.
206
+ async def _filled(self, following: Following) -> None:
207
+ await asyncio.sleep(FILL_AFTER_S)
208
+ try:
209
+ if following.closed or not following.held:
210
+ return
211
+ highest = max(following.held)
212
+ wanted = highest - following.last
213
+ name = following.name.removeprefix(LOG_PREFIX)
214
+ for entry in await self.store.since(name, after=following.last, limit=wanted):
215
+ if following.last < entry.seq <= highest:
216
+ following.held.setdefault(entry.seq, entry)
217
+ for seq in sorted(seq for seq in following.held if seq <= highest):
218
+ entry = following.held.pop(seq)
219
+ if seq > following.last:
220
+ self._handed(following, entry)
221
+ finally:
222
+ following.filling = None
223
+ if following.held and not following.closed:
224
+ following.filling = box_task(self._filled(following))
225
+
226
+ # The channel was lost: this following is over, so a reader that comes back follows a new one
227
+ # (subscribed again) rather than this one, which nobody listens on; its readers are dropped.
228
+ def _over(self, following: Following) -> None:
229
+ if following.closed:
230
+ return
231
+ following.closed = True
232
+ if self._following.get(following.name) is following:
233
+ del self._following[following.name]
234
+ self._dropped(following.name)
235
+
236
+ def _handed(self, following: Following, entry: Entry) -> None:
237
+ following.last = entry.seq
238
+ self._delivered(following.name, entry)
239
+
240
+ def _let_go(self, following: Following) -> None:
241
+ following.closed = True
242
+ self._following.pop(following.name, None)
243
+ if following.listening is not None:
244
+ following.listening.close()
245
+ if following.filling is not None:
246
+ following.filling.cancel()
247
+ if following.task is not None and following.task is not asyncio.current_task():
248
+ following.task.cancel()
249
+
250
+
251
+ # A big entry's address names its own log, which a feed's channel does not.
252
+ def encoded(entry: Entry, sender: str) -> bytes:
253
+ """The message an entry travels as: itself, or its address when it is big and durable."""
254
+ whole = f'{{"sender":{json.dumps(sender)},"entry":{entry.written_json()}}}'
255
+ if len(whole) > STUB_OVER_BYTES and not entry.ephemeral:
256
+ log = log_name(entry.call, entry.agent)
257
+ stub = {"sender": sender, "stored": {"log": log, "seq": entry.seq}}
258
+ return json.dumps(stub, separators=(",", ":")).encode()
259
+ return whole.encode()