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
pinecall/gateway/app.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""The gateway process: wired once at start, its doors, the one answer to a refusal, its pages."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import AsyncGenerator
|
|
7
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
11
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
12
|
+
from psycopg.errors import QueryCanceled
|
|
13
|
+
from psycopg_pool import PoolTimeout
|
|
14
|
+
from starlette.datastructures import Headers
|
|
15
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
16
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
17
|
+
|
|
18
|
+
from pinecall.channels.telephony.sip import rebuilt_until_whole
|
|
19
|
+
from pinecall.domain.errors import (
|
|
20
|
+
DeclarationRefused,
|
|
21
|
+
NotAvailable,
|
|
22
|
+
NotSignedIn,
|
|
23
|
+
PinecallError,
|
|
24
|
+
SettingsRefused,
|
|
25
|
+
StoreUnreachable,
|
|
26
|
+
Throttled,
|
|
27
|
+
)
|
|
28
|
+
from pinecall.evals.runs import Runner
|
|
29
|
+
from pinecall.fleet.roster import Roster
|
|
30
|
+
from pinecall.gateway import _deps
|
|
31
|
+
from pinecall.gateway._gateway import Gateway
|
|
32
|
+
from pinecall.gateway._served import ServedCalls, Serving
|
|
33
|
+
from pinecall.gateway._sockets import Sockets
|
|
34
|
+
from pinecall.gateway.api import (
|
|
35
|
+
accounts,
|
|
36
|
+
agents,
|
|
37
|
+
apps,
|
|
38
|
+
box,
|
|
39
|
+
calibration,
|
|
40
|
+
callbacks,
|
|
41
|
+
calls,
|
|
42
|
+
chat,
|
|
43
|
+
dataset,
|
|
44
|
+
desk,
|
|
45
|
+
entries,
|
|
46
|
+
evals,
|
|
47
|
+
fleet,
|
|
48
|
+
hosting,
|
|
49
|
+
judges,
|
|
50
|
+
keys,
|
|
51
|
+
line,
|
|
52
|
+
members,
|
|
53
|
+
metrics,
|
|
54
|
+
numbers,
|
|
55
|
+
ops,
|
|
56
|
+
org,
|
|
57
|
+
personas,
|
|
58
|
+
pipeline,
|
|
59
|
+
prompts,
|
|
60
|
+
providers,
|
|
61
|
+
recordings,
|
|
62
|
+
relay,
|
|
63
|
+
retrieval,
|
|
64
|
+
runner,
|
|
65
|
+
sessions,
|
|
66
|
+
settings,
|
|
67
|
+
signup,
|
|
68
|
+
sso_login,
|
|
69
|
+
threads,
|
|
70
|
+
usage,
|
|
71
|
+
visitors,
|
|
72
|
+
whatsapp,
|
|
73
|
+
widget,
|
|
74
|
+
)
|
|
75
|
+
from pinecall.gateway.api.providers import SAMPLES_A_MINUTE
|
|
76
|
+
from pinecall.gateway.calls.threads import Threads
|
|
77
|
+
from pinecall.gateway.dispatching.sweep import sweep_forever
|
|
78
|
+
from pinecall.gateway.ending.reaper import reap_forever
|
|
79
|
+
from pinecall.log.logs import Logs
|
|
80
|
+
from pinecall.log.store import Store
|
|
81
|
+
from pinecall.postgres.pool import TIMEOUTS, box_task
|
|
82
|
+
from pinecall.process.connections import Connections, opened
|
|
83
|
+
from pinecall.process.settings import Settings, load
|
|
84
|
+
from pinecall.providers import catalog
|
|
85
|
+
from pinecall.retrieval.embed import Embedder
|
|
86
|
+
from pinecall.tenancy.codes import Codes
|
|
87
|
+
from pinecall.tenancy.knocks import Throttle
|
|
88
|
+
from pinecall.tenancy.mail import Mailbox, Outbox, parse_mailbox_url
|
|
89
|
+
from pinecall.tenancy.remembered import RememberedKeys
|
|
90
|
+
from pinecall.tenancy.signin import SignIns
|
|
91
|
+
from pinecall.tenancy.throttle import Window
|
|
92
|
+
from pinecall.tenancy.tokens import Signer
|
|
93
|
+
from pinecall.tenancy.vault import box_credentials
|
|
94
|
+
from pinecall.tenancy.words import Words
|
|
95
|
+
|
|
96
|
+
logger = logging.getLogger(__name__)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
NO_EMBEDDER = "no providers row: the gateway starts with no embedder"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
NO_EMBEDDER_KEY = "the providers row embeds with %s and the box holds no key for it: no embedder"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
NO_LIVEKIT = "LIVEKIT_API_KEY and LIVEKIT_API_SECRET: the gateway signs every room token with them"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
NO_SIGNING = "PINECALL_TOKEN_KEY: the gateway signs its log and code tokens with it"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
NO_BOX_MAIL = "PINECALL_SMTP_URL does not read (%s): the box posts no letter of its own"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# The two ways a database too busy to answer now shows; a worker outlasts a 503 and retries.
|
|
115
|
+
NO_CONNECTION = "the database is busy: no connection came free within {wait:g} s; try again"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
TOO_SLOW = "the database took more than {took:g} s to answer; try again"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Capacitor's WebView origins: iOS serves from capacitor://localhost, Android from https://localhost.
|
|
122
|
+
THE_APPS_WEBVIEWS = ("capacitor://localhost", "https://localhost")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
DOORS = "/v1/"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# A bearer, never a cookie: Allow-Credentials is never sent.
|
|
132
|
+
ALLOWED_HEADERS = ("authorization", "content-type", _deps.WORLD, _deps.LOOKING_AT, "last-event-id")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
PREFLIGHT_KEPT_S = 600
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# The most any request carries, above every door's own cap (a hold melody 20 MiB, a hosted app's
|
|
139
|
+
# source 10 MiB): a body past it is refused as it streams in, never held whole in memory, and the
|
|
140
|
+
# webhooks that read a body to check its signature read at most this much of an unsigned one.
|
|
141
|
+
LARGEST_BODY = 32 * 1024 * 1024
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
TOO_LARGE = "the body is over {most} MiB, more than any door takes"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# A token of one call reads its own doors from any page: GET only, no credentials.
|
|
148
|
+
A_PAGES_READS = re.compile(r"^/v1/(calls/[^/]+/(events|state|recording)|codes/[^/]+)$")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
READ_HEADERS = ("authorization", "last-event-id", "accept", "range")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
READ_EXPOSED = ("content-range", "accept-ranges", "content-length")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# The console and the widget the wheel carries (scripts/hatch_build.py puts them here).
|
|
158
|
+
BUILT = Path(__file__).resolve().parents[1] / "public"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
THE_PAGE = "index.html"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
API_PREFIXES = ("v1/", ".well-known/")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
NOT_BUILT = "the console is not built into this gateway: `make deploy` builds it in"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
WIDGET_HEADERS = {"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=300"}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# The console is framed by nobody (a page that framed it could dress a click as another), reached
|
|
174
|
+
# over HTTPS alone once a browser has seen it, read as the type it is served as, and tells another
|
|
175
|
+
# site no more of where it came from than its origin (a sign-in lands with a code in the query).
|
|
176
|
+
PAGE_HEADERS = {
|
|
177
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
178
|
+
"X-Frame-Options": "DENY",
|
|
179
|
+
"Strict-Transport-Security": "max-age=31536000",
|
|
180
|
+
"X-Content-Type-Options": "nosniff",
|
|
181
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
ROUTERS = (
|
|
186
|
+
accounts,
|
|
187
|
+
agents,
|
|
188
|
+
apps,
|
|
189
|
+
box,
|
|
190
|
+
calibration,
|
|
191
|
+
callbacks,
|
|
192
|
+
calls,
|
|
193
|
+
chat,
|
|
194
|
+
dataset,
|
|
195
|
+
desk,
|
|
196
|
+
entries,
|
|
197
|
+
evals,
|
|
198
|
+
fleet,
|
|
199
|
+
hosting,
|
|
200
|
+
judges,
|
|
201
|
+
keys,
|
|
202
|
+
line,
|
|
203
|
+
members,
|
|
204
|
+
metrics,
|
|
205
|
+
numbers,
|
|
206
|
+
ops,
|
|
207
|
+
org,
|
|
208
|
+
personas,
|
|
209
|
+
pipeline,
|
|
210
|
+
prompts,
|
|
211
|
+
providers,
|
|
212
|
+
recordings,
|
|
213
|
+
relay,
|
|
214
|
+
retrieval,
|
|
215
|
+
runner,
|
|
216
|
+
sessions,
|
|
217
|
+
settings,
|
|
218
|
+
signup,
|
|
219
|
+
sso_login,
|
|
220
|
+
threads,
|
|
221
|
+
usage,
|
|
222
|
+
visitors,
|
|
223
|
+
whatsapp,
|
|
224
|
+
widget,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Read per request: the settings are loaded by the lifespan, after the middleware is built.
|
|
229
|
+
class AppOrigins:
|
|
230
|
+
"""CORS on /v1 for the allowed origins; any page may read what a call's token reads."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
233
|
+
"""Nothing allowed until the settings say what."""
|
|
234
|
+
self.app = app
|
|
235
|
+
self.cors: tuple[tuple[str, ...], CORSMiddleware] | None = None
|
|
236
|
+
self.any_page = CORSMiddleware(
|
|
237
|
+
app,
|
|
238
|
+
allow_origins=("*",),
|
|
239
|
+
allow_methods=("GET",),
|
|
240
|
+
allow_headers=READ_HEADERS,
|
|
241
|
+
expose_headers=READ_EXPOSED,
|
|
242
|
+
max_age=PREFLIGHT_KEPT_S,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
246
|
+
"""Route an allowed origin's /v1 request through CORS, and anything else straight in."""
|
|
247
|
+
if scope["type"] != "http" or not str(scope["path"]).startswith(DOORS):
|
|
248
|
+
await self.app(scope, receive, send)
|
|
249
|
+
return
|
|
250
|
+
origin = Headers(scope=scope).get("origin")
|
|
251
|
+
gateway: object = getattr(scope["app"].state, "gateway", None)
|
|
252
|
+
allowed = (
|
|
253
|
+
origins_allowed(gateway.connections.settings)
|
|
254
|
+
if isinstance(gateway, Gateway) and origin
|
|
255
|
+
else ()
|
|
256
|
+
)
|
|
257
|
+
if origin and origin not in allowed and A_PAGES_READS.match(str(scope["path"])):
|
|
258
|
+
await self.any_page(scope, receive, send)
|
|
259
|
+
return
|
|
260
|
+
if not origin or origin not in allowed:
|
|
261
|
+
await self.app(scope, receive, send)
|
|
262
|
+
return
|
|
263
|
+
await self._cors_for(allowed)(scope, receive, send)
|
|
264
|
+
|
|
265
|
+
def _cors_for(self, allowed: tuple[str, ...]) -> CORSMiddleware:
|
|
266
|
+
if self.cors is None or self.cors[0] != allowed:
|
|
267
|
+
cors = CORSMiddleware(
|
|
268
|
+
self.app,
|
|
269
|
+
allow_origins=allowed,
|
|
270
|
+
allow_methods=METHODS,
|
|
271
|
+
allow_headers=ALLOWED_HEADERS,
|
|
272
|
+
max_age=PREFLIGHT_KEPT_S,
|
|
273
|
+
)
|
|
274
|
+
self.cors = (allowed, cors)
|
|
275
|
+
return self.cors[1]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# Said by Content-Length, refused at once; said by nothing (chunked), counted as it comes and
|
|
279
|
+
# answered 413 the chunk it passes the most. The door is then told the client left, so it stops
|
|
280
|
+
# reading, and whatever it answers after is dropped: the 413 is the one answer.
|
|
281
|
+
class BodyCeiling:
|
|
282
|
+
"""No request's body is over LARGEST_BODY."""
|
|
283
|
+
|
|
284
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
285
|
+
"""The app it stands in front of."""
|
|
286
|
+
self.app = app
|
|
287
|
+
|
|
288
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
289
|
+
"""Pass the request on, its body counted."""
|
|
290
|
+
if scope["type"] != "http":
|
|
291
|
+
await self.app(scope, receive, send)
|
|
292
|
+
return
|
|
293
|
+
too_large = JSONResponse(
|
|
294
|
+
{"detail": TOO_LARGE.format(most=LARGEST_BODY // (1024 * 1024))}, status_code=413
|
|
295
|
+
)
|
|
296
|
+
declared = Headers(scope=scope).get("content-length")
|
|
297
|
+
if declared is not None and declared.isdigit() and int(declared) > LARGEST_BODY:
|
|
298
|
+
await too_large(scope, receive, send)
|
|
299
|
+
return
|
|
300
|
+
counted = 0
|
|
301
|
+
answered = False
|
|
302
|
+
|
|
303
|
+
async def counting() -> Message:
|
|
304
|
+
nonlocal counted, answered
|
|
305
|
+
message = await receive()
|
|
306
|
+
counted += len(message.get("body", b""))
|
|
307
|
+
if counted > LARGEST_BODY and not answered:
|
|
308
|
+
answered = True
|
|
309
|
+
await too_large(scope, receive, send)
|
|
310
|
+
return {"type": "http.disconnect"} if answered else message
|
|
311
|
+
|
|
312
|
+
async def unless_answered(message: Message) -> None:
|
|
313
|
+
if not answered:
|
|
314
|
+
await send(message)
|
|
315
|
+
|
|
316
|
+
await self.app(scope, counting, unless_answered)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# Everything opened goes on the stack: a failed start closes what opened, a stop closes it all.
|
|
320
|
+
@asynccontextmanager
|
|
321
|
+
async def lifespan(fastapi_app: FastAPI) -> AsyncGenerator[None]:
|
|
322
|
+
"""Wire the box once, run the reaper, and close all of it after."""
|
|
323
|
+
settings = load()
|
|
324
|
+
async with AsyncExitStack() as stack:
|
|
325
|
+
gateway = await wire(settings, stack)
|
|
326
|
+
fastapi_app.state.gateway = gateway
|
|
327
|
+
reaper = box_task(reap_forever(gateway.serving, gateway.connections.servers))
|
|
328
|
+
stack.push_async_callback(_cancelled, reaper)
|
|
329
|
+
sweep = box_task(sweep_forever(gateway.offering))
|
|
330
|
+
stack.push_async_callback(_cancelled, sweep)
|
|
331
|
+
gateway.loops.update(reaper=reaper, sweep=sweep)
|
|
332
|
+
# After the start, so a slow SFU never keeps the gateway from answering; again until whole.
|
|
333
|
+
rebuilt = box_task(rebuilt_until_whole(gateway.connections))
|
|
334
|
+
stack.push_async_callback(_cancelled, rebuilt)
|
|
335
|
+
yield
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def wire(settings: Settings, stack: AsyncExitStack) -> Gateway:
|
|
339
|
+
"""Open the connections, then what the gateway keeps in memory; the reverse at the end."""
|
|
340
|
+
connections = await stack.enter_async_context(opened(settings))
|
|
341
|
+
if not settings.livekit_api_key or not settings.livekit_api_secret:
|
|
342
|
+
raise SettingsRefused(NO_LIVEKIT)
|
|
343
|
+
if not settings.token_key:
|
|
344
|
+
raise SettingsRefused(NO_SIGNING)
|
|
345
|
+
logs = Logs(Store(connections.pool, writing=connections.writing), connections.signal)
|
|
346
|
+
# Pushed before everything that appends on its way out, so it runs after them and before the
|
|
347
|
+
# pool closes: every append a request was promised is written.
|
|
348
|
+
stack.push_async_callback(logs.store.writer.drained)
|
|
349
|
+
stack.push_async_callback(logs.close)
|
|
350
|
+
codes = Codes(logs)
|
|
351
|
+
sockets, live = Sockets(logs), ServedCalls(connections.signal)
|
|
352
|
+
await sockets.start()
|
|
353
|
+
stack.push_async_callback(sockets.close)
|
|
354
|
+
await live.listen()
|
|
355
|
+
stack.push_async_callback(live.quiet)
|
|
356
|
+
embedder = await embedder_of(connections)
|
|
357
|
+
serving = Serving(connections=connections, logs=logs, live=live, embedder=embedder)
|
|
358
|
+
threads = Threads(serving, sockets)
|
|
359
|
+
await threads.loaded()
|
|
360
|
+
await threads.start()
|
|
361
|
+
stack.push_async_callback(threads.closed)
|
|
362
|
+
roster = Roster(connections.signal)
|
|
363
|
+
await roster.start()
|
|
364
|
+
stack.push_async_callback(roster.close)
|
|
365
|
+
paced = Window(signal=connections.signal)
|
|
366
|
+
await paced.start()
|
|
367
|
+
stack.push_async_callback(paced.close)
|
|
368
|
+
outbox = Outbox(connections, _box_mailbox(settings))
|
|
369
|
+
stack.push_async_callback(outbox.drained)
|
|
370
|
+
remembered = RememberedKeys(connections.pool, connections.signal)
|
|
371
|
+
await remembered.start()
|
|
372
|
+
stack.push_async_callback(remembered.close)
|
|
373
|
+
return Gateway(
|
|
374
|
+
connections=connections,
|
|
375
|
+
logs=logs,
|
|
376
|
+
sockets=sockets,
|
|
377
|
+
live=live,
|
|
378
|
+
roster=roster,
|
|
379
|
+
codes=codes,
|
|
380
|
+
keys=remembered,
|
|
381
|
+
signer=Signer(settings.livekit_api_key, settings.livekit_api_secret, settings.token_key),
|
|
382
|
+
threads=threads,
|
|
383
|
+
closing=asyncio.Event(),
|
|
384
|
+
embedder=embedder,
|
|
385
|
+
signins=SignIns.kept(Words(connections.pool, connections.vault)),
|
|
386
|
+
samples=Throttle(connections.pool, SAMPLES_A_MINUTE),
|
|
387
|
+
paced=paced,
|
|
388
|
+
outbox=outbox,
|
|
389
|
+
evals=Runner(connections.pool),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# Set from the signal handler, through the loop: streams never end on their own, and a stop
|
|
394
|
+
# would otherwise wait out the grace period and cut them mid-frame.
|
|
395
|
+
def announce_closing(fastapi_app: FastAPI) -> None:
|
|
396
|
+
"""Tell every open stream the process is stopping."""
|
|
397
|
+
gateway: object = getattr(fastapi_app.state, "gateway", None)
|
|
398
|
+
if isinstance(gateway, Gateway):
|
|
399
|
+
asyncio.get_running_loop().call_soon_threadsafe(gateway.closing.set)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def refused(_request: Request, error: Exception) -> Response:
|
|
403
|
+
"""A refusal answered with its status and its sentence."""
|
|
404
|
+
status = error.status if isinstance(error, PinecallError) else 500
|
|
405
|
+
headers = {"WWW-Authenticate": "Bearer"} if isinstance(error, NotSignedIn) else None
|
|
406
|
+
if isinstance(error, Throttled):
|
|
407
|
+
headers = {"Retry-After": str(error.retry_after_s)}
|
|
408
|
+
return JSONResponse({"detail": str(error)}, status_code=status, headers=headers)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def busy(request: Request, error: Exception) -> Response:
|
|
412
|
+
"""A full pool or a statement past its timeout, answered 503 with its one sentence."""
|
|
413
|
+
if isinstance(error, PoolTimeout):
|
|
414
|
+
return await refused(request, StoreUnreachable(NO_CONNECTION.format(wait=TIMEOUTS.wait_s)))
|
|
415
|
+
took = TIMEOUTS.statement_ms / 1000
|
|
416
|
+
return await refused(request, StoreUnreachable(TOO_SLOW.format(took=took)))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def origins_allowed(settings: Settings) -> tuple[str, ...]:
|
|
420
|
+
"""The app's two WebViews, then PINECALL_APP_ORIGINS, each once."""
|
|
421
|
+
named = (item.strip() for item in settings.app_origins.split(","))
|
|
422
|
+
return tuple(dict.fromkeys((*THE_APPS_WEBVIEWS, *(item for item in named if item))))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def widget_file(file: str) -> FileResponse:
|
|
426
|
+
"""A file of the widget, for any site to load as a module."""
|
|
427
|
+
root = (BUILT / "widget").resolve()
|
|
428
|
+
params = (root / file).resolve()
|
|
429
|
+
if root not in params.parents or not params.is_file():
|
|
430
|
+
raise HTTPException(404, "Not Found")
|
|
431
|
+
return FileResponse(params, headers=WIDGET_HEADERS)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# The last route: a built file is itself, any other path is the page, so a reload lands where it
|
|
435
|
+
# was (the page reads its world from the path: `/sandbox/…` is the sandbox's). Never cached: a
|
|
436
|
+
# rebuild names new hashed assets in a new index.html.
|
|
437
|
+
def console(path: str) -> Response:
|
|
438
|
+
"""The console: an asset of its build, or its page."""
|
|
439
|
+
if path.startswith(API_PREFIXES):
|
|
440
|
+
raise HTTPException(404, "Not Found")
|
|
441
|
+
root = (BUILT / "console").resolve()
|
|
442
|
+
if not (root / THE_PAGE).is_file():
|
|
443
|
+
raise HTTPException(404, NOT_BUILT)
|
|
444
|
+
params = (root / path).resolve()
|
|
445
|
+
if path and root in params.parents and params.is_file() and params.name != THE_PAGE:
|
|
446
|
+
return FileResponse(params, headers=PAGE_HEADERS)
|
|
447
|
+
page = (root / THE_PAGE).read_text(encoding="utf-8")
|
|
448
|
+
return HTMLResponse(page, headers={**PAGE_HEADERS, "cache-control": "no-store"})
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# One gateway's app: a test serves two, each with a gateway of its own in its state. Swagger under
|
|
452
|
+
# /v1: the console has a /docs screen of its own. The console is the last route: a built file is
|
|
453
|
+
# itself, any other path is the page.
|
|
454
|
+
def served_app() -> FastAPI:
|
|
455
|
+
"""The gateway's doors, its refusals, the widget and the console, on a new app."""
|
|
456
|
+
served = FastAPI(
|
|
457
|
+
title="Pinecall gateway", lifespan=lifespan, docs_url="/v1/docs", redoc_url="/v1/redoc"
|
|
458
|
+
)
|
|
459
|
+
served.add_middleware(AppOrigins)
|
|
460
|
+
served.add_middleware(BodyCeiling)
|
|
461
|
+
for doors in ROUTERS:
|
|
462
|
+
served.include_router(doors.router)
|
|
463
|
+
served.add_exception_handler(PinecallError, refused)
|
|
464
|
+
served.add_exception_handler(PoolTimeout, busy)
|
|
465
|
+
served.add_exception_handler(QueryCanceled, busy)
|
|
466
|
+
served.add_api_route("/widget/{file}", widget_file, methods=["GET"], include_in_schema=False)
|
|
467
|
+
served.add_api_route("/{path:path}", console, methods=["GET"], include_in_schema=False)
|
|
468
|
+
return served
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# A box with no embedder still starts: every door that embeds says what the operator must set.
|
|
472
|
+
async def embedder_of(connections: Connections) -> Embedder | None:
|
|
473
|
+
"""The box's embedder from its providers row and key; None, and said, when it has none."""
|
|
474
|
+
try:
|
|
475
|
+
embedding = (await catalog.providers(connections.pool)).embedding
|
|
476
|
+
except NotAvailable:
|
|
477
|
+
logger.warning(NO_EMBEDDER)
|
|
478
|
+
return None
|
|
479
|
+
if embedding is None:
|
|
480
|
+
return None
|
|
481
|
+
key = (await box_credentials(connections.pool, connections.vault)).get(embedding.vendor)
|
|
482
|
+
if key is None:
|
|
483
|
+
logger.warning(NO_EMBEDDER_KEY, embedding.vendor)
|
|
484
|
+
return None
|
|
485
|
+
return Embedder(embedding, key, connections.http)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# A box without mail still starts: its letters are not sent, and /.well-known says so.
|
|
489
|
+
def _box_mailbox(settings: Settings) -> Mailbox | None:
|
|
490
|
+
if settings.smtp_url is None:
|
|
491
|
+
return None
|
|
492
|
+
try:
|
|
493
|
+
return parse_mailbox_url(settings.smtp_url, settings.mail_from or "")
|
|
494
|
+
except DeclarationRefused as unreadable:
|
|
495
|
+
logger.warning(NO_BOX_MAIL, unreadable)
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
async def _cancelled[T](task: asyncio.Task[T]) -> None:
|
|
500
|
+
task.cancel()
|
|
501
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
app = served_app()
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""A call handed to a socket: call.attached, then its pump; parked calls given to the next one."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.scope import Scope
|
|
6
|
+
from pinecall.gateway._served import CLAIMED, STARTED, ServedCalls
|
|
7
|
+
from pinecall.gateway._sockets import SocketId, Sockets
|
|
8
|
+
from pinecall.log.reduce import reduce
|
|
9
|
+
from pinecall.session.tools import unanswered
|
|
10
|
+
from pinecall.wire.events import CallAttached, CallStarted
|
|
11
|
+
from pinecall.wire.frames import Entry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def attach(live: ServedCalls, call: str, app: SocketId) -> Entry | None:
|
|
15
|
+
"""Give the call to this socket: call.attached first, then the tools still waiting."""
|
|
16
|
+
served = live.attach(call, app)
|
|
17
|
+
if served is None:
|
|
18
|
+
return None
|
|
19
|
+
entries = await served.log.whole()
|
|
20
|
+
started = next((entry for entry in entries if entry.type == STARTED), None)
|
|
21
|
+
if started is None:
|
|
22
|
+
# Not started yet: the socket hears it from what comes next.
|
|
23
|
+
live.pumped(call, after=entries[-1].seq if entries else 0)
|
|
24
|
+
return None
|
|
25
|
+
claimed = next(
|
|
26
|
+
(str(entry.data["code"]) for entry in reversed(entries) if entry.type == CLAIMED), None
|
|
27
|
+
)
|
|
28
|
+
data = CallAttached(
|
|
29
|
+
app=app,
|
|
30
|
+
started=CallStarted.model_validate(started.data),
|
|
31
|
+
state=reduce(entries).app_state,
|
|
32
|
+
seq=entries[-1].seq,
|
|
33
|
+
claimed=claimed,
|
|
34
|
+
)
|
|
35
|
+
entry = await served.log.append("call.attached", data.written())
|
|
36
|
+
# After call.attached, so the result lands on the call id still awaited: read off the log, since
|
|
37
|
+
# the worker may have asked them of another gateway.
|
|
38
|
+
live.pumped(call, after=entry.seq - 1, then=unanswered(entries))
|
|
39
|
+
return entry
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def parked_calls_of(live: ServedCalls, scope: Scope, slug: str, app: SocketId) -> None:
|
|
43
|
+
"""Give every parked call of the agent in the scope to this socket."""
|
|
44
|
+
for call in live.parked(scope, slug):
|
|
45
|
+
await attach(live, call, app)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Each call of a leaving socket goes where a new call would, or waits parked.
|
|
49
|
+
async def handed_on(live: ServedCalls, sockets: Sockets, calls: Iterable[str]) -> tuple[int, int]:
|
|
50
|
+
"""Hand the calls on; how many were handed and how many parked."""
|
|
51
|
+
handed = parked = 0
|
|
52
|
+
for call in calls:
|
|
53
|
+
served = live.calls.get(call)
|
|
54
|
+
# An ended call waits for its seal, which needs no socket.
|
|
55
|
+
if served is None or (await served.log.snapshot()).status == "ended":
|
|
56
|
+
continue
|
|
57
|
+
taking = sockets.serving(served.scope, served.agent, None)
|
|
58
|
+
if taking is not None and await attach(live, call, taking.owner) is not None:
|
|
59
|
+
handed += 1
|
|
60
|
+
else:
|
|
61
|
+
live.attach(call, None)
|
|
62
|
+
parked += 1
|
|
63
|
+
return handed, parked
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The app's and the desk's commands for a call, told to whichever gateway runs it."""
|
|
2
|
+
|
|
3
|
+
from pinecall.process.signal import Signal
|
|
4
|
+
from pinecall.wire.frames import Command
|
|
5
|
+
|
|
6
|
+
# One channel per call: every gateway that serves the call listens on it while it does.
|
|
7
|
+
COMMANDS_CHANNEL = "cmd:{call}"
|
|
8
|
+
|
|
9
|
+
# The one verb a desk sends, which a written call's session takes as a supervisor's.
|
|
10
|
+
SUPERVISOR_VERB = "supervisor.verb"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Lossy, as today's queue is on a gateway that dies: nobody hearing it is the refusal the sender
|
|
14
|
+
# gives, and a Redis that is away is a NotAvailable the app can try again after.
|
|
15
|
+
async def commanded(signal: Signal, command: Command) -> bool:
|
|
16
|
+
"""Tell the gateways that serve the call; False when none does."""
|
|
17
|
+
channel = COMMANDS_CHANNEL.format(call=command.call)
|
|
18
|
+
return await signal.published(channel, command.model_dump_json().encode()) > 0
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""What one gateway sends an app socket another holds, the answers back, and who holds which."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, TypeAdapter
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.errors import NotAvailable
|
|
8
|
+
from pinecall.domain.scope import Scope
|
|
9
|
+
from pinecall.process.shared import Shared
|
|
10
|
+
from pinecall.process.signal import Signal
|
|
11
|
+
from pinecall.wire.commands import DevAnswer
|
|
12
|
+
from pinecall.wire.frames import Entry
|
|
13
|
+
|
|
14
|
+
# Each app socket's inbox, listened to by the gateway holding it while it is open.
|
|
15
|
+
APP_CHANNEL = "app:{app}"
|
|
16
|
+
|
|
17
|
+
# Where a dev.request's answer goes back to the gateway that asked it.
|
|
18
|
+
ANSWER_CHANNEL = "answer:{id}"
|
|
19
|
+
|
|
20
|
+
# Every gateway says the app sockets it holds here, for the org's list of apps.
|
|
21
|
+
SOCKETS_CHANNEL = "sockets"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Bound(BaseModel):
|
|
25
|
+
"""A call bound to an app socket another gateway holds, and the seq its pump starts after."""
|
|
26
|
+
|
|
27
|
+
call: str
|
|
28
|
+
after: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ForApp(BaseModel):
|
|
32
|
+
"""One message for an app socket: a call bound to it, an entry to send it, or a stop."""
|
|
33
|
+
|
|
34
|
+
bound: Bound | None = None
|
|
35
|
+
entry: Entry | None = None
|
|
36
|
+
stop: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SocketRow:
|
|
41
|
+
"""An app socket another gateway holds, as the org's list of apps shows it."""
|
|
42
|
+
|
|
43
|
+
app: str
|
|
44
|
+
scope: Scope
|
|
45
|
+
address: str | None
|
|
46
|
+
connected_at: float
|
|
47
|
+
host: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Elsewhere:
|
|
51
|
+
"""The app sockets the other gateways hold, as each says them."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, signal: Signal) -> None:
|
|
54
|
+
"""Nothing heard yet."""
|
|
55
|
+
self.shared = Shared(signal, SOCKETS_CHANNEL, _ROWS, (), lambda: None)
|
|
56
|
+
|
|
57
|
+
async def start(self) -> None:
|
|
58
|
+
"""Hear the others, and say this gateway's."""
|
|
59
|
+
await self.shared.start()
|
|
60
|
+
|
|
61
|
+
async def close(self) -> None:
|
|
62
|
+
"""Stop hearing and saying."""
|
|
63
|
+
await self.shared.close()
|
|
64
|
+
|
|
65
|
+
def rows_of(self, org: str, env: str) -> list[SocketRow]:
|
|
66
|
+
"""The org's sockets in the world on every other gateway."""
|
|
67
|
+
return [
|
|
68
|
+
row
|
|
69
|
+
for heard in self.shared.theirs.values()
|
|
70
|
+
for row in heard.share
|
|
71
|
+
if row.scope.org == org and row.scope.env == env
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_ROWS: TypeAdapter[tuple[SocketRow, ...]] = TypeAdapter(tuple[SocketRow, ...])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Lossy, as the signal is: a bound call whose notice was lost is told again when its worker asks
|
|
79
|
+
# a tool, and parked calls are handed on at every pass.
|
|
80
|
+
def told_bound(signal: Signal, app: str, bound: Bound) -> None:
|
|
81
|
+
"""Tell the gateway holding the socket that the call is bound to it, from this seq."""
|
|
82
|
+
signal.publish(APP_CHANNEL.format(app=app), ForApp(bound=bound).model_dump_json().encode())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def told_app(signal: Signal, app: str, message: ForApp) -> bool:
|
|
86
|
+
"""Hand the message to the gateway holding the socket; False when none does."""
|
|
87
|
+
channel = APP_CHANNEL.format(app=app)
|
|
88
|
+
try:
|
|
89
|
+
return await signal.published(channel, message.model_dump_json().encode()) > 0
|
|
90
|
+
except NotAvailable:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def answered_back(signal: Signal, answer: DevAnswer) -> bool:
|
|
95
|
+
"""Hand a dev.answer to the gateway that asked it; False when none waits."""
|
|
96
|
+
channel = ANSWER_CHANNEL.format(id=answer.id)
|
|
97
|
+
try:
|
|
98
|
+
return await signal.published(channel, answer.model_dump_json().encode()) > 0
|
|
99
|
+
except NotAvailable:
|
|
100
|
+
return False
|