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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|