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,223 @@
|
|
|
1
|
+
"""A written call over a WebSocket: a message in, every entry of the call out."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
6
|
+
from pydantic import TypeAdapter, ValidationError
|
|
7
|
+
from starlette.websockets import WebSocketState
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.call import CallContext, Contact, Route, new_call_id, today_in
|
|
10
|
+
from pinecall.domain.errors import (
|
|
11
|
+
Conflict,
|
|
12
|
+
DeclarationRefused,
|
|
13
|
+
NotAllowed,
|
|
14
|
+
NotFound,
|
|
15
|
+
PinecallError,
|
|
16
|
+
QuotaExhausted,
|
|
17
|
+
)
|
|
18
|
+
from pinecall.domain.names import THE_WIDGET, Json, JsonObject
|
|
19
|
+
from pinecall.domain.scope import Scope
|
|
20
|
+
from pinecall.gateway import _deps
|
|
21
|
+
from pinecall.gateway._call_setup import exhausted
|
|
22
|
+
from pinecall.gateway._gateway import Gateway
|
|
23
|
+
from pinecall.gateway._sockets import NO_AGENT, NO_UNCLAIMED, NOT_THAT_APP, Registration
|
|
24
|
+
from pinecall.gateway._text_calls import open_text, resume_text, tokens_of
|
|
25
|
+
from pinecall.log.logs import Subscription
|
|
26
|
+
from pinecall.session import text
|
|
27
|
+
from pinecall.session.session import Session
|
|
28
|
+
from pinecall.tenancy import admission, keys, personas
|
|
29
|
+
|
|
30
|
+
router = APIRouter()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
NOT_TAKEN_UP = "call {call} cannot be taken up: it is over, or not this agent's; open a new one"
|
|
34
|
+
|
|
35
|
+
NOT_A_STATE = "?state= is the state the call opens in: a JSON object of the class's fields"
|
|
36
|
+
|
|
37
|
+
STATE_TOO_BIG = "?state= is {size} bytes: the state a call opens in is {at_most} bytes at most"
|
|
38
|
+
|
|
39
|
+
A_STATE_AT_MOST = 16 * 1024
|
|
40
|
+
|
|
41
|
+
_A_STATE: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
|
|
42
|
+
|
|
43
|
+
OVER = "the call ended: {reason}"
|
|
44
|
+
|
|
45
|
+
# What uvicorn closes with on a stop; a caller never sends it, so the call is left for the next
|
|
46
|
+
# process to take up.
|
|
47
|
+
SERVICE_RESTART = 1012
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The org's key, never a room token (`pinecall chat`, `test`, `simulate`). A refusal accepts the
|
|
51
|
+
# socket first: a close before the handshake is a bare 403 with no reason.
|
|
52
|
+
@router.websocket("/v1/chat")
|
|
53
|
+
async def chat(websocket: WebSocket) -> None:
|
|
54
|
+
"""One text call: {text} frames in, every entry of the call out."""
|
|
55
|
+
gateway = _deps.gateway_of(websocket)
|
|
56
|
+
try:
|
|
57
|
+
found, again = await _chatting(websocket, gateway)
|
|
58
|
+
session = (
|
|
59
|
+
await _taken_up(gateway, found, again)
|
|
60
|
+
if again
|
|
61
|
+
else await open_text(
|
|
62
|
+
gateway.serving, found, await _chat_context(websocket, gateway, found)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
except PinecallError as refused:
|
|
66
|
+
await websocket.accept()
|
|
67
|
+
await websocket.close(code=_deps.POLICY_VIOLATION, reason=_deps.close_reason(str(refused)))
|
|
68
|
+
return
|
|
69
|
+
await websocket.accept()
|
|
70
|
+
served = gateway.live.calls.get(session.call.context.call)
|
|
71
|
+
heard = await served.log.followed() if served is not None else None
|
|
72
|
+
sending = asyncio.create_task(_sent(websocket, heard)) if heard is not None else None
|
|
73
|
+
# The call can end from the desk or by the model: the socket is closed under the caller.
|
|
74
|
+
ending = asyncio.create_task(_hung_up(websocket, session))
|
|
75
|
+
try:
|
|
76
|
+
if not again:
|
|
77
|
+
await session.start()
|
|
78
|
+
await _turns(websocket, gateway, session)
|
|
79
|
+
finally:
|
|
80
|
+
ending.cancel()
|
|
81
|
+
if sending is not None:
|
|
82
|
+
sending.cancel()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _hung_up(websocket: WebSocket, session: Session) -> None:
|
|
86
|
+
await session.over.wait()
|
|
87
|
+
reason, _ = session.ended or ("error", "platform")
|
|
88
|
+
if websocket.application_state is WebSocketState.CONNECTED:
|
|
89
|
+
await websocket.close(reason=_deps.close_reason(OVER.format(reason=reason)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _chatting(websocket: WebSocket, gateway: Gateway) -> tuple[Registration, str | None]:
|
|
93
|
+
data = _deps.bearer_of(websocket.headers)
|
|
94
|
+
verified = None if data is None else await gateway.keys.verify(data)
|
|
95
|
+
if verified is None:
|
|
96
|
+
raise NotAllowed(_deps.TAKES_A_KEY)
|
|
97
|
+
keys.check_opens(verified, "talk")
|
|
98
|
+
env = _deps.world_of_request(websocket, verified)
|
|
99
|
+
scope = keys.scope_of(verified, env)
|
|
100
|
+
slug = websocket.query_params.get("agent", "")
|
|
101
|
+
keys.check_agent(verified, slug)
|
|
102
|
+
app = websocket.query_params.get("app")
|
|
103
|
+
registration = gateway.sockets.serving(scope, slug, app)
|
|
104
|
+
if registration is None:
|
|
105
|
+
raise NotFound(_why_not(gateway, scope, slug, app))
|
|
106
|
+
return registration, websocket.query_params.get("call")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _taken_up(gateway: Gateway, registration: Registration, call: str) -> Session:
|
|
110
|
+
session = await resume_text(
|
|
111
|
+
gateway.serving, registration, call, gateway.connections.settings.timezone
|
|
112
|
+
)
|
|
113
|
+
if session is None:
|
|
114
|
+
raise Conflict(NOT_TAKEN_UP.format(call=call))
|
|
115
|
+
return session
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# A web caller is a visitor id; the org's own key may name who it is (`?contact=`) and which of
|
|
119
|
+
# the agent's synthetic callers plays it (`?persona=`), whose rules are frozen into the call here.
|
|
120
|
+
async def _chat_context(
|
|
121
|
+
websocket: WebSocket, gateway: Gateway, registration: Registration
|
|
122
|
+
) -> CallContext:
|
|
123
|
+
scope = registration.scope
|
|
124
|
+
named = websocket.query_params.get("persona") or None
|
|
125
|
+
persona = (
|
|
126
|
+
None
|
|
127
|
+
if named is None
|
|
128
|
+
else await personas.persona(gateway.connections.pool, scope.org, registration.slug, named)
|
|
129
|
+
)
|
|
130
|
+
contact = websocket.query_params.get("contact")
|
|
131
|
+
return CallContext(
|
|
132
|
+
call=new_call_id(),
|
|
133
|
+
channel=THE_WIDGET,
|
|
134
|
+
direction="inbound",
|
|
135
|
+
caller=websocket.query_params.get("caller") or f"web_{new_call_id()[5:17]}",
|
|
136
|
+
contact=Contact(id=contact) if contact else None,
|
|
137
|
+
persona=named,
|
|
138
|
+
accepts_when=None if persona is None else persona.persona.accepts_when or None,
|
|
139
|
+
declines_when=None if persona is None else persona.persona.declines_when or None,
|
|
140
|
+
route=Route(org=scope.org, agent=registration.slug, channel=THE_WIDGET, env=scope.env),
|
|
141
|
+
today=today_in(gateway.connections.settings.timezone),
|
|
142
|
+
holder=scope.holder or None,
|
|
143
|
+
state=_opening_state(websocket),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _opening_state(websocket: WebSocket) -> JsonObject:
|
|
148
|
+
query = websocket.query_params.get("state")
|
|
149
|
+
if not query:
|
|
150
|
+
return {}
|
|
151
|
+
size = len(query.encode())
|
|
152
|
+
if size > A_STATE_AT_MOST:
|
|
153
|
+
raise DeclarationRefused(STATE_TOO_BIG.format(size=size, at_most=A_STATE_AT_MOST))
|
|
154
|
+
try:
|
|
155
|
+
return _A_STATE.validate_json(query)
|
|
156
|
+
except ValidationError:
|
|
157
|
+
raise DeclarationRefused(NOT_A_STATE) from None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# A failed send flips the application state; a receive after it raises RuntimeError, so the
|
|
161
|
+
# state is asked before each receive.
|
|
162
|
+
async def _turns(websocket: WebSocket, gateway: Gateway, session: Session) -> None:
|
|
163
|
+
scope = session.call.context.route
|
|
164
|
+
try:
|
|
165
|
+
while websocket.application_state is WebSocketState.CONNECTED:
|
|
166
|
+
frame = await websocket.receive_json()
|
|
167
|
+
if _hangs_up(frame):
|
|
168
|
+
# Ended before the socket closes, so the caller hears the call is over before
|
|
169
|
+
# it stops whatever served it.
|
|
170
|
+
await text.end(session, "caller_hung_up", "caller")
|
|
171
|
+
over = OVER.format(reason="caller_hung_up")
|
|
172
|
+
await websocket.close(reason=_deps.close_reason(over))
|
|
173
|
+
return
|
|
174
|
+
data = _text_of(frame)
|
|
175
|
+
if not data:
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
await admission.admit_turn(
|
|
179
|
+
gateway.connections.pool,
|
|
180
|
+
Scope(scope.org, scope.env),
|
|
181
|
+
turns=session.call.turns,
|
|
182
|
+
tokens=tokens_of(session.usage),
|
|
183
|
+
at=gateway.logs.store.clock(),
|
|
184
|
+
)
|
|
185
|
+
except QuotaExhausted as refused:
|
|
186
|
+
await exhausted(gateway.logs, scope.org, session.call.config.slug, refused)
|
|
187
|
+
await text.end(session, "timeout", "platform")
|
|
188
|
+
await websocket.close(
|
|
189
|
+
code=_deps.POLICY_VIOLATION, reason=_deps.close_reason(str(refused))
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
await text.hears(session, data)
|
|
193
|
+
except WebSocketDisconnect as gone:
|
|
194
|
+
if gone.code != SERVICE_RESTART and not session.closed:
|
|
195
|
+
await text.end(session, "caller_hung_up", "caller")
|
|
196
|
+
return
|
|
197
|
+
# A send that found the caller gone closed the socket under the loop, with no close to hear.
|
|
198
|
+
if not session.closed:
|
|
199
|
+
await text.end(session, "caller_hung_up", "caller")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _sent(websocket: WebSocket, heard: Subscription) -> None:
|
|
203
|
+
async for entry in heard:
|
|
204
|
+
await websocket.send_json(entry.written())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _hangs_up(data: Json) -> bool:
|
|
208
|
+
return isinstance(data, dict) and data.get("hangup") is True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _text_of(data: Json) -> str:
|
|
212
|
+
if not isinstance(data, dict):
|
|
213
|
+
return ""
|
|
214
|
+
text_said = data.get("text")
|
|
215
|
+
return text_said if isinstance(text_said, str) else ""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _why_not(gateway: Gateway, scope: Scope, slug: str, app: str | None) -> str:
|
|
219
|
+
if app is not None:
|
|
220
|
+
return NOT_THAT_APP.format(app=app, slug=slug)
|
|
221
|
+
if gateway.sockets.of(scope, slug) is not None:
|
|
222
|
+
return NO_UNCLAIMED.format(slug=slug)
|
|
223
|
+
return f"{NO_AGENT.format(slug=slug)}: start the app that serves it, then chat again"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The dataset doors: a call kept as a case or read as a golden, the inbox, a case decided."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Path, Query
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.errors import Conflict, NotFound
|
|
9
|
+
from pinecall.domain.names import Env
|
|
10
|
+
from pinecall.domain.scope import Scope
|
|
11
|
+
from pinecall.evals import calibration, dataset
|
|
12
|
+
from pinecall.evals.calibration import Label, Where
|
|
13
|
+
from pinecall.evals.dataset import Promoted
|
|
14
|
+
from pinecall.gateway import _deps
|
|
15
|
+
from pinecall.gateway._deps import Acting, EvalsKey, GatewayDep, ScopeDep
|
|
16
|
+
from pinecall.gateway._gateway import Gateway
|
|
17
|
+
from pinecall.log import queries
|
|
18
|
+
from pinecall.tenancy.keys import check_agent
|
|
19
|
+
from pinecall.wire.frames import Entry
|
|
20
|
+
from pinecall.wire.rest.evals import (
|
|
21
|
+
CaseDecision,
|
|
22
|
+
CaseStatus,
|
|
23
|
+
EvalCase,
|
|
24
|
+
EvalCaseList,
|
|
25
|
+
Expect,
|
|
26
|
+
Golden,
|
|
27
|
+
PromoteCaseRequest,
|
|
28
|
+
)
|
|
29
|
+
from pinecall.wire.scores import CallScore
|
|
30
|
+
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
STILL_GOING = "call {call} is still going: a case is made of a call that ended"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GoldenQuery(BaseModel):
|
|
38
|
+
"""What a call read as a golden is named, and the seq it is cut at."""
|
|
39
|
+
|
|
40
|
+
name: str | None = Field(default=None, min_length=1)
|
|
41
|
+
from_seq: int = Field(default=0, ge=0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# The case is the org's, played in the sandbox whichever world its call ran in; the key must be
|
|
45
|
+
# one that reads the call, as a replay's does. An expect left empty is the one the call's broken
|
|
46
|
+
# verdicts give.
|
|
47
|
+
@router.post("/v1/evals/cases", response_model_exclude_unset=True)
|
|
48
|
+
async def promote_case(
|
|
49
|
+
body: PromoteCaseRequest, key: EvalsKey, scope: ScopeDep, gateway: GatewayDep
|
|
50
|
+
) -> EvalCase:
|
|
51
|
+
"""A finished call's caller lines kept as a case of the org's dataset."""
|
|
52
|
+
entries, agent, env, version = await _finished(gateway, key, scope, body.call)
|
|
53
|
+
score = _score_in(entries)
|
|
54
|
+
expect = body.expect
|
|
55
|
+
if expect == Expect() and score is not None:
|
|
56
|
+
expect = dataset.expect_of(score.judges, entries)
|
|
57
|
+
golden = dataset.golden_of(entries, body.name, expect)
|
|
58
|
+
promoting = Promoted(
|
|
59
|
+
org=key.org,
|
|
60
|
+
env=env,
|
|
61
|
+
name=body.name,
|
|
62
|
+
author=key.bearer.key.subject or key.bearer.key.key_id,
|
|
63
|
+
held_out=body.held_out,
|
|
64
|
+
broke=[] if score is None else dataset.broke_of(score),
|
|
65
|
+
source_version=version,
|
|
66
|
+
)
|
|
67
|
+
return await dataset.promoted(gateway.connections.pool, golden, agent, promoting)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# What `pinecall runs promote` writes to test/candidates: the same golden a case would hold,
|
|
71
|
+
# derived here once, kept nowhere.
|
|
72
|
+
@router.get("/v1/calls/{call}/golden", response_model_exclude_unset=True)
|
|
73
|
+
async def golden_of_call(
|
|
74
|
+
call: Annotated[str, Path()],
|
|
75
|
+
key: EvalsKey,
|
|
76
|
+
scope: ScopeDep,
|
|
77
|
+
gateway: GatewayDep,
|
|
78
|
+
query: Annotated[GoldenQuery, Query()],
|
|
79
|
+
) -> Golden:
|
|
80
|
+
"""The golden a finished call makes from a seq on, its expect from its broken verdicts."""
|
|
81
|
+
entries, _, _, _ = await _finished(gateway, key, scope, call)
|
|
82
|
+
score = _score_in(entries)
|
|
83
|
+
expect = Expect() if score is None else dataset.expect_of(score.judges, entries)
|
|
84
|
+
cut = dataset.cut_at(entries, query.from_seq)
|
|
85
|
+
return dataset.golden_of(cut, query.name or call, expect)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/v1/evals/cases", response_model_exclude_unset=True)
|
|
89
|
+
async def list_cases(
|
|
90
|
+
key: EvalsKey,
|
|
91
|
+
gateway: GatewayDep,
|
|
92
|
+
agent: Annotated[str | None, Query()] = None,
|
|
93
|
+
status: Annotated[CaseStatus | None, Query()] = None,
|
|
94
|
+
) -> EvalCaseList:
|
|
95
|
+
"""The org's cases, one agent's or every one, the pending first; how many wait, of how many."""
|
|
96
|
+
if agent is not None:
|
|
97
|
+
check_agent(key.bearer, agent)
|
|
98
|
+
pool = gateway.connections.pool
|
|
99
|
+
return EvalCaseList(
|
|
100
|
+
cases=await dataset.listed(pool, key.org, agent, status),
|
|
101
|
+
pending=await dataset.waiting(pool, key.org, agent),
|
|
102
|
+
pending_at_most=dataset.PENDING_AT_MOST,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# The judge called wrong is a calibration label in the call's own world, so the key must read the
|
|
107
|
+
# call as the calibration door's does.
|
|
108
|
+
@router.patch("/v1/evals/cases/{id}", response_model_exclude_unset=True)
|
|
109
|
+
async def decide_case(
|
|
110
|
+
case: Annotated[str, Path(alias="id")],
|
|
111
|
+
body: CaseDecision,
|
|
112
|
+
key: EvalsKey,
|
|
113
|
+
scope: ScopeDep,
|
|
114
|
+
gateway: GatewayDep,
|
|
115
|
+
) -> EvalCase:
|
|
116
|
+
"""Approve, dismiss, hold out or mark a case as kept in the repository."""
|
|
117
|
+
pool = gateway.connections.pool
|
|
118
|
+
kept = await dataset.found(pool, key.org, case)
|
|
119
|
+
check_agent(key.bearer, kept.agent)
|
|
120
|
+
dataset.check_decision(kept, body)
|
|
121
|
+
author = key.bearer.key.subject or key.bearer.key.key_id
|
|
122
|
+
if body.judge_was_wrong is not None:
|
|
123
|
+
reader = _deps.Reader(acting=key, scope=scope)
|
|
124
|
+
await _deps.check_readable(gateway, reader, kept.source_call)
|
|
125
|
+
where = Where(org=key.org, env=kept.source_env, agent=kept.agent)
|
|
126
|
+
label = Label(
|
|
127
|
+
call=kept.source_call,
|
|
128
|
+
judge=body.judge_was_wrong,
|
|
129
|
+
held=True,
|
|
130
|
+
author=author,
|
|
131
|
+
note=body.note,
|
|
132
|
+
)
|
|
133
|
+
await calibration.labelled(pool, where, label)
|
|
134
|
+
return await dataset.decided(pool, key.org, case, body, author)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.delete("/v1/evals/cases/{id}", status_code=204)
|
|
138
|
+
async def forget_case(
|
|
139
|
+
case: Annotated[str, Path(alias="id")], key: EvalsKey, gateway: GatewayDep
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Forget one of the org's cases; another org's, or nobody's, is the same 404."""
|
|
142
|
+
await dataset.forgotten(gateway.connections.pool, key.org, case)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _finished(
|
|
146
|
+
gateway: Gateway, key: Acting, scope: Scope, call: str
|
|
147
|
+
) -> tuple[list[Entry], str, Env, int | None]:
|
|
148
|
+
"""A call the key reads and that ended: its log, its agent, its world, its settings version."""
|
|
149
|
+
await _deps.check_readable(gateway, _deps.Reader(acting=key, scope=scope), call)
|
|
150
|
+
kept = await queries.scope_of_call(gateway.connections.pool, call)
|
|
151
|
+
entries = await gateway.logs.store.whole(call)
|
|
152
|
+
if kept is None or kept.scope is None or not entries:
|
|
153
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
154
|
+
check_agent(key.bearer, kept.agent)
|
|
155
|
+
if not any(entry.type == "call.ended" for entry in entries):
|
|
156
|
+
raise Conflict(STILL_GOING.format(call=call))
|
|
157
|
+
return list(entries), kept.agent, kept.scope.env, kept.versions.config
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _score_in(entries: list[Entry]) -> CallScore | None:
|
|
161
|
+
"""The call's last call.score, the verdict a judge may write again after the seal."""
|
|
162
|
+
found = [entry for entry in entries if entry.type == "call.score"]
|
|
163
|
+
return None if not found else CallScore.model_validate(found[-1].data)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The supervisor's desk: listening in, taking a seat, and the verbs sent to a live call."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.errors import (
|
|
6
|
+
Conflict,
|
|
7
|
+
NotAllowed,
|
|
8
|
+
NotFound,
|
|
9
|
+
)
|
|
10
|
+
from pinecall.domain.person import RoomScope
|
|
11
|
+
from pinecall.gateway import _deps
|
|
12
|
+
from pinecall.gateway._deps import Acting, GatewayDep, Reader, ReaderDep
|
|
13
|
+
from pinecall.gateway._gateway import Gateway
|
|
14
|
+
from pinecall.gateway.calls.commands import SUPERVISOR_VERB, commanded
|
|
15
|
+
from pinecall.log import queries
|
|
16
|
+
from pinecall.tenancy import keys, reads, tokens
|
|
17
|
+
from pinecall.tenancy.reads import Read
|
|
18
|
+
from pinecall.wire.commands import SupervisorVerb, Verb
|
|
19
|
+
from pinecall.wire.frames import Command
|
|
20
|
+
from pinecall.wire.parts import Supervisor
|
|
21
|
+
from pinecall.wire.rest.calls import (
|
|
22
|
+
ReadKind,
|
|
23
|
+
SeatResponse,
|
|
24
|
+
VerbResponse,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
IS_OVER = "call {call} is over: read its log or its recording instead"
|
|
31
|
+
|
|
32
|
+
READS_ONLY = "this token reads the call and sends no verb: steering it takes a supervise token"
|
|
33
|
+
|
|
34
|
+
A_KEY = "key:{org}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post("/v1/calls/{call}/listen")
|
|
38
|
+
async def listen(call: str, key: _deps.SuperviseKey, gateway: GatewayDep) -> SeatResponse:
|
|
39
|
+
"""A hidden seat that hears one live call."""
|
|
40
|
+
return await _seated(gateway, key, call, "observe", "listen")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Not hidden: livekit delivers no track of a hidden seat, and a takeover would be silent.
|
|
44
|
+
@router.post("/v1/calls/{call}/supervise")
|
|
45
|
+
async def supervise(call: str, key: _deps.SuperviseKey, gateway: GatewayDep) -> SeatResponse:
|
|
46
|
+
"""A seat that speaks in one live call; its token also sends the verbs."""
|
|
47
|
+
return await _seated(gateway, key, call, "supervise", "supervise")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The body never says who sent it: the credential does.
|
|
51
|
+
@router.post("/v1/calls/{call}/verbs", status_code=202)
|
|
52
|
+
async def queue_verb(
|
|
53
|
+
call: str, verb: Verb, reading: ReaderDep, gateway: GatewayDep
|
|
54
|
+
) -> VerbResponse:
|
|
55
|
+
"""One supervise verb on a live call; the call's log says what it did."""
|
|
56
|
+
if reading.visit is not None and reading.visit.scope != "supervise":
|
|
57
|
+
raise NotAllowed(READS_ONLY)
|
|
58
|
+
if reading.acting is not None:
|
|
59
|
+
keys.check_opens(reading.acting.bearer, "supervise")
|
|
60
|
+
await _deps.check_readable(gateway, reading, call)
|
|
61
|
+
state = await gateway.logs.reading(call).snapshot()
|
|
62
|
+
if state.seq == 0:
|
|
63
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
64
|
+
if await gateway.logs.store.sealed(call):
|
|
65
|
+
raise Conflict(IS_OVER.format(call=call))
|
|
66
|
+
wanted = SupervisorVerb(by=_who(reading), verb=verb)
|
|
67
|
+
served = gateway.live.calls.get(call)
|
|
68
|
+
if served is not None and served.session is not None:
|
|
69
|
+
await served.session.supervise(wanted)
|
|
70
|
+
return VerbResponse(call=call, verb=verb.verb, seq=None)
|
|
71
|
+
# Whichever gateway runs the call's worker stream or its session takes it.
|
|
72
|
+
sent = Command(type=SUPERVISOR_VERB, agent=state.agent, call=call, data=wanted.written())
|
|
73
|
+
if not await commanded(gateway.connections.signal, sent):
|
|
74
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
75
|
+
return VerbResponse(call=call, verb=verb.verb, seq=None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# A seat hears the call live: it is recorded as a read of the call, once it is handed out.
|
|
79
|
+
async def _seated(
|
|
80
|
+
gateway: Gateway, key: Acting, call: str, scope: RoomScope, what: ReadKind
|
|
81
|
+
) -> SeatResponse:
|
|
82
|
+
kept = await queries.scope_of_call(gateway.connections.pool, call)
|
|
83
|
+
if kept is None or kept.scope is None or kept.scope.org != key.org:
|
|
84
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
85
|
+
keys.check_agent(key.bearer, kept.agent)
|
|
86
|
+
if await gateway.logs.store.sealed(call):
|
|
87
|
+
raise Conflict(IS_OVER.format(call=call))
|
|
88
|
+
seat = tokens.seat(gateway.signer, call, scope, key.bearer)
|
|
89
|
+
read = Read(call, what, _deps.asked_by(key))
|
|
90
|
+
await reads.record(gateway.connections.pool, kept.scope, read)
|
|
91
|
+
return SeatResponse(
|
|
92
|
+
server_url=gateway.connections.settings.browser_livekit_url(kept.scope.env),
|
|
93
|
+
participant_token=seat.token,
|
|
94
|
+
call=call,
|
|
95
|
+
identity=seat.identity,
|
|
96
|
+
org=key.org,
|
|
97
|
+
subject=seat.subject,
|
|
98
|
+
name=seat.name,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _who(reading: Reader) -> Supervisor:
|
|
103
|
+
if reading.visit is not None:
|
|
104
|
+
if reading.visit.subject is not None:
|
|
105
|
+
return Supervisor(id=reading.visit.subject, name=reading.visit.name)
|
|
106
|
+
return Supervisor(id=reading.visit.identity or "")
|
|
107
|
+
if reading.acting is None:
|
|
108
|
+
return Supervisor(id="")
|
|
109
|
+
bearer = reading.acting.bearer
|
|
110
|
+
if bearer.key.subject is not None:
|
|
111
|
+
name = bearer.member.name if bearer.member is not None else bearer.key.name
|
|
112
|
+
return Supervisor(id=bearer.key.subject, name=name)
|
|
113
|
+
return Supervisor(id=A_KEY.format(org=bearer.key.org))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""A worker's batches of a call on one socket for its life, each answered with the seqs it got."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.errors import NotSignedIn, PinecallError
|
|
9
|
+
from pinecall.gateway import _deps
|
|
10
|
+
from pinecall.gateway._deps import Acting
|
|
11
|
+
from pinecall.gateway._gateway import Gateway
|
|
12
|
+
from pinecall.gateway._served import Served
|
|
13
|
+
from pinecall.gateway.api.calls import check_kind, counted, known
|
|
14
|
+
from pinecall.tenancy import keys
|
|
15
|
+
from pinecall.wire.rest.calls import (
|
|
16
|
+
AppendEntriesRefused,
|
|
17
|
+
AppendEntriesRequest,
|
|
18
|
+
AppendEntriesResponse,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# A request per batch cost the gateway its routing, its headers and its key each time; on the
|
|
27
|
+
# socket a batch is one frame in and one out, and the log's answer to a retry is the same as the
|
|
28
|
+
# batch door's. A refusal is a frame too, and the socket stays: a refused batch never ends a call.
|
|
29
|
+
@router.websocket("/v1/calls/{call}/entries")
|
|
30
|
+
async def entries_socket(websocket: WebSocket, call: str) -> None:
|
|
31
|
+
"""A worker's batches of a call, on one socket for its life, each answered with its seqs."""
|
|
32
|
+
gateway = _deps.gateway_of(websocket)
|
|
33
|
+
await websocket.accept()
|
|
34
|
+
try:
|
|
35
|
+
served = await _served(websocket, gateway, call)
|
|
36
|
+
except PinecallError as refused:
|
|
37
|
+
await _refused(websocket, refused)
|
|
38
|
+
await websocket.close(code=_deps.POLICY_VIOLATION, reason=_deps.close_reason(str(refused)))
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
while True:
|
|
42
|
+
raw = await websocket.receive_text()
|
|
43
|
+
await _answered(websocket, gateway, served, call, raw)
|
|
44
|
+
except WebSocketDisconnect:
|
|
45
|
+
logger.debug("call %s: its worker's socket closed", call)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _served(websocket: WebSocket, gateway: Gateway, call: str) -> Served:
|
|
49
|
+
data = _deps.bearer_of(websocket.headers)
|
|
50
|
+
verified = None if data is None else await gateway.keys.verify(data)
|
|
51
|
+
if verified is None:
|
|
52
|
+
raise NotSignedIn(_deps.TAKES_A_KEY)
|
|
53
|
+
keys.check_opens(verified, "app", "fleet")
|
|
54
|
+
key = Acting(bearer=verified, env=_deps.world_of_request(websocket, verified))
|
|
55
|
+
return await known(gateway, key, call)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _answered(
|
|
59
|
+
websocket: WebSocket, gateway: Gateway, served: Served, call: str, raw: str
|
|
60
|
+
) -> None:
|
|
61
|
+
try:
|
|
62
|
+
body = AppendEntriesRequest.read_json(raw, "entries")
|
|
63
|
+
for item in body.entries:
|
|
64
|
+
check_kind(item.type)
|
|
65
|
+
gateway.live.in_use(call, time.monotonic())
|
|
66
|
+
began = time.perf_counter()
|
|
67
|
+
entries = await served.log.append_many(body.entries, after=body.after)
|
|
68
|
+
counted(gateway, time.perf_counter() - began, entries)
|
|
69
|
+
except PinecallError as refused:
|
|
70
|
+
await _refused(websocket, refused)
|
|
71
|
+
return
|
|
72
|
+
await websocket.send_text(AppendEntriesResponse(entries=entries).written_json())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _refused(websocket: WebSocket, refused: PinecallError) -> None:
|
|
76
|
+
frame = AppendEntriesRefused(refused=str(refused), status=refused.status)
|
|
77
|
+
await websocket.send_json(frame.written())
|