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,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
|
+
)
|