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,197 @@
1
+ """The org's people: listed, invited, changed, reset, removed."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Path, Request
8
+
9
+ from pinecall.domain.errors import Conflict, DeclarationRefused, NotFound
10
+ from pinecall.domain.names import AN_ADDRESS, PRODUCTION
11
+ from pinecall.domain.person import parse_role, parse_status
12
+ from pinecall.gateway._deps import Acting, GatewayDep, TeamKey, public_url
13
+ from pinecall.gateway._gateway import Gateway
14
+ from pinecall.tenancy import admission, keys, letters, orgs, people
15
+ from pinecall.tenancy.letters import Brand, Letter, Link
16
+ from pinecall.wire.rest.accounts import (
17
+ ChangeMemberRequest,
18
+ InvitationResponse,
19
+ InviteMemberRequest,
20
+ MemberList,
21
+ MemberRow,
22
+ )
23
+
24
+ router = APIRouter()
25
+
26
+
27
+ # Invited people become active by accepting; a change may only wake a disabled one.
28
+ NOT_BY_HAND = "{email} has not accepted their invitation: they become active by accepting it"
29
+
30
+
31
+ NOT_YOURSELF = "you cannot remove yourself: another admin of this org removes you"
32
+
33
+
34
+ NOT_YOURSELF_DISABLED = "you cannot disable yourself: another admin of this org disables you"
35
+
36
+
37
+ NOT_YOUR_OWN_ROW = "nobody changes their own role or production access: another admin does"
38
+
39
+
40
+ AN_ADMIN_OPENS_PRODUCTION = "{email} is an admin, and an admin always opens production"
41
+
42
+
43
+ NOT_ACTIVE = (
44
+ "{email} is {status}, not active: an invited member uses their invitation, and a disabled one "
45
+ "is enabled before their password is reset"
46
+ )
47
+
48
+
49
+ NOT_AN_ADDRESS = "an email has one @ and a domain, not {email!r}"
50
+
51
+
52
+ # Who a letter says invited or reset, when the key names nobody.
53
+ AN_ADMIN = "An admin"
54
+
55
+
56
+ type Worded = Callable[[str, Link, Brand], Letter]
57
+
58
+
59
+ # The path says {id}, as the console spells it; `id` is a builtin, so the function says member.
60
+ MemberId = Annotated[str, Path(alias="id")]
61
+
62
+
63
+ A_MEMBER = "/v1/members/{id}"
64
+
65
+
66
+ A_MEMBERS_RESET = "/v1/members/{id}/reset"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Posting:
71
+ """A link's letter: whom it goes to, how it is worded, and whose mailbox carries it."""
72
+
73
+ invited: people.Invited
74
+ worded: Worded
75
+ # The org, when the admin holds the token anyway; None (the box's mailbox) when the letter
76
+ # alone proves the address, so no org's server ever sees that link.
77
+ through: str | None
78
+
79
+
80
+ @router.get("/v1/members")
81
+ async def list_members(key: TeamKey, gateway: GatewayDep) -> MemberList:
82
+ """Every member of the org, oldest first, the disabled ones too."""
83
+ listed = await people.listed(gateway.connections.pool, key.org)
84
+ return MemberList(members=[MemberRow.of(member) for member in listed])
85
+
86
+
87
+ # The token is answered this once and kept as a hash. It is handed to the admin only when the
88
+ # address is in no other org; otherwise it is mailed only, by the box's own mailbox, and the link
89
+ # seats the person with the password they already have.
90
+ @router.post("/v1/members", status_code=201)
91
+ async def invite_member(
92
+ body: InviteMemberRequest, key: TeamKey, request: Request, gateway: GatewayDep
93
+ ) -> InvitationResponse:
94
+ """Invite a person, within the org's seats, and mail them the link."""
95
+ pool = gateway.connections.pool
96
+ role = parse_role(body.role)
97
+ if not AN_ADDRESS.match(body.email.strip()):
98
+ raise DeclarationRefused(NOT_AN_ADDRESS.format(email=body.email))
99
+ keys.check_may_grant(key.bearer, role, production=body.production)
100
+ elsewhere = await _member_elsewhere(gateway, key.org, body.email)
101
+ seats = (await admission.quotas_of(pool, key.org, PRODUCTION)).seats
102
+ invitee = people.Invitee(body.email, body.name, role, frozenset(body.agents), body.production)
103
+ invited = await people.invite(pool, key.org, invitee, seats=seats, vouched=elsewhere)
104
+ posting = Posting(invited, letters.invitation_letter, None if elsewhere else key.org)
105
+ mailed = await _mailed(request, gateway, key, posting)
106
+ return InvitationResponse(
107
+ member=MemberRow.of(invited.member),
108
+ token=None if elsewhere else invited.token,
109
+ expires_at=invited.expires_at,
110
+ mailed=mailed,
111
+ link=_card(request, gateway, None if elsewhere else invited.token),
112
+ )
113
+
114
+
115
+ @router.patch(A_MEMBER)
116
+ async def change_member(
117
+ member: MemberId, body: ChangeMemberRequest, key: TeamKey, gateway: GatewayDep
118
+ ) -> MemberRow:
119
+ """Change a member's role, agents, standing or production access; disabling revokes keys."""
120
+ pool = gateway.connections.pool
121
+ found = await people.find(pool, key.org, member)
122
+ if found is None:
123
+ raise NotFound(people.NOBODY_BY_THAT_ID)
124
+ role = None if body.role is None else parse_role(body.role)
125
+ status = None if body.status is None else parse_status(body.status)
126
+ if status == "active" and found.status == "invited":
127
+ raise DeclarationRefused(NOT_BY_HAND.format(email=found.email))
128
+ yours = key.bearer.key.subject == member
129
+ if status == "disabled" and yours:
130
+ raise Conflict(NOT_YOURSELF_DISABLED)
131
+ if yours and (role is not None or body.production is not None):
132
+ raise Conflict(NOT_YOUR_OWN_ROW)
133
+ keys.check_may_grant(key.bearer, role, production=bool(body.production))
134
+ if body.production is False and (role or found.role) == "admin":
135
+ raise Conflict(AN_ADMIN_OPENS_PRODUCTION.format(email=found.email))
136
+ agents = None if body.agents is None else frozenset(body.agents)
137
+ change = people.Change(role=role, agents=agents, status=status, production=body.production)
138
+ changed = await people.update(pool, key.org, member, change)
139
+ gateway.keys.forget(subject=member)
140
+ return MemberRow.of(changed)
141
+
142
+
143
+ @router.delete(A_MEMBER, status_code=204)
144
+ async def remove_member(member: MemberId, key: TeamKey, gateway: GatewayDep) -> None:
145
+ """Take a member out for good, their keys revoked; never yourself, never the last admin."""
146
+ if key.bearer.key.subject == member:
147
+ raise Conflict(NOT_YOURSELF)
148
+ await people.remove(gateway.connections.pool, key.org, member)
149
+ gateway.keys.forget(subject=member)
150
+
151
+
152
+ # A new link spends the older ones; it is handed over on the same terms as an invitation.
153
+ @router.post(A_MEMBERS_RESET, status_code=201)
154
+ async def reset_password(
155
+ member: MemberId, key: TeamKey, request: Request, gateway: GatewayDep
156
+ ) -> InvitationResponse:
157
+ """A one-use link that sets an active member's password, mailed to them."""
158
+ pool = gateway.connections.pool
159
+ found = await people.find(pool, key.org, member)
160
+ if found is None:
161
+ raise NotFound(people.NOBODY_BY_THAT_ID)
162
+ elsewhere = await _member_elsewhere(gateway, key.org, found.email)
163
+ issued = await people.reset(pool, key.org, member, vouched=elsewhere)
164
+ if issued is None:
165
+ raise Conflict(NOT_ACTIVE.format(email=found.email, status=found.status))
166
+ posting = Posting(issued, letters.reset_letter, None if elsewhere else key.org)
167
+ mailed = await _mailed(request, gateway, key, posting)
168
+ return InvitationResponse(
169
+ member=MemberRow.of(issued.member),
170
+ token=None if elsewhere else issued.token,
171
+ expires_at=issued.expires_at,
172
+ mailed=mailed,
173
+ link=_card(request, gateway, None if elsewhere else issued.token),
174
+ )
175
+
176
+
177
+ def _card(request: Request, gateway: Gateway, token: str | None) -> str | None:
178
+ return None if token is None else letters.card_link(public_url(request, gateway), token)
179
+
180
+
181
+ async def _member_elsewhere(gateway: Gateway, org: str, email: str) -> bool:
182
+ rows = await people.orgs_of(gateway.connections.pool, email)
183
+ return any(row.org != org for row in rows)
184
+
185
+
186
+ async def _mailed(request: Request, gateway: Gateway, key: Acting, posting: Posting) -> bool:
187
+ pool = gateway.connections.pool
188
+ org = await orgs.find(pool, key.org)
189
+ invited = posting.invited
190
+ link = Link(
191
+ org=key.org if org is None else org.name,
192
+ link=letters.card_link(public_url(request, gateway), invited.token),
193
+ by=key.bearer.key.name or AN_ADMIN,
194
+ dies=invited.expires_at,
195
+ )
196
+ letter = posting.worded(invited.member.email, link, await letters.brand_of(pool))
197
+ return await gateway.outbox.post(posting.through, letter)
@@ -0,0 +1,226 @@
1
+ """`GET /metrics`: what the gateway counted and holds, as Prometheus text, on loopback alone."""
2
+
3
+ import ipaddress
4
+ import time
5
+
6
+ import psycopg
7
+ from fastapi import APIRouter, Request
8
+ from fastapi.responses import PlainTextResponse
9
+
10
+ from pinecall.channels import offers
11
+ from pinecall.domain.errors import NotAllowed
12
+ from pinecall.fleet.roster import heard_lately, worker_state
13
+ from pinecall.gateway._deps import GatewayDep, client_of
14
+ from pinecall.gateway._gateway import Gateway
15
+ from pinecall.process.metrics import family, histogram
16
+ from pinecall.providers import build
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ # Caddy sets it on every request it passes on: a request that carries it came from off the box.
22
+ FORWARDED = "x-forwarded-for"
23
+
24
+ NOT_HERE = (
25
+ "/metrics answers the addresses PINECALL_METRICS_FROM names (the box's loopback unless said), "
26
+ "never a forwarded request: curl http://127.0.0.1:8080/metrics on the box"
27
+ )
28
+
29
+ TEXT_FORMAT = "text/plain; version=0.0.4; charset=utf-8"
30
+
31
+ # What each standby is behind, as the primary sees it: empty on a box with no replica, and on a
32
+ # standby itself. A lag never replayed yet (a replica caught up and idle) reads as zero.
33
+ REPLICAS = """
34
+ select application_name as replica, extract(epoch from coalesce(replay_lag, interval '0')) as lag
35
+ from pg_stat_replication
36
+ """
37
+
38
+
39
+ # No key: the address is the fence, and a scraper on the box or in the cluster holds none. A
40
+ # request through the load balancer always carries X-Forwarded-For, and is refused whatever it says.
41
+ @router.get("/metrics", include_in_schema=False)
42
+ async def read_metrics(request: Request, gateway: GatewayDep) -> PlainTextResponse:
43
+ """What the gateway counted since it started and what it holds now, in Prometheus's text."""
44
+ allowed = gateway.connections.settings.metrics_from
45
+ if not from_any(client_of(request), allowed) or FORWARDED in request.headers:
46
+ raise NotAllowed(NOT_HERE)
47
+ now = time.time()
48
+ replicas = await _replicas(gateway)
49
+ waiting = await _waiting(gateway, now)
50
+ return PlainTextResponse(_measures_of(gateway, now, replicas, waiting), media_type=TEXT_FORMAT)
51
+
52
+
53
+ def from_any(client: str, networks: str) -> bool:
54
+ """Whether the address is one of the comma-separated addresses or networks."""
55
+ try:
56
+ address = ipaddress.ip_address(client)
57
+ except ValueError:
58
+ return False
59
+ named = (part.strip() for part in networks.split(",") if part.strip())
60
+ return any(address in ipaddress.ip_network(network, strict=False) for network in named)
61
+
62
+
63
+ # A database that cannot say leaves the family empty: the scrape still answers.
64
+ async def _replicas(gateway: Gateway) -> list[tuple[str, float]]:
65
+ try:
66
+ async with gateway.connections.pool.connection() as connection:
67
+ rows = await (await connection.execute(REPLICAS)).fetchall()
68
+ except psycopg.Error:
69
+ return []
70
+ return [(str(row["replica"]), float(row["lag"])) for row in rows]
71
+
72
+
73
+ # The rooms with a caller no worker opened yet, per fleet: a call LiveKit or a worker dropped.
74
+ async def _waiting(gateway: Gateway, now: float) -> dict[str, int]:
75
+ try:
76
+ return await offers.waiting(gateway.connections.pool, now - offers.WAITING_COUNTED_S)
77
+ except psycopg.Error:
78
+ return {}
79
+
80
+
81
+ def _measures_of(
82
+ gateway: Gateway, now: float, replicas: list[tuple[str, float]], waiting: dict[str, int]
83
+ ) -> str:
84
+ """Every family this gateway exposes, in the text format."""
85
+ counted = gateway.counters
86
+ pool = gateway.connections.pool.get_stats()
87
+ live = gateway.live
88
+ fleets = sorted({seat.fleet for seat in gateway.roster.seats.values()})
89
+ totals = [gateway.roster.totals(fleet, now) for fleet in fleets]
90
+ workers = {fleet: gateway.roster.of(fleet, now) for fleet in fleets}
91
+ errors = sorted(counted.errors.items())
92
+ # Every vendor installed is a series, 0 while it is sound: a query (an alert's) on the family
93
+ # finds it before any vendor has failed.
94
+ failing = counted.failing(now)
95
+ vendors = sorted(set(build.installed()) | failing)
96
+ return "".join(
97
+ (
98
+ histogram(
99
+ "pinecall_append_seconds",
100
+ "Time a worker's entry or batch took to be written, measured at the door.",
101
+ counted.append_seconds,
102
+ ),
103
+ family(
104
+ "pinecall_entries_appended_total",
105
+ "Entries workers wrote through the append doors.",
106
+ "counter",
107
+ [({}, counted.appended)],
108
+ ),
109
+ family(
110
+ "pinecall_errors_total",
111
+ "Error entries workers wrote, by code and the vendor whose plugin failed.",
112
+ "counter",
113
+ [({"code": code, "vendor": vendor}, count) for (code, vendor), count in errors],
114
+ ),
115
+ family(
116
+ "pinecall_pool_connections",
117
+ "Connections the pool holds open, lent out now, and at most.",
118
+ "gauge",
119
+ [
120
+ ({"state": "open"}, pool.get("pool_size", 0)),
121
+ ({"state": "in_use"}, pool.get("pool_size", 0) - pool.get("pool_available", 0)),
122
+ ({"state": "max"}, pool.get("pool_max", 0)),
123
+ ],
124
+ ),
125
+ family(
126
+ "pinecall_pool_waiting",
127
+ "Requests waiting for a connection now.",
128
+ "gauge",
129
+ [({}, pool.get("requests_waiting", 0))],
130
+ ),
131
+ family(
132
+ "pinecall_pool_requests_total",
133
+ "Connections asked of the pool.",
134
+ "counter",
135
+ [({}, pool.get("requests_num", 0))],
136
+ ),
137
+ family(
138
+ "pinecall_pool_wait_seconds_total",
139
+ "Time requests spent waiting for a connection.",
140
+ "counter",
141
+ [({}, pool.get("requests_wait_ms", 0) / 1000)],
142
+ ),
143
+ family(
144
+ "pinecall_held",
145
+ "What the gateway holds now: live log readers, app sockets, calls served.",
146
+ "gauge",
147
+ [
148
+ ({"what": "log_readers"}, gateway.logs.readers),
149
+ ({"what": "app_sockets"}, len(live.sockets)),
150
+ ({"what": "calls_live"}, len(live.calls)),
151
+ ],
152
+ ),
153
+ family(
154
+ "pinecall_writer_waiting",
155
+ "Appends queued for the log's writer and not yet in a transaction.",
156
+ "gauge",
157
+ [({}, gateway.logs.store.writer.waiting)],
158
+ ),
159
+ family(
160
+ "pinecall_fleet",
161
+ "Each fleet: workers, seats, calls held, accepting; rooms waiting for a worker.",
162
+ "gauge",
163
+ [
164
+ ({"fleet": total.fleet, "what": what}, value)
165
+ for total in totals
166
+ for what, value in (
167
+ ("workers", total.workers),
168
+ ("seats", total.seats),
169
+ ("busy", total.active),
170
+ ("accepting", total.accepting),
171
+ ("waiting", waiting.get(total.fleet, 0)),
172
+ )
173
+ ],
174
+ ),
175
+ family(
176
+ "pinecall_vendor_failing",
177
+ "1 for a vendor over its error line in the last two minutes, as this gateway saw; "
178
+ "0 for every other vendor installed.",
179
+ "gauge",
180
+ [({"vendor": vendor}, int(vendor in failing)) for vendor in vendors],
181
+ ),
182
+ family(
183
+ "pinecall_replication_lag_seconds",
184
+ "How far behind each standby is in replaying the primary's WAL.",
185
+ "gauge",
186
+ [({"replica": replica}, lag) for replica, lag in replicas],
187
+ ),
188
+ family(
189
+ "pinecall_loop_running",
190
+ "Whether each loop the process must keep (the reaper, the sweep) still runs.",
191
+ "gauge",
192
+ [
193
+ ({"loop": name}, int(not task.done()))
194
+ for name, task in sorted(gateway.loops.items())
195
+ ],
196
+ ),
197
+ family(
198
+ "pinecall_spend_unusual",
199
+ "How many times its usual day an org's calls cost today, while they do.",
200
+ "gauge",
201
+ [({"org": org}, multiple) for org, multiple in sorted(counted.unusual.items())],
202
+ ),
203
+ family(
204
+ "pinecall_worker_state",
205
+ "How the roster counts each worker: accepting, failing, full, draining, cordoned.",
206
+ "gauge",
207
+ [
208
+ ({"fleet": fleet, "worker": seat.worker, "state": state}, 1)
209
+ for fleet, seats in workers.items()
210
+ for seat in seats
211
+ if (state := worker_state(seat, seats, now)) != "gone"
212
+ ],
213
+ ),
214
+ family(
215
+ "pinecall_worker_first_audio_p95_seconds",
216
+ "Each worker's first audio at the p95 over its last minute, as its heartbeat says.",
217
+ "gauge",
218
+ [
219
+ ({"fleet": fleet, "worker": seat.worker}, seat.first_audio_p95_s)
220
+ for fleet, seats in workers.items()
221
+ for seat in seats
222
+ if seat.first_audio_p95_s is not None and heard_lately(seat, now)
223
+ ],
224
+ ),
225
+ )
226
+ )