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,655 @@
|
|
|
1
|
+
"""The call doors a worker writes through and a reader reads: open, append, seal, the log."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Header, Query, Response
|
|
11
|
+
from fastapi.responses import StreamingResponse
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from pinecall.channels import offers, routes
|
|
15
|
+
from pinecall.channels.telephony import carrier
|
|
16
|
+
from pinecall.domain.agent import AgentConfig, Versions
|
|
17
|
+
from pinecall.domain.call import CallContext, Opener
|
|
18
|
+
from pinecall.domain.errors import (
|
|
19
|
+
Conflict,
|
|
20
|
+
DeclarationRefused,
|
|
21
|
+
NotAllowed,
|
|
22
|
+
NotFound,
|
|
23
|
+
)
|
|
24
|
+
from pinecall.domain.names import JsonObject
|
|
25
|
+
from pinecall.domain.person import THE_FLEET
|
|
26
|
+
from pinecall.domain.scope import Scope
|
|
27
|
+
from pinecall.gateway import _deps, _streams
|
|
28
|
+
from pinecall.gateway._call_setup import tuned
|
|
29
|
+
from pinecall.gateway._deps import (
|
|
30
|
+
Acting,
|
|
31
|
+
CallReaderDep,
|
|
32
|
+
GatewayDep,
|
|
33
|
+
Reader,
|
|
34
|
+
ReaderDep,
|
|
35
|
+
ScopeDep,
|
|
36
|
+
TeamKey,
|
|
37
|
+
WorkerKey,
|
|
38
|
+
asked_by,
|
|
39
|
+
)
|
|
40
|
+
from pinecall.gateway._gateway import Gateway
|
|
41
|
+
from pinecall.gateway._served import Served
|
|
42
|
+
from pinecall.gateway._sockets import NO_AGENT, NO_UNCLAIMED, NOT_THAT_APP, Registration
|
|
43
|
+
from pinecall.gateway._streams import frame, paced, streamed, wants_sse
|
|
44
|
+
from pinecall.gateway.calls.binding import attach
|
|
45
|
+
from pinecall.gateway.calls.serving import (
|
|
46
|
+
claim_code,
|
|
47
|
+
first_seen,
|
|
48
|
+
looked_up,
|
|
49
|
+
opened,
|
|
50
|
+
served_call,
|
|
51
|
+
serving_agent,
|
|
52
|
+
)
|
|
53
|
+
from pinecall.gateway.ending.seal import remembered, sealed
|
|
54
|
+
from pinecall.log import openings, queries
|
|
55
|
+
from pinecall.log.readers import Filter, parse_filter, project_entry, project_state
|
|
56
|
+
from pinecall.log.store import DEFAULT_LIMIT, Claim
|
|
57
|
+
from pinecall.process.recordings import (
|
|
58
|
+
recordings_of,
|
|
59
|
+
)
|
|
60
|
+
from pinecall.providers import catalog
|
|
61
|
+
from pinecall.providers.build import vendor_named_in
|
|
62
|
+
from pinecall.providers.catalog import judge_ceiling
|
|
63
|
+
from pinecall.session.call import ToolUse
|
|
64
|
+
from pinecall.tenancy import (
|
|
65
|
+
disclosure,
|
|
66
|
+
erasure,
|
|
67
|
+
keys,
|
|
68
|
+
orgs,
|
|
69
|
+
policy,
|
|
70
|
+
recording_keys,
|
|
71
|
+
tokens,
|
|
72
|
+
)
|
|
73
|
+
from pinecall.tenancy.scopes import Picked
|
|
74
|
+
from pinecall.wire.commands import CallClaim
|
|
75
|
+
from pinecall.wire.events import (
|
|
76
|
+
EVENTS,
|
|
77
|
+
GATEWAY_WRITES,
|
|
78
|
+
TERMINAL_EVENT,
|
|
79
|
+
ErrorEvent,
|
|
80
|
+
ToolCall,
|
|
81
|
+
)
|
|
82
|
+
from pinecall.wire.frames import Command, Entry
|
|
83
|
+
from pinecall.wire.parts import ToolResult
|
|
84
|
+
from pinecall.wire.rest.agents import JudgingSettings
|
|
85
|
+
from pinecall.wire.rest.calls import (
|
|
86
|
+
AppendEntriesRequest,
|
|
87
|
+
AppendEntriesResponse,
|
|
88
|
+
AppendEntryRequest,
|
|
89
|
+
Erasure,
|
|
90
|
+
LogPage,
|
|
91
|
+
LookupRequest,
|
|
92
|
+
LookupResponse,
|
|
93
|
+
OpenCallRequest,
|
|
94
|
+
OpenCallResponse,
|
|
95
|
+
RecordingKeyResponse,
|
|
96
|
+
RememberResponse,
|
|
97
|
+
SealCallRequest,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
router = APIRouter()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
logger = logging.getLogger(__name__)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
UNKNOWN_EVENT = "no event is called {kind!r}: the log takes the protocol's own words"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
THE_GATEWAYS = "{kind!r} is written by the gateway itself, never sent to it"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
NOT_OPEN = "this gateway is not writing call {call!r}: open it with POST /v1/calls first"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
SEALED = "call {call!r} is over: nothing more can be written to it"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
NOT_ITS_ACCOUNT = (
|
|
119
|
+
"{number} lives in another Twilio account than the one this call came from: refused"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
NOT_APPROVED = (
|
|
124
|
+
"{number} was hooked by the org and waits for the box's operator to approve it: no call to it "
|
|
125
|
+
"opens until then"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
SPENT = "token_spent"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
ERROR = "error"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
SWITCHED = "vendor.switched"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
ALREADY_SPENT = "call {call} was opened by its token already: a token opens one call, once"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
ELSEWHERE = "call %s was opened in org %s, %s, agent %s: its token was minted for another"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
UNOPENED_KEY = "the key of call {call}'s recording is sealed under a key the vault no longer lists"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
STILL_LIVE = "call {call} is still running: it can be erased once it has ended"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class LogQuery(BaseModel):
|
|
151
|
+
"""What a log's reader asks for: the cursor, the types, only what is kept, a page's size."""
|
|
152
|
+
|
|
153
|
+
after: int = Field(0, ge=0)
|
|
154
|
+
types: str | None = None
|
|
155
|
+
durable: bool = False
|
|
156
|
+
limit: int = Field(DEFAULT_LIMIT, ge=1, le=DEFAULT_LIMIT)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class StreamOptions(BaseModel):
|
|
160
|
+
"""The headers that choose a stream over a page, and where a reconnecting stream resumes."""
|
|
161
|
+
|
|
162
|
+
accept: str | None = None
|
|
163
|
+
last_event_id: str | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# The log opens before the media, so a console sees the call ring; call.started is the worker's.
|
|
167
|
+
@router.post("/v1/calls")
|
|
168
|
+
async def open_call(body: OpenCallRequest, key: WorkerKey, gateway: GatewayDep) -> OpenCallResponse:
|
|
169
|
+
"""Open a call's log, serve it to its agent's socket, say its minutes and its first words."""
|
|
170
|
+
context = body.context
|
|
171
|
+
keys.check_agent(key.bearer, body.agent)
|
|
172
|
+
scope = _call_corner(key, context)
|
|
173
|
+
if context.direction == "inbound" and context.route.number is not None:
|
|
174
|
+
await _arrived(gateway, scope, context.route.number, body.carrier_account)
|
|
175
|
+
await _unclaimed_or_in(gateway, context.call, scope)
|
|
176
|
+
await _spent(gateway, context, scope, body.agent)
|
|
177
|
+
ceiling = await _deps.admit_call(gateway, scope, body.agent)
|
|
178
|
+
found = serving_agent(gateway.sockets, scope, body.agent, body.app, context)
|
|
179
|
+
_refuse_unserved(gateway, scope, body, found)
|
|
180
|
+
config, versions = await _tuned(gateway, scope, body.agent, found, context.call)
|
|
181
|
+
# Asked again by a worker whose gateway died with the answer: the same call, one ringing.
|
|
182
|
+
again = await openings.opening_of(gateway.connections.pool, context.call) is not None
|
|
183
|
+
opener = _opener_of(key)
|
|
184
|
+
claim = Claim(scope, versions, opened_by=opener)
|
|
185
|
+
await gateway.logs.store.claim(context.call, body.agent, scope.org, claim)
|
|
186
|
+
# A worker opened the room's call: nothing is left to offer it to another.
|
|
187
|
+
await offers.forgotten(gateway.connections.pool, context.call)
|
|
188
|
+
await openings.kept(gateway.connections.pool, scope.org, context, config)
|
|
189
|
+
await gateway.prompts.keep(gateway.connections.pool, scope.org, config.knowledge or "")
|
|
190
|
+
owner = None if found is None else found.owner
|
|
191
|
+
served = served_call(gateway.serving, owner, context, config, scope)
|
|
192
|
+
gateway.live.opened_by(context.call, opener)
|
|
193
|
+
await gateway.live.commands_heard(context.call)
|
|
194
|
+
if not again:
|
|
195
|
+
await opened(served.log, context, body.agent)
|
|
196
|
+
first, notice = await _opening(gateway, scope, config.language)
|
|
197
|
+
return OpenCallResponse(
|
|
198
|
+
seconds_left=None if ceiling is None else ceiling.seconds,
|
|
199
|
+
minutes=None if ceiling is None else ceiling.minutes,
|
|
200
|
+
disclosure=first if context.direction == "outbound" else None,
|
|
201
|
+
recording_notice=notice,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# A gateway that restarted forgot the call: served again from the worker's word, no quota, no
|
|
206
|
+
# token, no second call.ringing; the socket holding its agent hears call.attached.
|
|
207
|
+
@router.post("/v1/calls/{call}/reopened", status_code=204)
|
|
208
|
+
async def reopen_call(
|
|
209
|
+
call: str, body: OpenCallRequest, key: WorkerKey, gateway: GatewayDep
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Serve again a call the gateway forgot."""
|
|
212
|
+
if call != body.context.call:
|
|
213
|
+
raise DeclarationRefused(f"the context is call {body.context.call!r}")
|
|
214
|
+
if gateway.live.calls.get(call) is not None:
|
|
215
|
+
return
|
|
216
|
+
scope = _call_corner(key, body.context)
|
|
217
|
+
kept = await queries.scope_of_call(gateway.connections.pool, call)
|
|
218
|
+
if kept is None:
|
|
219
|
+
raise NotFound(NOT_OPEN.format(call=call))
|
|
220
|
+
if kept.scope != scope:
|
|
221
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
222
|
+
if kept.sealed:
|
|
223
|
+
raise Conflict(SEALED.format(call=call))
|
|
224
|
+
registration = gateway.sockets.serving(scope, body.agent, None)
|
|
225
|
+
config, _ = await _tuned(gateway, scope, body.agent, registration, call)
|
|
226
|
+
await openings.kept(gateway.connections.pool, scope.org, body.context, config)
|
|
227
|
+
served_call(gateway.serving, None, body.context, config, scope)
|
|
228
|
+
gateway.live.opened_by(call, kept.opened_by)
|
|
229
|
+
if registration is not None:
|
|
230
|
+
await attach(gateway.live, call, registration.owner)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# The entry comes back with its seq: only the gateway numbers a log.
|
|
234
|
+
@router.post("/v1/calls/{call}/events")
|
|
235
|
+
async def append_entry(
|
|
236
|
+
call: str, body: AppendEntryRequest, key: WorkerKey, gateway: GatewayDep
|
|
237
|
+
) -> Entry:
|
|
238
|
+
"""Write one entry of a call this gateway serves."""
|
|
239
|
+
check_kind(body.type)
|
|
240
|
+
served = await known(gateway, key, call)
|
|
241
|
+
began = time.perf_counter()
|
|
242
|
+
entry = await served.log.append(body.type, body.data, ephemeral=body.ephemeral)
|
|
243
|
+
counted(gateway, time.perf_counter() - began, [entry])
|
|
244
|
+
return entry
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Taken whole or refused whole; the answer to a retry of the last batch is the seqs it was given.
|
|
248
|
+
@router.post("/v1/calls/{call}/entries")
|
|
249
|
+
async def append_entries(
|
|
250
|
+
call: str, body: AppendEntriesRequest, key: WorkerKey, gateway: GatewayDep
|
|
251
|
+
) -> AppendEntriesResponse:
|
|
252
|
+
"""Write a worker's batch of a call this gateway serves, once and in order."""
|
|
253
|
+
for item in body.entries:
|
|
254
|
+
check_kind(item.type)
|
|
255
|
+
served = await known(gateway, key, call)
|
|
256
|
+
began = time.perf_counter()
|
|
257
|
+
entries = await served.log.append_many(body.entries, after=body.after)
|
|
258
|
+
counted(gateway, time.perf_counter() - began, entries)
|
|
259
|
+
return AppendEntriesResponse(entries=entries)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.post("/v1/calls/{call}/sealed", status_code=204)
|
|
263
|
+
async def seal_call(call: str, body: SealCallRequest, key: WorkerKey, gateway: GatewayDep) -> None:
|
|
264
|
+
"""Price the call, write its summary and score, and seal its log."""
|
|
265
|
+
await sealed(gateway.serving, await known(gateway, key, call), body, lent=body.lent)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# The gateway writes tool.call and tool.result: appending the call is what reaches the app.
|
|
269
|
+
@router.post("/v1/calls/{call}/tools")
|
|
270
|
+
async def run_tool(
|
|
271
|
+
call: str,
|
|
272
|
+
agent: Annotated[str, Query()],
|
|
273
|
+
tool_call: ToolCall,
|
|
274
|
+
key: WorkerKey,
|
|
275
|
+
gateway: GatewayDep,
|
|
276
|
+
) -> ToolResult:
|
|
277
|
+
"""Run a worker's tool call through the app that holds its agent, and answer its result."""
|
|
278
|
+
served = await known(gateway, key, call)
|
|
279
|
+
if agent != served.agent:
|
|
280
|
+
raise NotFound(NOT_OPEN.format(call=call))
|
|
281
|
+
# Parked because nobody holds the agent: a refusal now, not a wait until the tool's deadline.
|
|
282
|
+
if served.app is None and gateway.sockets.of(served.scope, agent) is None:
|
|
283
|
+
raise Conflict(NO_AGENT.format(slug=agent))
|
|
284
|
+
use = ToolUse(
|
|
285
|
+
call_id=tool_call.call_id, name=tool_call.name, arguments=dict(tool_call.arguments)
|
|
286
|
+
)
|
|
287
|
+
# Its socket is on another gateway, which may never have heard the call was bound to it.
|
|
288
|
+
if served.app is not None and served.app not in gateway.live.sockets:
|
|
289
|
+
gateway.live.pumped(call, after=0)
|
|
290
|
+
return await served.tools.ran(use, tool_call.speech_id)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# The worker seals the call's recording with it before storing it; asked again, the same key.
|
|
294
|
+
@router.post("/v1/calls/{call}/recording/key")
|
|
295
|
+
async def recording_key(call: str, key: WorkerKey, gateway: GatewayDep) -> RecordingKeyResponse:
|
|
296
|
+
"""The key the call's recording is sealed under, made once for the call."""
|
|
297
|
+
served = await known(gateway, key, call)
|
|
298
|
+
connections = gateway.connections
|
|
299
|
+
found = await recording_keys.key_for(
|
|
300
|
+
connections.pool, connections.vault, served.scope.org, call
|
|
301
|
+
)
|
|
302
|
+
if found is None:
|
|
303
|
+
raise Conflict(UNOPENED_KEY.format(call=call))
|
|
304
|
+
return RecordingKeyResponse(key=base64.urlsafe_b64encode(found).decode())
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# No id: commands have no seq, and a worker that loses the stream asks again.
|
|
308
|
+
@router.get("/v1/calls/{call}/commands")
|
|
309
|
+
async def stream_commands(call: str, key: WorkerKey, gateway: GatewayDep) -> StreamingResponse:
|
|
310
|
+
"""The app's commands for the call, in order, until it is sealed."""
|
|
311
|
+
served = await known(gateway, key, call)
|
|
312
|
+
await gateway.live.commands_heard(call)
|
|
313
|
+
return streamed(_commanded(served.commands), gateway.closing)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@router.get("/v1/calls/{call}/judging")
|
|
317
|
+
async def call_judging(call: str, key: WorkerKey, gateway: GatewayDep) -> JudgingSettings:
|
|
318
|
+
"""Whether the call's org judges its calls at hang-up."""
|
|
319
|
+
served = await known(gateway, key, call)
|
|
320
|
+
pool = gateway.connections.pool
|
|
321
|
+
on = await orgs.judged(pool, served.scope.org)
|
|
322
|
+
return JudgingSettings(on=on, ceiling_usd=judge_ceiling(await catalog.providers(pool)))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# The gateway writes memory.ops and docs.sources on the log itself: the worker has no database.
|
|
326
|
+
@router.post("/v1/calls/{call}/lookup")
|
|
327
|
+
async def lookup(
|
|
328
|
+
call: str, body: LookupRequest, key: WorkerKey, gateway: GatewayDep
|
|
329
|
+
) -> LookupResponse:
|
|
330
|
+
"""Recall or search for a call served here, answered as the model reads it."""
|
|
331
|
+
served = await known(gateway, key, call)
|
|
332
|
+
started = time.perf_counter()
|
|
333
|
+
output = await looked_up(gateway.serving, served, body)
|
|
334
|
+
return LookupResponse(output=output, took_ms=(time.perf_counter() - started) * 1000)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@router.post("/v1/calls/{call}/remember")
|
|
338
|
+
async def remember(call: str, key: WorkerKey, gateway: GatewayDep) -> RememberResponse:
|
|
339
|
+
"""Write what the call taught into its contact's memory now, as the seal would."""
|
|
340
|
+
served = await known(gateway, key, call)
|
|
341
|
+
started = time.perf_counter()
|
|
342
|
+
written = await remembered(gateway.serving, served)
|
|
343
|
+
ops = 0 if written is None else len(written.op.facts)
|
|
344
|
+
return RememberResponse(ops=ops, took_ms=(time.perf_counter() - started) * 1000)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# A code nobody issued is a 404, and an ordinary one: the caller may be dialling an extension.
|
|
348
|
+
@router.post("/v1/calls/{call}/claim", status_code=204)
|
|
349
|
+
async def claim_keypad_code(
|
|
350
|
+
call: str, call_claim: CallClaim, key: WorkerKey, gateway: GatewayDep
|
|
351
|
+
) -> None:
|
|
352
|
+
"""The caller keyed a page's code: tie the call to it."""
|
|
353
|
+
served = await known(gateway, key, call)
|
|
354
|
+
if not await claim_code(gateway.codes, served, call_claim.code, via="keypad"):
|
|
355
|
+
raise NotFound(_deps.NOBODY_ISSUED.format(code=call_claim.code, agent=served.agent))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@router.get("/v1/calls/{call}/events", response_model=None)
|
|
359
|
+
async def stream_events(
|
|
360
|
+
call: str,
|
|
361
|
+
reading: CallReaderDep,
|
|
362
|
+
gateway: GatewayDep,
|
|
363
|
+
query: Annotated[LogQuery, Query()],
|
|
364
|
+
options: Annotated[StreamOptions, Header()],
|
|
365
|
+
) -> Response | StreamingResponse | LogPage:
|
|
366
|
+
"""A call's entries above the cursor: a page, or a stream that ends with the call."""
|
|
367
|
+
config = await _deps.check_readable(gateway, reading, call)
|
|
368
|
+
await _deps.record_read(gateway, reading, call, "log")
|
|
369
|
+
cursor = max(query.after, _seq_of(options.last_event_id))
|
|
370
|
+
only = parse_filter(query.types, durable=query.durable)
|
|
371
|
+
store = gateway.logs.store
|
|
372
|
+
over = await store.sealed(call)
|
|
373
|
+
if over and cursor >= await store.latest_seq(call):
|
|
374
|
+
return Response(status_code=204)
|
|
375
|
+
if wants_sse(options.accept):
|
|
376
|
+
entries = gateway.logs.reading(call).stream(after=cursor, only=only)
|
|
377
|
+
return streamed(_projected(entries, reading, config, ends=True), gateway.closing)
|
|
378
|
+
read = await store.since(call, after=cursor, limit=query.limit)
|
|
379
|
+
return _page(read, only, reading, config, live=not over)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@router.get("/v1/agents/{slug}/calls", response_model=None)
|
|
383
|
+
async def stream_agent_events(
|
|
384
|
+
slug: str,
|
|
385
|
+
reading: ReaderDep,
|
|
386
|
+
gateway: GatewayDep,
|
|
387
|
+
query: Annotated[LogQuery, Query()],
|
|
388
|
+
options: Annotated[StreamOptions, Header()],
|
|
389
|
+
) -> StreamingResponse | LogPage:
|
|
390
|
+
"""An agent's own log: its registrations, its declarations, its errors. It never ends."""
|
|
391
|
+
if reading.acting is None:
|
|
392
|
+
raise NotAllowed(_deps.NOT_YOURS)
|
|
393
|
+
owner = await gateway.logs.store.owner(slug)
|
|
394
|
+
if owner is not None and owner != reading.acting.org:
|
|
395
|
+
raise NotFound(NO_AGENT.format(slug=slug))
|
|
396
|
+
config = gateway.sockets.declared(slug)
|
|
397
|
+
only = parse_filter(query.types, durable=query.durable)
|
|
398
|
+
if wants_sse(options.accept):
|
|
399
|
+
entries = gateway.logs.agent(slug).stream(after=query.after, only=only)
|
|
400
|
+
return streamed(_projected(entries, reading, config, ends=False), gateway.closing)
|
|
401
|
+
read = await gateway.logs.store.since(f"@{slug}", after=query.after, limit=query.limit)
|
|
402
|
+
return _page(read, only, reading, config, live=True)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@router.get("/v1/calls/{call}/state")
|
|
406
|
+
async def call_state(call: str, reading: CallReaderDep, gateway: GatewayDep) -> JsonObject:
|
|
407
|
+
"""The call's folded state as this reader may see it, and the seq a stream resumes from."""
|
|
408
|
+
config = await _deps.check_readable(gateway, reading, call)
|
|
409
|
+
await _deps.record_read(gateway, reading, call, "log")
|
|
410
|
+
state = await gateway.logs.reading(call).snapshot()
|
|
411
|
+
if state.seq == 0:
|
|
412
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
413
|
+
viewer = None if reading.visit is None else reading.visit.identity
|
|
414
|
+
return {
|
|
415
|
+
"state": project_state(state, reading.projection, config, viewer),
|
|
416
|
+
"last_seq": state.seq,
|
|
417
|
+
"live": not await gateway.logs.store.sealed(call),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# The one door that deletes from a call's log: through erasure's own path, never the trigger's.
|
|
422
|
+
@router.delete("/v1/calls/{call}")
|
|
423
|
+
async def erase_call(call: str, key: TeamKey, where: ScopeDep, gateway: GatewayDep) -> Erasure:
|
|
424
|
+
"""Erase one call: its log, facts, tokens, the memories it taught and its recording."""
|
|
425
|
+
kept = await queries.scope_of_call(gateway.connections.pool, call)
|
|
426
|
+
if kept is None or kept.scope is None or not _sees_to_erase(where, kept.scope):
|
|
427
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
428
|
+
keys.check_agent(key.bearer, kept.agent)
|
|
429
|
+
if not kept.sealed:
|
|
430
|
+
raise Conflict(STILL_LIVE.format(call=call))
|
|
431
|
+
recordings = recordings_of(gateway.connections.settings, gateway.connections.http)
|
|
432
|
+
erased = await erasure.call(
|
|
433
|
+
gateway.connections.pool, recordings, kept.scope, call, by=asked_by(key)
|
|
434
|
+
)
|
|
435
|
+
gateway.logs.forget(call)
|
|
436
|
+
return erased.trail
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# Live only, no cursor: every entry of the feed is also in a log, which is where to resume.
|
|
440
|
+
@router.get("/v1/events", response_model=None)
|
|
441
|
+
async def stream_org_events(reading: ReaderDep, gateway: GatewayDep) -> StreamingResponse:
|
|
442
|
+
"""The org's calls and agents changing, as they change."""
|
|
443
|
+
if reading.acting is None:
|
|
444
|
+
raise NotAllowed(_deps.NOT_YOURS)
|
|
445
|
+
feed = await gateway.logs.feed_reader(reading.acting.org, reading.acting.env)
|
|
446
|
+
return streamed(_projected(feed, reading, None, ends=False), gateway.closing)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# An id grants nothing: another org's or world's call is the same 404 as a call nobody opened.
|
|
450
|
+
# The fleet's key serves every org of its world; a tenant's worker its own org alone.
|
|
451
|
+
# A call this gateway serves, or one another gateway opened, served here from what was kept when
|
|
452
|
+
# it opened: one read, the first time a door here asks. A call opened by a release that kept
|
|
453
|
+
# nothing is the 404 a worker answers by saying the call again (`/reopened`).
|
|
454
|
+
# What a door of a call this gateway serves starts from; the socket door starts from it too.
|
|
455
|
+
async def known(gateway: Gateway, key: Acting, call: str) -> Served:
|
|
456
|
+
"""The call as this gateway serves it, first seen here if need be; a refusal names why."""
|
|
457
|
+
now = time.monotonic()
|
|
458
|
+
served = gateway.live.calls.get(call)
|
|
459
|
+
if served is not None:
|
|
460
|
+
_yours(key, served.scope, served.opened_by, call)
|
|
461
|
+
gateway.live.in_use(call, now)
|
|
462
|
+
return served
|
|
463
|
+
pool = gateway.connections.pool
|
|
464
|
+
kept = await queries.scope_of_call(pool, call)
|
|
465
|
+
if kept is None or kept.scope is None:
|
|
466
|
+
raise NotFound(NOT_OPEN.format(call=call))
|
|
467
|
+
_yours(key, kept.scope, kept.opened_by, call)
|
|
468
|
+
if kept.sealed:
|
|
469
|
+
raise Conflict(SEALED.format(call=call))
|
|
470
|
+
opening = await openings.opening_of(pool, call)
|
|
471
|
+
if opening is None:
|
|
472
|
+
raise NotFound(NOT_OPEN.format(call=call))
|
|
473
|
+
return first_seen(gateway.serving, opening.context, opening.config, kept, now)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def check_kind(kind: str) -> None:
|
|
477
|
+
"""Refuse a type the protocol lacks, or one the gateway alone writes on a log."""
|
|
478
|
+
if kind not in EVENTS:
|
|
479
|
+
raise DeclarationRefused(UNKNOWN_EVENT.format(kind=kind))
|
|
480
|
+
if kind in GATEWAY_WRITES:
|
|
481
|
+
raise NotAllowed(THE_GATEWAYS.format(kind=kind))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# What /metrics reads: the append's time at the door, and each vendor's failures as they come in.
|
|
485
|
+
# A vendor fails a call when an error names its plugin, or when a stage switches away from it.
|
|
486
|
+
def counted(gateway: Gateway, seconds: float, entries: list[Entry]) -> None:
|
|
487
|
+
"""Count an append's time and entries, and what they say of vendors, for /metrics."""
|
|
488
|
+
counters = gateway.counters
|
|
489
|
+
counters.appended_in(seconds, len(entries))
|
|
490
|
+
at = time.monotonic()
|
|
491
|
+
for entry in entries:
|
|
492
|
+
vendor = ""
|
|
493
|
+
if entry.type == ERROR:
|
|
494
|
+
code, message = entry.data.get("code"), entry.data.get("message")
|
|
495
|
+
vendor = vendor_named_in(message if isinstance(message, str) else "")
|
|
496
|
+
counters.failed(code if isinstance(code, str) else "", vendor)
|
|
497
|
+
elif entry.type == SWITCHED and entry.data.get("available") is False:
|
|
498
|
+
named = entry.data.get("vendor")
|
|
499
|
+
vendor = named if isinstance(named, str) else ""
|
|
500
|
+
if vendor and entry.call is not None:
|
|
501
|
+
counters.failed_on(vendor, entry.call, at)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# A tenant's worker opens its own org's calls in its own world; the fleet's opens any org's in
|
|
505
|
+
# its world, in the scope the dispatch named.
|
|
506
|
+
def _call_corner(key: Acting, context: CallContext) -> Scope:
|
|
507
|
+
named = Scope(context.route.org, context.env, context.holder or "")
|
|
508
|
+
fleet = THE_FLEET in key.bearer.key.scopes
|
|
509
|
+
if not fleet and context.route.org != key.org:
|
|
510
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=context.call))
|
|
511
|
+
if not fleet and context.env != key.env:
|
|
512
|
+
raise NotAllowed(
|
|
513
|
+
f"this key opens {key.env}, and that call's route answers in {context.env}"
|
|
514
|
+
)
|
|
515
|
+
return keys.scope_of(key.bearer, key.env, dispatched=named)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# A call the fleet opened is the fleet's to write; a written call runs here and no worker door
|
|
519
|
+
# serves it; one an org's own worker opened is that org's. One opened before the head said who is
|
|
520
|
+
# served as before.
|
|
521
|
+
def _yours(key: Acting, scope: Scope, opened_by: Opener | None, call: str) -> None:
|
|
522
|
+
fleet = THE_FLEET in key.bearer.key.scopes
|
|
523
|
+
if scope.env != key.env or (not fleet and scope.org != key.org):
|
|
524
|
+
raise NotFound(NOT_OPEN.format(call=call))
|
|
525
|
+
if opened_by == "gateway" or (opened_by == "fleet" and not fleet):
|
|
526
|
+
raise NotFound(NOT_OPEN.format(call=call))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _opener_of(key: Acting) -> Opener:
|
|
530
|
+
return "fleet" if THE_FLEET in key.bearer.key.scopes else "app"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# The carrier, the fence and the world rule worked, whatever is refused here. A number the org
|
|
534
|
+
# hooked rings nobody until the operator approves it is the org's; a call Twilio sent from an
|
|
535
|
+
# account the number does not live in is somebody else's Twilio pointed at it.
|
|
536
|
+
async def _arrived(gateway: Gateway, scope: Scope, number: str, came_from: str | None) -> None:
|
|
537
|
+
pool = gateway.connections.pool
|
|
538
|
+
await routes.called(pool, scope.org, number)
|
|
539
|
+
if await routes.is_waiting(pool, scope.org, number):
|
|
540
|
+
raise NotAllowed(NOT_APPROVED.format(number=number))
|
|
541
|
+
if came_from is None:
|
|
542
|
+
return
|
|
543
|
+
expected = await carrier.twilio_account_of(gateway.connections, scope.org, number)
|
|
544
|
+
if expected is not None and expected != came_from:
|
|
545
|
+
raise NotAllowed(NOT_ITS_ACCOUNT.format(number=number))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# A call a dial placed already has its head: the worker opens it in the scope the head keeps, or
|
|
549
|
+
# not at all, whatever its key; the first claim of a head stands, so this is the one check.
|
|
550
|
+
async def _unclaimed_or_in(gateway: Gateway, call: str, scope: Scope) -> None:
|
|
551
|
+
kept = await queries.scope_of_call(gateway.connections.pool, call)
|
|
552
|
+
if kept is not None and kept.scope is not None and kept.scope != scope:
|
|
553
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=call))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# A web call opens by the token its room was minted with: once, and only in the org, the world
|
|
557
|
+
# and for the agent it was minted for. Asked of the ledger by the call's id, whatever the worker
|
|
558
|
+
# says: a call no token was minted for (a phone, a dial, a run) is not a token's to refuse.
|
|
559
|
+
async def _spent(gateway: Gateway, context: CallContext, scope: Scope, agent: str) -> None:
|
|
560
|
+
spending = await tokens.spend(gateway.connections.pool, context.call, scope, agent)
|
|
561
|
+
if spending in {"spent", "never_minted"}:
|
|
562
|
+
return
|
|
563
|
+
if spending == "minted_elsewhere":
|
|
564
|
+
logger.warning(ELSEWHERE, context.call, scope.org, scope.env, agent)
|
|
565
|
+
raise NotFound(_deps.NO_SUCH_CALL.format(call=context.call))
|
|
566
|
+
sentence = ALREADY_SPENT.format(call=context.call)
|
|
567
|
+
refused = ErrorEvent(code=SPENT, message=sentence, recoverable=True)
|
|
568
|
+
await gateway.logs.agent(agent).append("error", refused.written())
|
|
569
|
+
raise Conflict(sentence)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _refuse_unserved(
|
|
573
|
+
gateway: Gateway, scope: Scope, body: OpenCallRequest, registration: Registration | None
|
|
574
|
+
) -> None:
|
|
575
|
+
if body.app is not None and registration is None:
|
|
576
|
+
raise Conflict(NOT_THAT_APP.format(app=body.app, slug=body.agent))
|
|
577
|
+
# Held by consoles alone: refused before the greeting, rather than a call with no app.
|
|
578
|
+
if registration is None and gateway.sockets.of(scope, body.agent) is not None:
|
|
579
|
+
raise Conflict(NO_UNCLAIMED.format(slug=body.agent))
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# The call's id picks its version where the scope stands on a canary, the same on a reopen.
|
|
583
|
+
async def _tuned(
|
|
584
|
+
gateway: Gateway, scope: Scope, agent: str, registration: Registration | None, call: str
|
|
585
|
+
) -> tuple[AgentConfig, Versions]:
|
|
586
|
+
declared = gateway.sockets.of(scope, agent) if registration is None else registration
|
|
587
|
+
config = AgentConfig(slug=agent) if declared is None else declared.config
|
|
588
|
+
configured = await catalog.providers(gateway.connections.pool)
|
|
589
|
+
return await tuned(gateway.connections.pool, config, scope, configured, Picked(call=call))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# The worker says the disclosure before the greeting, and the notice only where it records.
|
|
593
|
+
async def _opening(
|
|
594
|
+
gateway: Gateway, scope: Scope, language: str | None
|
|
595
|
+
) -> tuple[str | None, str | None]:
|
|
596
|
+
pool = gateway.connections.pool
|
|
597
|
+
kept = (await policy.policy_of(pool, scope.org)).policy
|
|
598
|
+
org = await orgs.find(pool, scope.org)
|
|
599
|
+
name = scope.org if org is None else org.name
|
|
600
|
+
return disclosure.disclosure_of(kept, name, language), disclosure.notice_of(kept, language)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _page(
|
|
604
|
+
read: list[Entry], only: Filter, reading: Reader, config: AgentConfig | None, *, live: bool
|
|
605
|
+
) -> LogPage:
|
|
606
|
+
viewer = None if reading.visit is None else reading.visit.identity
|
|
607
|
+
kept = (
|
|
608
|
+
project_entry(item, reading.projection, config, viewer)
|
|
609
|
+
for item in read
|
|
610
|
+
if only.passes(item)
|
|
611
|
+
)
|
|
612
|
+
# `next` is the last seq read, not kept, so a page that kept nothing still moves the cursor.
|
|
613
|
+
return LogPage(
|
|
614
|
+
entries=[kept_one for kept_one in kept if kept_one is not None],
|
|
615
|
+
live=live,
|
|
616
|
+
next=read[-1].seq if read else None,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
async def _projected(
|
|
621
|
+
entries: AsyncIterator[Entry], reading: Reader, config: AgentConfig | None, *, ends: bool
|
|
622
|
+
) -> AsyncIterator[str]:
|
|
623
|
+
viewer = None if reading.visit is None else reading.visit.identity
|
|
624
|
+
yield f"retry: {_streams.RETRY_MS}\n\n"
|
|
625
|
+
async for entry in paced(entries):
|
|
626
|
+
if entry is None:
|
|
627
|
+
yield _streams.PING
|
|
628
|
+
continue
|
|
629
|
+
data = project_entry(entry, reading.projection, config, viewer)
|
|
630
|
+
if data is not None:
|
|
631
|
+
yield frame(entry.type, data, seq=entry.seq)
|
|
632
|
+
if ends and entry.type == TERMINAL_EVENT:
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
async def _commanded(waiting: asyncio.Queue[Command | None]) -> AsyncIterator[str]:
|
|
637
|
+
async def taken() -> AsyncIterator[Command]:
|
|
638
|
+
while (command := await waiting.get()) is not None:
|
|
639
|
+
yield command
|
|
640
|
+
|
|
641
|
+
async for command in paced(taken()):
|
|
642
|
+
yield _streams.PING if command is None else frame(command.type, command.written())
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _seq_of(header: str | None) -> int:
|
|
646
|
+
try:
|
|
647
|
+
return max(int(header or 0), 0)
|
|
648
|
+
except ValueError:
|
|
649
|
+
return 0
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# A key erases what it may read: its org and world, and a colleague's corner only as the org's own.
|
|
653
|
+
def _sees_to_erase(where: Scope, owner: Scope) -> bool:
|
|
654
|
+
same = where.org == owner.org and where.env == owner.env
|
|
655
|
+
return same and owner.holder in ("", where.holder)
|