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,73 @@
1
+ """The fleet doors: a worker's heartbeat, and LiveKit's word on a room's people."""
2
+
3
+ import time
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Query, Request
7
+
8
+ from pinecall.channels import rooms
9
+ from pinecall.channels.telephony.hand_over import unbridged
10
+ from pinecall.domain.errors import NotAllowed
11
+ from pinecall.domain.names import PRODUCTION, Env
12
+ from pinecall.fleet import worlds
13
+ from pinecall.fleet.demand import Line, wanted_scaled
14
+ from pinecall.gateway._deps import FleetKey, GatewayDep, webhook_body
15
+ from pinecall.gateway.dispatching.arrivals import arrived, settled
16
+ from pinecall.gateway.ending.stranded import stranded
17
+ from pinecall.wire.rest.fleet import HeartbeatRequest, HeartbeatResponse
18
+ from pinecall.wire.rest.ops import FleetDemand
19
+
20
+ router = APIRouter()
21
+
22
+ UNSIGNED = "the event carries no Authorization: LiveKit signs every event it sends"
23
+
24
+
25
+ @router.post("/v1/fleet/heartbeat")
26
+ async def heartbeat(
27
+ body: HeartbeatRequest, _key: FleetKey, gateway: GatewayDep
28
+ ) -> HeartbeatResponse:
29
+ """A worker's report; the answer says whether it is cordoned and its fleet full."""
30
+ return gateway.roster.report(body, time.time())
31
+
32
+
33
+ # KEDA's metrics-api scaler reads `wanted` and keeps the scaled Deployment at it (infra/charts),
34
+ # asking with its world's fleet key, which names the fleet: one number needs no operator's key.
35
+ @router.get("/v1/fleet/wanted")
36
+ async def wanted_workers(
37
+ key: FleetKey,
38
+ gateway: GatewayDep,
39
+ scaled: Annotated[str, Query(min_length=1)],
40
+ seats: Annotated[int, Query(gt=0)],
41
+ most: Annotated[int, Query(gt=0)] = Line.at_most,
42
+ ) -> FleetDemand:
43
+ """How many workers whose names start with `scaled` the fleet wants, each of `seats` seats."""
44
+ fleet = worlds.fleet_of(await worlds.fleets(gateway.connections.pool), key.env)
45
+ line = Line(at_most=most, seats_per_worker=seats)
46
+ now = time.time()
47
+ wanted, active, capacity = wanted_scaled(gateway.roster.of(fleet, now), scaled, line, now)
48
+ return FleetDemand(fleet=fleet, wanted=wanted, active=active, seats=capacity)
49
+
50
+
51
+ # livekit sends its token bare in Authorization, and every room event of the box: all but an
52
+ # agent lost mid-call are answered and let be. Each world's LiveKit names its world in the URL
53
+ # its config sends to, so the room is asked of the LiveKit it lives on.
54
+ @router.post("/v1/livekit/webhook", status_code=204)
55
+ async def receive_livekit_event(
56
+ request: Request, gateway: GatewayDep, world: Annotated[Env, Query()] = PRODUCTION
57
+ ) -> None:
58
+ """A room event LiveKit signed: a caller alone is offered a worker; an agent lost, told."""
59
+ # In this order: a worker gone sends the sentence, a caller alone is offered a worker, a room
60
+ # an agent joined or that ended is let go, and a room a hand-over bridges goes with either of
61
+ # its legs; each event moves one of the four.
62
+ token = request.headers.get("Authorization")
63
+ if not token:
64
+ raise NotAllowed(UNSIGNED)
65
+ settings = gateway.connections.settings
66
+ body = (await webhook_body(request)).decode()
67
+ event = rooms.livekit_event(
68
+ body, token, settings.livekit_api_key or "", settings.livekit_api_secret or ""
69
+ )
70
+ await stranded(gateway.serving, gateway.offering, world, event)
71
+ await arrived(gateway.offering, world, event)
72
+ await settled(gateway.offering, event)
73
+ await unbridged(gateway.offering.servers[world], event)
@@ -0,0 +1,241 @@
1
+ """The hosting doors: the apps the box hosts for an org, their releases, and the org's secrets."""
2
+
3
+ import asyncio
4
+ from datetime import UTC, date, datetime, timedelta
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Query, Request, Response
8
+
9
+ from pinecall.domain.errors import DeclarationRefused
10
+ from pinecall.domain.names import parse_env
11
+ from pinecall.gateway._deps import Acting, AppKey, GatewayDep, asked_by, capped_body
12
+ from pinecall.postgres.pool import Pool
13
+ from pinecall.tenancy import admission, hosted_running, hosting, org_secrets
14
+ from pinecall.tenancy.hosted_running import Served
15
+ from pinecall.tenancy.hosting import HostedApp, Release
16
+ from pinecall.tenancy.org_secrets import Secret
17
+ from pinecall.wire.rest.hosting import (
18
+ AppLogsResponse,
19
+ HostedAppList,
20
+ HostedAppRow,
21
+ PutSecretRequest,
22
+ ReleaseList,
23
+ ReleaseRow,
24
+ RollbackRequest,
25
+ SecretList,
26
+ SecretRow,
27
+ ServedPage,
28
+ ServedRow,
29
+ )
30
+
31
+ router = APIRouter()
32
+
33
+
34
+ GZIP = "application/gzip"
35
+
36
+
37
+ ROLLED_BACK = "rollback to release {release}"
38
+
39
+
40
+ NOT_A_MONTH = "{month!r} is not a month: YYYY-MM, like 2026-09"
41
+
42
+
43
+ @router.get("/v1/hosted")
44
+ async def list_hosted_apps(key: AppKey, gateway: GatewayDep) -> HostedAppList:
45
+ """The apps the box hosts for the org in this world, by name."""
46
+ listed = await hosting.apps_of(gateway.connections.pool, key.org, key.env)
47
+ return HostedAppList(
48
+ apps=[
49
+ HostedAppRow(
50
+ name=app.name,
51
+ release=app.release,
52
+ live_release=app.live_release,
53
+ failed_why=app.failed_why,
54
+ stopped=app.stopped,
55
+ created_by=app.created_by,
56
+ created_at=app.created_at.timestamp(),
57
+ )
58
+ for app in listed
59
+ ]
60
+ )
61
+
62
+
63
+ # The body is the tarball, no multipart; the note rides the query. An app's first release is
64
+ # what makes it: counted against the org's quota, and given the token its process will run on.
65
+ @router.post("/v1/hosted/{name}/releases")
66
+ async def upload_release(
67
+ name: str,
68
+ request: Request,
69
+ key: AppKey,
70
+ gateway: GatewayDep,
71
+ note: Annotated[str, Query(max_length=200)] = "",
72
+ ) -> ReleaseRow:
73
+ """The project's sources as the app's next release."""
74
+ pool = gateway.connections.pool
75
+ app = HostedApp(org=key.org, env=key.env, name=hosting.checked_name(name))
76
+ source = await asyncio.to_thread(
77
+ hosting.checked_source, await capped_body(request, hosting.LARGEST_SOURCE)
78
+ )
79
+ author = asked_by(key)
80
+ if not await hosting.is_hosted(pool, app):
81
+ hosted = len(await hosting.apps_of(pool, key.org, key.env))
82
+ await admission.admit_hosted_app(pool, key.org, key.env, hosting=hosted)
83
+ await hosting.open_app(pool, gateway.connections.vault, app, created_by=author)
84
+ kept = await hosting.keep_release(pool, app, source, author=author, note=note.strip())
85
+ return _release_row(app, kept)
86
+
87
+
88
+ @router.get("/v1/hosted/{name}/releases")
89
+ async def list_releases(name: str, key: AppKey, gateway: GatewayDep) -> ReleaseList:
90
+ """The app's releases, newest first."""
91
+ app = HostedApp(org=key.org, env=key.env, name=name)
92
+ kept = await hosting.releases_of(gateway.connections.pool, app)
93
+ return ReleaseList(releases=[_release_row(app, release) for release in kept])
94
+
95
+
96
+ @router.get("/v1/hosted/{name}/releases/{release}/source")
97
+ async def release_source(name: str, release: int, key: AppKey, gateway: GatewayDep) -> Response:
98
+ """The tarball one release was uploaded as."""
99
+ app = HostedApp(org=key.org, env=key.env, name=name)
100
+ source = await hosting.source_of(gateway.connections.pool, app, release)
101
+ return Response(source.data, media_type=GZIP)
102
+
103
+
104
+ @router.post("/v1/hosted/{name}/stop", status_code=204)
105
+ async def stop_hosted_app(name: str, key: AppKey, gateway: GatewayDep) -> None:
106
+ """Stop running the app: its process drains, and its releases and token stay."""
107
+ app = HostedApp(org=key.org, env=key.env, name=name)
108
+ await hosted_running.stop_app(gateway.connections.pool, app, by=asked_by(key))
109
+
110
+
111
+ @router.post("/v1/hosted/{name}/start", status_code=204)
112
+ async def start_hosted_app(name: str, key: AppKey, gateway: GatewayDep) -> None:
113
+ """Run a stopped app again, its newest release."""
114
+ app = HostedApp(org=key.org, env=key.env, name=name)
115
+ await hosted_running.start_app(gateway.connections.pool, app)
116
+
117
+
118
+ @router.post("/v1/hosted/{name}/rollback")
119
+ async def roll_back_release(
120
+ name: str, body: RollbackRequest, key: AppKey, gateway: GatewayDep
121
+ ) -> ReleaseRow:
122
+ """An earlier release's sources kept again as the app's next release."""
123
+ pool = gateway.connections.pool
124
+ app = HostedApp(org=key.org, env=key.env, name=name)
125
+ source = await hosting.source_of(pool, app, body.release)
126
+ note = ROLLED_BACK.format(release=body.release)
127
+ kept = await hosting.keep_release(pool, app, source, author=asked_by(key), note=note)
128
+ return _release_row(app, kept)
129
+
130
+
131
+ # The runner sends them on its next beat: a first ask answers what it sent last, or nothing.
132
+ @router.get("/v1/hosted/{name}/logs")
133
+ async def app_logs(name: str, key: AppKey, gateway: GatewayDep) -> AppLogsResponse:
134
+ """The last lines of the app's process, and the runner told to send them again."""
135
+ app = HostedApp(org=key.org, env=key.env, name=name)
136
+ logs = await hosted_running.ask_for_logs(gateway.connections.pool, app)
137
+ return AppLogsResponse(
138
+ name=name,
139
+ host=logs.host,
140
+ lines=logs.lines,
141
+ at=None if logs.at is None else logs.at.timestamp(),
142
+ )
143
+
144
+
145
+ @router.get("/v1/hosted/usage")
146
+ async def hosted_usage(
147
+ key: AppKey, gateway: GatewayDep, month: Annotated[str | None, Query()] = None
148
+ ) -> ServedPage:
149
+ """The time the org's apps served here per UTC day, in one month: this one by default."""
150
+ since, until = month_of(month)
151
+ rows = await hosted_running.served(
152
+ gateway.connections.pool, since, until, org=key.org, env=key.env
153
+ )
154
+ return served_page(since, until, rows)
155
+
156
+
157
+ @router.delete("/v1/hosted/{name}", status_code=204)
158
+ async def drop_hosted_app(name: str, key: AppKey, gateway: GatewayDep) -> None:
159
+ """Stop hosting the app: its releases go, and its token is revoked."""
160
+ app = HostedApp(org=key.org, env=key.env, name=name)
161
+ await hosting.drop_app(gateway.connections.pool, app)
162
+
163
+
164
+ @router.get("/v1/secrets")
165
+ async def list_secrets(key: AppKey, gateway: GatewayDep) -> SecretList:
166
+ """The org's secrets in this world, by name; never a value."""
167
+ return await _secrets(gateway.connections.pool, key)
168
+
169
+
170
+ @router.put("/v1/secrets/{name}")
171
+ async def put_secret(
172
+ name: str, body: PutSecretRequest, key: AppKey, gateway: GatewayDep
173
+ ) -> SecretList:
174
+ """Keep one secret, sealed, replacing the value it had; the org's list after it."""
175
+ connections = gateway.connections
176
+ secret = Secret(env=key.env, name=name, value=body.value)
177
+ await org_secrets.put_secret(
178
+ connections.pool, connections.vault, key.org, secret, set_by=asked_by(key)
179
+ )
180
+ return await _secrets(connections.pool, key)
181
+
182
+
183
+ @router.delete("/v1/secrets/{name}")
184
+ async def drop_secret(name: str, key: AppKey, gateway: GatewayDep) -> SecretList:
185
+ """Forget one secret; the org's list after it, 404 for a name nobody set."""
186
+ pool = gateway.connections.pool
187
+ await org_secrets.drop_secret(pool, key.org, key.env, name)
188
+ return await _secrets(pool, key)
189
+
190
+
191
+ def month_of(month: str | None) -> tuple[date, date]:
192
+ """The first day of the month named (YYYY-MM), or of this UTC one, and of the next."""
193
+ if month is None:
194
+ first = datetime.now(UTC).date().replace(day=1)
195
+ else:
196
+ try:
197
+ first = datetime.strptime(month, "%Y-%m").replace(tzinfo=UTC).date()
198
+ except ValueError:
199
+ raise DeclarationRefused(NOT_A_MONTH.format(month=month)) from None
200
+ after = (first.replace(day=28) + timedelta(days=4)).replace(day=1)
201
+ return first, after
202
+
203
+
204
+ def served_page(since: date, until: date, rows: list[Served]) -> ServedPage:
205
+ """The time apps served, as the doors answer it."""
206
+ return ServedPage(
207
+ since=since.isoformat(),
208
+ until=until.isoformat(),
209
+ rows=[
210
+ ServedRow(
211
+ org=row.org,
212
+ env=parse_env(row.env),
213
+ name=row.name,
214
+ day=row.day.isoformat(),
215
+ seconds=round(row.seconds, 1),
216
+ )
217
+ for row in rows
218
+ ],
219
+ )
220
+
221
+
222
+ async def _secrets(pool: Pool, key: Acting) -> SecretList:
223
+ listed = await org_secrets.secrets_of(pool, key.org, key.env)
224
+ return SecretList(
225
+ secrets=[
226
+ SecretRow(name=secret.name, set_by=secret.set_by, set_at=secret.set_at.timestamp())
227
+ for secret in listed
228
+ ]
229
+ )
230
+
231
+
232
+ def _release_row(app: HostedApp, release: Release) -> ReleaseRow:
233
+ return ReleaseRow(
234
+ name=app.name,
235
+ release=release.release,
236
+ sha256=release.sha256,
237
+ bytes=release.bytes,
238
+ author=release.author,
239
+ note=release.note,
240
+ created_at=release.created_at.timestamp(),
241
+ )
@@ -0,0 +1,97 @@
1
+ """The judge doors: the org's questions for every agent's calls, and one agent's for its own."""
2
+
3
+ import re
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from pinecall.domain.agent import AgentJudge
8
+ from pinecall.domain.errors import DeclarationRefused
9
+ from pinecall.domain.scope import THE_ORGS_OWN
10
+ from pinecall.gateway._deps import Acting, EvalsKey, GatewayDep
11
+ from pinecall.postgres.pool import Pool
12
+ from pinecall.tenancy import judges
13
+ from pinecall.tenancy.judges import StoredJudge
14
+ from pinecall.wire.rest.evals import JudgeList, JudgeRequest, JudgeRow
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ # The name call.score gives the verdict: lower-case words joined by hyphens.
20
+ A_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
21
+
22
+
23
+ NOT_A_NAME = (
24
+ "{name!r} is not a judge's name: lower-case words joined by hyphens, like offers-next-slot"
25
+ )
26
+
27
+
28
+ # One list for both worlds, as the agent's personas are.
29
+ @router.get("/v1/org/judges")
30
+ async def list_org_judges(key: EvalsKey, gateway: GatewayDep) -> JudgeList:
31
+ """The org's judges, asked of every agent's calls, by name."""
32
+ return await _listed(gateway.connections.pool, key.org, THE_ORGS_OWN)
33
+
34
+
35
+ @router.put("/v1/org/judges/{name}")
36
+ async def put_org_judge(
37
+ name: str, body: JudgeRequest, key: EvalsKey, gateway: GatewayDep
38
+ ) -> JudgeList:
39
+ """Write one of the org's judges whole; the org's list after it."""
40
+ return await _written(gateway.connections.pool, key, THE_ORGS_OWN, name, body)
41
+
42
+
43
+ @router.delete("/v1/org/judges/{name}")
44
+ async def drop_org_judge(name: str, key: EvalsKey, gateway: GatewayDep) -> JudgeList:
45
+ """Forget one of the org's judges; the org's list after it, 404 for a name nobody wrote."""
46
+ return await _dropped(gateway.connections.pool, key.org, THE_ORGS_OWN, name)
47
+
48
+
49
+ @router.get("/v1/agents/{slug}/judges")
50
+ async def list_judges(slug: str, key: EvalsKey, gateway: GatewayDep) -> JudgeList:
51
+ """The agent's own judges, by name; the org's are at /v1/org/judges."""
52
+ return await _listed(gateway.connections.pool, key.org, slug)
53
+
54
+
55
+ @router.put("/v1/agents/{slug}/judges/{name}")
56
+ async def put_judge(
57
+ slug: str, name: str, body: JudgeRequest, key: EvalsKey, gateway: GatewayDep
58
+ ) -> JudgeList:
59
+ """Write one of the agent's own judges whole; the agent's list after it."""
60
+ return await _written(gateway.connections.pool, key, slug, name, body)
61
+
62
+
63
+ @router.delete("/v1/agents/{slug}/judges/{name}")
64
+ async def drop_judge(slug: str, name: str, key: EvalsKey, gateway: GatewayDep) -> JudgeList:
65
+ """Forget one of the agent's own judges; its list after it, 404 for a name nobody wrote."""
66
+ return await _dropped(gateway.connections.pool, key.org, slug, name)
67
+
68
+
69
+ async def _written(pool: Pool, key: Acting, agent: str, name: str, body: JudgeRequest) -> JudgeList:
70
+ if not A_NAME.match(name):
71
+ raise DeclarationRefused(NOT_A_NAME.format(name=name))
72
+ written = AgentJudge(name=name, question=body.question, runs_on=body.runs_on)
73
+ bearer = key.bearer.key
74
+ await judges.put_judge(pool, key.org, agent, written, author=bearer.subject or bearer.key_id)
75
+ return await _listed(pool, key.org, agent)
76
+
77
+
78
+ async def _dropped(pool: Pool, org: str, agent: str, name: str) -> JudgeList:
79
+ await judges.drop_judge(pool, org, agent, name)
80
+ return await _listed(pool, org, agent)
81
+
82
+
83
+ async def _listed(pool: Pool, org: str, agent: str) -> JudgeList:
84
+ return JudgeList(
85
+ judges=[_row_of(stored) for stored in await judges.judges_of(pool, org, agent)]
86
+ )
87
+
88
+
89
+ def _row_of(stored: StoredJudge) -> JudgeRow:
90
+ judge = stored.judge
91
+ return JudgeRow(
92
+ name=judge.name,
93
+ question=judge.question,
94
+ runs_on=judge.runs_on,
95
+ author=stored.author,
96
+ set_at=stored.set_at.timestamp(),
97
+ )
@@ -0,0 +1,110 @@
1
+ """The org's keys: listed by fingerprint, a server's token minted, one revoked."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from pinecall.domain.errors import NotAllowed, NotFound
8
+ from pinecall.domain.names import PRODUCTION, parse_env
9
+ from pinecall.domain.person import THE_KEYS
10
+ from pinecall.gateway._deps import AppKey, BearerDep, GatewayDep
11
+ from pinecall.tenancy import keys, people
12
+ from pinecall.tenancy.keys import Bearer, ListedKey
13
+ from pinecall.wire.rest.accounts import (
14
+ CreateKeyRequest,
15
+ KeyIssuedResponse,
16
+ KeyRow,
17
+ RevokeKeyResponse,
18
+ )
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ # Made by a person and owned by the org, so it outlives whoever made it.
24
+ BY_A_PERSON = "a server's token is made by a person, from Tokens in the console"
25
+
26
+
27
+ NO_PRODUCTION = "{name} has no production access: a production token is made by somebody who has"
28
+
29
+
30
+ # One sentence for another org's key, a revoked one and one that is not yours to stop.
31
+ NO_SUCH_KEY = "no live key of this org has the fingerprint {fingerprint}"
32
+
33
+
34
+ # Any key: every server's token, and the asker's own person keys, or every person's with `keys`.
35
+ @router.get("/v1/keys")
36
+ async def list_keys(key: BearerDep, gateway: GatewayDep) -> list[KeyRow]:
37
+ """The org's keys this key may see, oldest first, the revoked ones too; never a key."""
38
+ pool = gateway.connections.pool
39
+ names = {member.id: member.name for member in await people.listed(pool, key.key.org)}
40
+ return [
41
+ key_row(row, names)
42
+ for row in await keys.listed(pool, key.key.org)
43
+ if row.key.subject is None
44
+ or row.key.subject == key.key.subject
45
+ or THE_KEYS in key.key.scopes
46
+ ]
47
+
48
+
49
+ # Only the fingerprint is kept: the token is in this answer and never again.
50
+ @router.post("/v1/keys")
51
+ async def create_key(body: CreateKeyRequest, key: AppKey, gateway: GatewayDep) -> KeyIssuedResponse:
52
+ """A server's token for the org in one world, `pc_live_` or `pc_test_`."""
53
+ env = parse_env(body.env)
54
+ person = key.bearer.member
55
+ if key.bearer.key.subject is None or person is None:
56
+ raise NotAllowed(BY_A_PERSON)
57
+ if env == PRODUCTION and not person.opens_production:
58
+ raise NotAllowed(NO_PRODUCTION.format(name=person.name))
59
+ keys.check_expiry(body.expires_at, datetime.now(UTC))
60
+ issued = keys.Issued(
61
+ org=key.org,
62
+ env=env,
63
+ scopes=keys.server_scopes(body.scopes),
64
+ label=body.label,
65
+ created_by=person.id,
66
+ expires_at=body.expires_at,
67
+ )
68
+ minted, secret = await keys.issue(gateway.connections.pool, issued)
69
+ return KeyIssuedResponse.of(minted, secret)
70
+
71
+
72
+ # A POST, not a DELETE: the row stays, so the entries that name the key still read.
73
+ @router.post("/v1/keys/{fingerprint}/revoke")
74
+ async def revoke_key(fingerprint: str, key: BearerDep, gateway: GatewayDep) -> RevokeKeyResponse:
75
+ """Stop one of the org's keys from the next request on: your own, one you made, or any."""
76
+ pool = gateway.connections.pool
77
+ listed = await keys.listed(pool, key.key.org)
78
+ row = next((row for row in listed if row.fingerprint == fingerprint), None)
79
+ if row is None or row.revoked_at is not None or not _may_stop(key, row):
80
+ raise NotFound(NO_SUCH_KEY.format(fingerprint=fingerprint))
81
+ stopped = await keys.revoke(pool, fingerprint)
82
+ if not stopped:
83
+ raise NotFound(NO_SUCH_KEY.format(fingerprint=fingerprint))
84
+ for each in stopped:
85
+ gateway.keys.forget(fingerprint=each)
86
+ return RevokeKeyResponse(fingerprint=fingerprint, revoked=True)
87
+
88
+
89
+ def key_row(row: ListedKey, names: dict[str, str]) -> KeyRow:
90
+ """A listed key as the doors send it, its maker named where the org still knows them."""
91
+ is_a_persons = row.key.subject is not None
92
+ return KeyRow(
93
+ fingerprint=row.fingerprint,
94
+ label=row.key.label,
95
+ kind="person" if is_a_persons else "server",
96
+ env=None if is_a_persons else row.key.env,
97
+ name=row.key.name,
98
+ created_by=None if row.created_by is None else names.get(row.created_by, row.created_by),
99
+ created_at=row.created_at,
100
+ last_used_at=row.last_used_at,
101
+ revoked_at=row.revoked_at,
102
+ scopes=sorted(row.key.scopes),
103
+ expires_at=row.key.expires_at,
104
+ )
105
+
106
+
107
+ def _may_stop(key: Bearer, row: ListedKey) -> bool:
108
+ subject = key.key.subject
109
+ mine = subject is not None and subject in (row.key.subject, row.created_by)
110
+ return mine or THE_KEYS in key.key.scopes
@@ -0,0 +1,142 @@
1
+ """The developer's line: whose terminal a ring at the agent lands in, and their phones."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Query
6
+
7
+ from pinecall.channels import routes
8
+ from pinecall.channels.telephony import sip
9
+ from pinecall.domain.errors import (
10
+ Conflict,
11
+ NotAllowed,
12
+ )
13
+ from pinecall.domain.names import SANDBOX, parse_e164
14
+ from pinecall.domain.scope import Scope
15
+ from pinecall.fleet import worlds
16
+ from pinecall.gateway import _deps
17
+ from pinecall.gateway._deps import Acting, AppKey, CallsKey, FleetKey, GatewayDep, ScopeDep
18
+ from pinecall.gateway._gateway import Gateway
19
+ from pinecall.wire.rest.agents import (
20
+ DeveloperPhones,
21
+ RingHandoff,
22
+ RingTarget,
23
+ ScopeHolder,
24
+ TestNumber,
25
+ TestNumbers,
26
+ )
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ NOT_IN_PRODUCTION = "production has one corner and the org holds it: there is nothing to route"
32
+
33
+ NOBODY_TO_ROUTE_TO = "a phone reaches a person's own corner, and this key names no person"
34
+
35
+
36
+ @router.get("/v1/agents/{slug}/line")
37
+ async def get_line(slug: str, _key: CallsKey, where: ScopeDep, gateway: GatewayDep) -> RingTarget:
38
+ """Who holds the agent's line, and who else could take it."""
39
+ return await _line(gateway, where, slug)
40
+
41
+
42
+ @router.post("/v1/agents/{slug}/line")
43
+ async def take_line(slug: str, _key: AppKey, where: ScopeDep, gateway: GatewayDep) -> RingTarget:
44
+ """Take the agent's line for this scope."""
45
+ gateway.sockets.take_the_line(where, slug)
46
+ return await _line(gateway, where, slug)
47
+
48
+
49
+ @router.delete("/v1/agents/{slug}/line")
50
+ async def drop_line(slug: str, _key: AppKey, where: ScopeDep, gateway: GatewayDep) -> RingTarget:
51
+ """Let the line go, to the newest other scope that could take it."""
52
+ gateway.sockets.drop_the_line(where, slug)
53
+ return await _line(gateway, where, slug)
54
+
55
+
56
+ @router.put("/v1/line/from")
57
+ async def put_developer_phones(
58
+ developer_phones: DeveloperPhones, key: AppKey, gateway: GatewayDep
59
+ ) -> dict[str, list[str]]:
60
+ """Send rings from this phone to the key's person's own scope."""
61
+ holder = _person_of(key)
62
+ gateway.sockets.calls_from(key.env, parse_e164(developer_phones.number), holder)
63
+ return {"calling": gateway.sockets.calling(key.env, holder)}
64
+
65
+
66
+ @router.delete("/v1/line/from")
67
+ async def drop_developer_phones(key: AppKey, gateway: GatewayDep) -> dict[str, list[str]]:
68
+ """Stop sending this person's phones to their scope, and say which were forgotten."""
69
+ return {"forgot": gateway.sockets.forget_calls_from(key.env, _person_of(key))}
70
+
71
+
72
+ # A developer lacks the numbers scope in production: this shows production's numbers and agents.
73
+ @router.get("/v1/line/numbers")
74
+ async def list_test_numbers(key: AppKey, gateway: GatewayDep) -> TestNumbers:
75
+ """The production numbers a developer's phone can dial, and the phones that are theirs."""
76
+ holder = _person_of(key)
77
+ doors = await routes.of_org(gateway.connections.pool, key.org, "production")
78
+ return TestNumbers(
79
+ calling=gateway.sockets.calling(key.env, holder),
80
+ numbers=[
81
+ TestNumber(number=door.number, agent=door.agent)
82
+ for door in doors
83
+ if door.channel == "phone" and door.number is not None
84
+ ],
85
+ )
86
+
87
+
88
+ # A developer's own phone dialling the production number reaches their sandbox copy, while they
89
+ # hold it; every other caller reaches production. Where the sandbox has a LiveKit of its own, the
90
+ # ring is dialled there over SIP, through the trunk the answer carries.
91
+ @router.get("/v1/agents/{slug}/rings-for")
92
+ async def rings_for(
93
+ slug: str,
94
+ _key: FleetKey,
95
+ gateway: GatewayDep,
96
+ org: Annotated[str, Query()],
97
+ caller: Annotated[str, Query()],
98
+ ) -> RingHandoff:
99
+ """Where a production ring from this phone goes: a developer's scope and fleet, or nowhere."""
100
+ taking = gateway.sockets.taking(Scope(org, SANDBOX), slug, caller)
101
+ if taking is None or not taking.scope.holder:
102
+ return RingHandoff()
103
+ if caller not in gateway.sockets.calling(SANDBOX, taking.scope.holder):
104
+ return RingHandoff()
105
+ fleet = worlds.fleet_of(await worlds.fleets(gateway.connections.pool), SANDBOX)
106
+ trunk = sip.hand_over_trunk(gateway.connections, caller)
107
+ return RingHandoff(holder=taking.scope.holder, fleet=fleet, trunk=trunk)
108
+
109
+
110
+ def _person_of(key: Acting) -> str:
111
+ if key.env != SANDBOX:
112
+ raise Conflict(NOT_IN_PRODUCTION)
113
+ if key.bearer.member is None:
114
+ raise NotAllowed(NOBODY_TO_ROUTE_TO)
115
+ return key.bearer.member.id
116
+
117
+
118
+ async def _line(gateway: Gateway, where: Scope, slug: str) -> RingTarget:
119
+ holder = gateway.sockets.line_of(where.env, slug)
120
+ waiting = [
121
+ item
122
+ for item in gateway.sockets.waiting_for_the_line(where, slug)
123
+ if item.scope.holder != holder
124
+ ]
125
+ holding = None
126
+ if holder is not None:
127
+ holding = await _deps.named_holder(
128
+ gateway, Scope(where.org, where.env, holder)
129
+ ) or ScopeHolder(holder=None, name=None)
130
+ return RingTarget(
131
+ agent=slug,
132
+ env=where.env,
133
+ held=holder is not None,
134
+ holding=holding,
135
+ # In production every holder is the org's own: the line is never "yours" there.
136
+ yours=holder is not None and holder == where.holder and where.env == SANDBOX,
137
+ waiting=[
138
+ await _deps.named_holder(gateway, waiter.scope) or ScopeHolder(holder=None, name=None)
139
+ for waiter in waiting
140
+ ],
141
+ calling=gateway.sockets.calling(where.env, where.holder) if where.holder else [],
142
+ )