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
pinecall/cli/main.py ADDED
@@ -0,0 +1,521 @@
1
+ """pinecall-runtime: the operator's verbs, and the ones the cluster's pods and jobs run."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import sys
7
+ import time
8
+ from collections.abc import Sequence
9
+ from datetime import UTC, datetime
10
+ from importlib.metadata import version
11
+ from pathlib import Path
12
+ from types import FrameType
13
+ from typing import override
14
+ from urllib.parse import urlparse
15
+
16
+ import httpx
17
+ import uvicorn
18
+ from livekit import api
19
+
20
+ from pinecall.channels import offers, whatsapp
21
+ from pinecall.cli import (
22
+ _archive,
23
+ _drift,
24
+ _facts,
25
+ _load,
26
+ _operator,
27
+ _sessions,
28
+ _traceback,
29
+ _usage,
30
+ )
31
+ from pinecall.domain.errors import NotAvailable, PinecallError
32
+ from pinecall.domain.names import DISTRIBUTION
33
+ from pinecall.gateway.app import announce_closing, app, embedder_of
34
+ from pinecall.log import days
35
+ from pinecall.postgres.migrate import (
36
+ apply_migrations,
37
+ granted,
38
+ migration_files,
39
+ migrations_behind,
40
+ )
41
+ from pinecall.postgres.pool import open_pool
42
+ from pinecall.process.connections import closed, keyring_of, opened, servers_of, vault_of
43
+ from pinecall.process.recordings import recordings_of
44
+ from pinecall.process.settings import Settings, load
45
+ from pinecall.providers import catalog, prices
46
+ from pinecall.providers.build import installed
47
+ from pinecall.providers.catalog import Providers
48
+ from pinecall.retrieval import memory
49
+ from pinecall.runner import main as runner
50
+ from pinecall.tenancy import retention, vault
51
+ from pinecall.worker import main as worker
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"})
57
+
58
+
59
+ NOT_LOOPBACK = (
60
+ "PINECALL_GATEWAY_URL binds {host}: a box's gateway listens on loopback, behind Caddy "
61
+ "(a pod's says PINECALL_GATEWAY_LISTEN)"
62
+ )
63
+
64
+
65
+ # Google's load balancer keeps an idle connection to a gateway 600 s and sends the next request
66
+ # down it: the gateway must keep it longer (Google says 620 s), or a request meets a closed socket
67
+ # and the caller a 503.
68
+ KEEP_ALIVE_S = 620
69
+
70
+
71
+ # A stream never ends on its own: told nothing, a stop waits out uvicorn's grace and cuts it.
72
+ class Stopping(uvicorn.Server):
73
+ """uvicorn's server, which tells the open streams when it is told to stop."""
74
+
75
+ @override
76
+ def handle_exit(self, sig: int, frame: FrameType | None) -> None:
77
+ """Tell the streams, then stop as uvicorn does."""
78
+ announce_closing(app)
79
+ super().handle_exit(sig, frame)
80
+
81
+
82
+ def main(argv: Sequence[str] | None = None) -> None:
83
+ """Parse the verb and run it; its exit is the process's."""
84
+ data = verbs().parse_args(argv)
85
+ settings = load()
86
+ logging.basicConfig(
87
+ level=settings.log_level.upper(), format="%(levelname)s %(name)s: %(message)s"
88
+ )
89
+ try:
90
+ sys.exit(data.run(settings, data))
91
+ except PinecallError as refused:
92
+ sys.stderr.write(f"pinecall-runtime: {refused}\n")
93
+ sys.exit(1)
94
+
95
+
96
+ def gateway(settings: Settings, _args: argparse.Namespace) -> int:
97
+ """Serve the gateway: on its URL's loopback on a box, on PINECALL_GATEWAY_LISTEN in a pod."""
98
+ host, port = _listening(settings)
99
+ # The one proxy in front is believed alone: Caddy on loopback, or a cluster's load balancer.
100
+ config = uvicorn.Config(
101
+ app,
102
+ host=host,
103
+ port=port,
104
+ proxy_headers=True,
105
+ forwarded_allow_ips=settings.trusted_proxies,
106
+ timeout_keep_alive=KEEP_ALIVE_S,
107
+ )
108
+ # Every plugin imported before the server listens: imported at the first door that lists the
109
+ # vendors (/metrics, /v1/providers) instead, the 45 held the loop 5 s while requests waited
110
+ # (production, 2026-10-04).
111
+ installed()
112
+ Stopping(config).run()
113
+ return 0
114
+
115
+
116
+ def start(settings: Settings, _args: argparse.Namespace) -> int:
117
+ """A worker of the fleet, until told to stop or cordoned."""
118
+ return asyncio.run(worker.run(settings))
119
+
120
+
121
+ def overflow(settings: Settings, _args: argparse.Namespace) -> int:
122
+ """The fleet's overflow, until told to stop."""
123
+ return asyncio.run(worker.overflow(settings))
124
+
125
+
126
+ def run_apps(settings: Settings, _args: argparse.Namespace) -> int:
127
+ """The world's hosted apps, kept running until told to stop."""
128
+ return asyncio.run(runner.run(settings))
129
+
130
+
131
+ def migrate_up(settings: Settings, _args: argparse.Namespace) -> int:
132
+ """Apply the migrations the database lacks, and say which."""
133
+ applied = asyncio.run(apply_migrations(settings.database_url))
134
+ names = ", ".join(applied.applied) or "nothing: it was up to date"
135
+ sys.stdout.write(f"{applied.database}/{applied.schema}: {names}\n")
136
+ if settings.db_app_role:
137
+ asyncio.run(granted(settings.database_url, settings.db_app_role))
138
+ sys.stdout.write(f"{settings.db_app_role}: every table's rows, no table's shape\n")
139
+ return 0
140
+
141
+
142
+ def migrate_status(settings: Settings, _args: argparse.Namespace) -> int:
143
+ """What the database has not run yet; the exit is 1 while it is behind."""
144
+ behind = asyncio.run(_behind(settings))
145
+ if not behind:
146
+ sys.stdout.write("up to date\n")
147
+ return 0
148
+ sys.stdout.write(f"{len(behind)} behind: {', '.join(behind)}\n")
149
+ return 1
150
+
151
+
152
+ def migrate_plan(_settings: Settings, _args: argparse.Namespace) -> int:
153
+ """Every migration on the disk, in the order a run applies them; no database asked."""
154
+ for path in migration_files():
155
+ sys.stdout.write(f"{path.name}\n")
156
+ return 0
157
+
158
+
159
+ def providers(settings: Settings, args: argparse.Namespace) -> int:
160
+ """Every vendor this build runs, what it does, and whether the box holds its key."""
161
+ with_a_key = asyncio.run(_box_vendors(settings))
162
+ rows = sorted(installed().values(), key=lambda vendor: vendor.name)
163
+ for vendor in rows:
164
+ does = sorted(vendor.does)
165
+ if args.does is not None and args.does not in does:
166
+ continue
167
+ state = "broken" if vendor.broken else ("ready" if vendor.name in with_a_key else "no key")
168
+ sys.stdout.write(f"{vendor.name:16} {','.join(does):12} {state}\n")
169
+ sys.stdout.write(f"{len(rows)} vendors\n")
170
+ return 0
171
+
172
+
173
+ # Once: a box that has a row is edited from the console, and a second seed is refused.
174
+ def providers_seed(settings: Settings, args: argparse.Namespace) -> int:
175
+ """Write the providers row a box starts from, off a JSON file."""
176
+ seeded = Providers.model_validate_json(Path(args.file).read_text(encoding="utf-8"))
177
+ asyncio.run(_seeded(settings, seeded))
178
+ sys.stdout.write("the providers row is written: the console edits it from now on\n")
179
+ return 0
180
+
181
+
182
+ # Shown by default: a file written by mistake replaces what the console set.
183
+ def providers_prices(settings: Settings, args: argparse.Namespace) -> int:
184
+ """What a prices file changes in the box's rates, and with --apply, the rates written."""
185
+ written = prices.rates_from_csv(Path(args.file).read_text(encoding="utf-8"))
186
+ box = asyncio.run(_box_providers(settings))
187
+ change = prices.rates_changed(box.rates, written)
188
+ if args.apply:
189
+ rates = {**box.rates, **written}
190
+ asyncio.run(_configured(settings, box.model_copy(update={"rates": rates})))
191
+ for mark, models in (("+", change.added), ("~", change.changed)):
192
+ sys.stdout.writelines(f"{mark} {model}\n" for model in models)
193
+ sys.stdout.write(
194
+ f"{len(change.added)} new, {len(change.changed)} changed, {len(change.unchanged)} the "
195
+ f"same, {len(change.only_on_the_box)} only on the box and kept\n"
196
+ )
197
+ done = "written" if args.apply else "nothing written: --apply writes them"
198
+ sys.stdout.write(f"{done}\n")
199
+ return 0
200
+
201
+
202
+ def memory_reembed(settings: Settings, _args: argparse.Namespace) -> int:
203
+ """Every fact another model embedded, embedded again by the box's; how many there were."""
204
+ count = asyncio.run(_reembedded(settings))
205
+ sys.stdout.write(f"{count} facts re-embedded\n")
206
+ return 0
207
+
208
+
209
+ def retention_due(settings: Settings, _args: argparse.Namespace) -> int:
210
+ """The sealed calls the next run would erase, one line each, and how many."""
211
+ calls = asyncio.run(_due(settings))
212
+ for call in calls:
213
+ sys.stdout.write(f"{call.call} {call.scope.org} {call.scope.env}\n")
214
+ sys.stdout.write(f"{len(calls)} calls past their org's days\n")
215
+ return 0
216
+
217
+
218
+ def retention_run(settings: Settings, _args: argparse.Namespace) -> int:
219
+ """Erase every sealed call past its org's days, forget what is kept for a time; how many."""
220
+ erased, records, dials, seen, kept = asyncio.run(_purged(settings))
221
+ sys.stdout.write(f"{len(erased)} calls erased past their org's days\n")
222
+ sys.stdout.write(f"{records} call records and {dials} dials forgotten past 24 months\n")
223
+ sys.stdout.write(f"{seen} WhatsApp message ids forgotten past Meta's 7 days of retries\n")
224
+ sys.stdout.write(
225
+ f"days of the log: {len(kept.made)} made ahead, {len(kept.dropped)} dropped once empty\n"
226
+ )
227
+ for day in kept.refused:
228
+ sys.stdout.write(f"{day} not made: {days.DEFAULT} holds rows of it\n")
229
+ return 0
230
+
231
+
232
+ # Nothing is re-sealed that no key opens: that row is named, and the old key stays until it is 0.
233
+ def vault_rotate(settings: Settings, _args: argparse.Namespace) -> int:
234
+ """Every sealed value re-sealed under PINECALL_VAULT_KEY's first key; 1 while one opens not."""
235
+ columns = asyncio.run(_rotated(settings))
236
+ for column in columns:
237
+ sys.stdout.write(
238
+ f"{column.column.named}: {column.resealed} re-sealed, {column.current} under the "
239
+ f"first key already, {column.unopened} opened by no key listed\n"
240
+ )
241
+ return 0 if all(column.unopened == 0 for column in columns) else 1
242
+
243
+
244
+ def doctor(settings: Settings, _args: argparse.Namespace) -> int:
245
+ """Each thing the box needs, a line each; the exit is 1 when one is missing."""
246
+ lines = asyncio.run(_examined(settings))
247
+ for name, trouble, state in lines:
248
+ text = "ok" if trouble is None else "NO"
249
+ why = f": {trouble}" if trouble is not None else (f": {state}" if state else "")
250
+ sys.stdout.write(f"{text} {name}{why}\n")
251
+ return 0 if all(trouble is None for _, trouble, _ in lines) else 1
252
+
253
+
254
+ def verbs() -> argparse.ArgumentParser:
255
+ """The parser of every group and verb, each bound to the function that runs it."""
256
+ verbs = argparse.ArgumentParser(prog="pinecall-runtime")
257
+ verbs.add_argument("--version", action="version", version=version(DISTRIBUTION))
258
+ under = verbs.add_subparsers(required=True)
259
+ under.add_parser("gateway", help="the gateway, both worlds").set_defaults(run=gateway)
260
+ worker_verbs = under.add_parser("worker", help="a worker of the fleet PINECALL_FLEET names")
261
+ of_worker = worker_verbs.add_subparsers(required=True)
262
+ of_worker.add_parser("start", help="answer the fleet's calls").set_defaults(run=start)
263
+ of_worker.add_parser("overflow", help="answer when the fleet is full").set_defaults(
264
+ run=overflow
265
+ )
266
+ runner_verbs = under.add_parser(
267
+ "runner", help="the runner of the world PINECALL_RUNNER_KEY opens"
268
+ )
269
+ of_runner = runner_verbs.add_subparsers(required=True)
270
+ of_runner.add_parser("start", help="keep the world's hosted apps running").set_defaults(
271
+ run=run_apps
272
+ )
273
+ migrate = under.add_parser("migrate", help="the schema").add_subparsers(required=True)
274
+ migrate.add_parser("up", help="apply what the database lacks").set_defaults(run=migrate_up)
275
+ migrate.add_parser("status", help="what the database lacks").set_defaults(run=migrate_status)
276
+ migrate.add_parser("plan", help="every migration, off the disk").set_defaults(run=migrate_plan)
277
+ _operator.keys_group(under.add_parser("keys", help="keys"))
278
+ under.add_parser("doctor", help="what this box lacks").set_defaults(run=doctor)
279
+ vault_verbs = under.add_parser("vault", help="the key every secret is sealed under")
280
+ vault_verbs.add_subparsers(required=True).add_parser(
281
+ "rotate", help="every sealed value re-sealed under the first key"
282
+ ).set_defaults(run=vault_rotate)
283
+ vendors = under.add_parser(
284
+ "providers", help="the vendors this build runs, and the row"
285
+ ).add_subparsers(required=True)
286
+ listing = vendors.add_parser("list", help="every vendor this build runs")
287
+ listing.add_argument("--does", choices=("llm", "stt", "tts"), default=None)
288
+ listing.set_defaults(run=providers)
289
+ seed = vendors.add_parser("seed", help="the providers row a box starts from, once")
290
+ seed.add_argument("file", help="a JSON file of the row")
291
+ seed.set_defaults(run=providers_seed)
292
+ pricing = vendors.add_parser("prices", help="the rates a prices file sets, shown or written")
293
+ pricing.add_argument("file", help="a CSV: vendor,model,unit,usd,as_of,source")
294
+ pricing.add_argument("--apply", action="store_true", help="write them into the box's row")
295
+ pricing.set_defaults(run=providers_prices)
296
+ memory_verbs = under.add_parser("memory", help="contact memory").add_subparsers(required=True)
297
+ memory_verbs.add_parser("reembed", help="every fact under the box's embedder").set_defaults(
298
+ run=memory_reembed
299
+ )
300
+ kept = under.add_parser("retention", help="the calls past their org's days")
301
+ kept_verbs = kept.add_subparsers(required=True)
302
+ kept_verbs.add_parser("due", help="what the next run would erase").set_defaults(
303
+ run=retention_due
304
+ )
305
+ kept_verbs.add_parser("run", help="erase them; a CronJob runs it nightly").set_defaults(
306
+ run=retention_run
307
+ )
308
+ _sessions.sessions_group(under.add_parser("sessions", help="the log, off Postgres"))
309
+ _facts.facts_group(under.add_parser("facts", help="each call's facts, folded from its log"))
310
+ _drift.drift_group(under.add_parser("drift", help="each day's drift, counted at the seal"))
311
+ _usage.usage_group(under.add_parser("usage", help="each org's usage totals, from its log"))
312
+ _operator.fence_group(under.add_parser("fence", help="the networks 5060 opens to"))
313
+ _operator.sip_group(under.add_parser("sip", help="where the carriers send each world's calls"))
314
+ _traceback.traceback_verb(
315
+ under.add_parser("traceback", help="a number's calls and dials, for a carrier")
316
+ )
317
+ _operator.init_group(under.add_parser("init", help="the first org and person, on a fresh box"))
318
+ _operator.orgs_group(under.add_parser("orgs", help="the tenants"))
319
+ _operator.routes_group(under.add_parser("routes", help="which agent answers a number"))
320
+ _operator.fleet_group(under.add_parser("fleet", help="the workers heard from"))
321
+ _load.load_verb(
322
+ under.add_parser("load", help="synthetic calls held against a sandbox, and measured")
323
+ )
324
+ return verbs
325
+
326
+
327
+ # A box's gateway binds loopback and nothing else, Caddy being its one door; a pod's binds what
328
+ # PINECALL_GATEWAY_LISTEN names, the cluster's load balancer being its door.
329
+ def _listening(settings: Settings) -> tuple[str, int]:
330
+ if settings.gateway_listen is not None:
331
+ host, _, port = settings.gateway_listen.rpartition(":")
332
+ return host.strip("[]"), int(port)
333
+ bound = urlparse(settings.gateway_url)
334
+ host, port = bound.hostname or "127.0.0.1", bound.port or 8080
335
+ if host not in LOOPBACK:
336
+ raise PinecallError(NOT_LOOPBACK.format(host=host))
337
+ return host, port
338
+
339
+
340
+ async def _behind(settings: Settings) -> tuple[str, ...]:
341
+ pool = await open_pool(settings.database_url)
342
+ try:
343
+ return await migrations_behind(pool)
344
+ finally:
345
+ await pool.close()
346
+
347
+
348
+ async def _box_vendors(settings: Settings) -> frozenset[str]:
349
+ pool = await open_pool(settings.database_url)
350
+ try:
351
+ return frozenset(await vault.box_credentials(pool, vault_of(settings.vault_key)))
352
+ finally:
353
+ await pool.close()
354
+
355
+
356
+ async def _seeded(settings: Settings, seeded: Providers) -> None:
357
+ pool = await open_pool(settings.database_url)
358
+ try:
359
+ await catalog.seed(pool, seeded)
360
+ finally:
361
+ await pool.close()
362
+
363
+
364
+ async def _box_providers(settings: Settings) -> Providers:
365
+ pool = await open_pool(settings.database_url)
366
+ try:
367
+ return await catalog.providers(pool)
368
+ finally:
369
+ await pool.close()
370
+
371
+
372
+ async def _configured(settings: Settings, edited: Providers) -> None:
373
+ pool = await open_pool(settings.database_url)
374
+ try:
375
+ await catalog.configure(pool, edited)
376
+ finally:
377
+ await pool.close()
378
+
379
+
380
+ async def _reembedded(settings: Settings) -> int:
381
+ async with opened(settings) as connections:
382
+ embedder = await embedder_of(connections)
383
+ if embedder is None:
384
+ raise NotAvailable("this box embeds nothing: no embedding in its providers row")
385
+ return await memory.reembed(connections.pool, embedder)
386
+
387
+
388
+ # The database and the vault's keys: no LiveKit, no HTTP.
389
+ async def _rotated(settings: Settings) -> list[vault.Resealing]:
390
+ keyring = keyring_of(settings.vault_key)
391
+ pool = await open_pool(settings.database_url)
392
+ try:
393
+ return await vault.resealed(pool, keyring)
394
+ finally:
395
+ await pool.close()
396
+
397
+
398
+ # The database alone: no vault, no LiveKit, so the nightly unit is handed nothing else.
399
+ async def _due(settings: Settings) -> list[retention.Due]:
400
+ pool = await open_pool(settings.database_url)
401
+ try:
402
+ return await retention.due(pool, time.time())
403
+ finally:
404
+ await pool.close()
405
+
406
+
407
+ async def _purged(settings: Settings) -> tuple[list[str], int, int, int, days.Kept]:
408
+ pool = await open_pool(settings.database_url)
409
+ now = time.time()
410
+ try:
411
+ async with httpx.AsyncClient() as http:
412
+ erased = await retention.purge(pool, recordings_of(settings, http), now)
413
+ records = await retention.forget_records(pool, now)
414
+ dials = await retention.forget_dials(pool, now)
415
+ seen = await whatsapp.forget_seen(pool, now)
416
+ return erased, records, dials, seen, await days.kept(pool, now)
417
+ finally:
418
+ await pool.close()
419
+
420
+
421
+ async def _examined(settings: Settings) -> list[tuple[str, str | None, str]]:
422
+ return [
423
+ ("vault", _vault(settings), ""),
424
+ ("database", await _database(settings), ""),
425
+ ("facts", await _facts.examined(settings), ""),
426
+ ("days", await _days(settings), ""),
427
+ ("offers", await _offers(settings), ""),
428
+ ("archive", *await _archived(settings)),
429
+ ("livekit", await _livekit(settings), ""),
430
+ ("gateway", await _gateway(settings), ""),
431
+ ]
432
+
433
+
434
+ async def _days(settings: Settings) -> str | None:
435
+ try:
436
+ pool = await open_pool(settings.database_url)
437
+ except PinecallError as refused:
438
+ return str(refused)
439
+ try:
440
+ return await days.examined(pool, time.time())
441
+ finally:
442
+ await pool.close()
443
+
444
+
445
+ async def _offers(settings: Settings) -> str | None:
446
+ try:
447
+ pool = await open_pool(settings.database_url)
448
+ except PinecallError as refused:
449
+ return str(refused)
450
+ try:
451
+ return await offers.examined(pool, time.time())
452
+ finally:
453
+ await pool.close()
454
+
455
+
456
+ def _vault(settings: Settings) -> str | None:
457
+ try:
458
+ vault_of(settings.vault_key)
459
+ except PinecallError as refused:
460
+ return str(refused)
461
+ return None
462
+
463
+
464
+ async def _database(settings: Settings) -> str | None:
465
+ try:
466
+ pool = await open_pool(settings.database_url)
467
+ except PinecallError as refused:
468
+ return str(refused)
469
+ try:
470
+ behind = await migrations_behind(pool)
471
+ finally:
472
+ await pool.close()
473
+ return f"{len(behind)} migrations behind: {', '.join(behind)}" if behind else None
474
+
475
+
476
+ async def _archived(settings: Settings) -> tuple[str | None, str]:
477
+ try:
478
+ pool = await open_pool(settings.database_url)
479
+ except PinecallError as refused:
480
+ return str(refused), ""
481
+ try:
482
+ archive = await _archive.archive_of(pool, settings.wal_spool)
483
+ finally:
484
+ await pool.close()
485
+ return _archive.archive_finding(archive, datetime.now(UTC))
486
+
487
+
488
+ # Each world's LiveKit is asked, and the one that does not answer is named.
489
+ async def _livekit(settings: Settings) -> str | None:
490
+ try:
491
+ servers = servers_of(settings)
492
+ except PinecallError as refused:
493
+ return str(refused)
494
+ failed: list[str] = []
495
+ try:
496
+ for world, server in servers.items():
497
+ refused = await _refused_by(server)
498
+ if refused is not None:
499
+ failed.append(f"{world} ({settings.livekit_url_of(world)}): {refused}")
500
+ finally:
501
+ await closed(servers)
502
+ return "; ".join(failed) or None
503
+
504
+
505
+ async def _refused_by(server: api.LiveKitAPI) -> str | None:
506
+ try:
507
+ await server.room.list_rooms(api.ListRoomsRequest())
508
+ except (api.TwirpError, OSError) as refused:
509
+ return str(refused)
510
+ return None
511
+
512
+
513
+ async def _gateway(settings: Settings) -> str | None:
514
+ try:
515
+ async with httpx.AsyncClient(timeout=5.0) as http:
516
+ answer = await http.get(f"{settings.gateway_url}/v1/docs")
517
+ except httpx.HTTPError as unreachable:
518
+ return str(unreachable)
519
+ return (
520
+ None if answer.status_code < httpx.codes.BAD_REQUEST else f"answered {answer.status_code}"
521
+ )
File without changes