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,117 @@
|
|
|
1
|
+
"""A call whose worker went away mid-call: its caller told once, a call back offered, drained."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from livekit import api
|
|
6
|
+
from livekit.protocol.webhook import WebhookEvent
|
|
7
|
+
|
|
8
|
+
from pinecall.channels import rooms
|
|
9
|
+
from pinecall.channels.offers import Offering
|
|
10
|
+
from pinecall.channels.rooms import Dispatch
|
|
11
|
+
from pinecall.domain.names import Env
|
|
12
|
+
from pinecall.fleet import worlds
|
|
13
|
+
from pinecall.gateway._served import Serving
|
|
14
|
+
from pinecall.log import queries
|
|
15
|
+
from pinecall.log.queries import CallScope
|
|
16
|
+
from pinecall.wire.events import CallbackRequested, CallEnded
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
LEFT = "participant_left"
|
|
21
|
+
|
|
22
|
+
# The reasons livekit gives when a participant's connection was lost rather than closed: a worker
|
|
23
|
+
# killed, a machine gone, a job process that died. A worker that ends a call, drains or hands it on
|
|
24
|
+
# leaves CLIENT_INITIATED; a room deleted says so. livekit-server pkg/rtc/types ToDisconnectReason.
|
|
25
|
+
LOST = frozenset(
|
|
26
|
+
{
|
|
27
|
+
api.DisconnectReason.SIGNAL_CLOSE,
|
|
28
|
+
api.DisconnectReason.CONNECTION_TIMEOUT,
|
|
29
|
+
api.DisconnectReason.STATE_MISMATCH,
|
|
30
|
+
api.DisconnectReason.JOIN_FAILURE,
|
|
31
|
+
api.DisconnectReason.MEDIA_FAILURE,
|
|
32
|
+
api.DisconnectReason.AGENT_ERROR,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
CALLBACK = "callback.requested"
|
|
37
|
+
|
|
38
|
+
STRANDED = "call %s: its worker went away (%s); the caller is told once and the call ends drained"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# The end is written first and only once: it is what tells a second delivery of the same event,
|
|
42
|
+
# the worker's own late end and the told job's own leaving that there is nothing left to do. The
|
|
43
|
+
# room is asked of the LiveKit of the world that sent the event.
|
|
44
|
+
async def stranded(
|
|
45
|
+
serving: Serving, offering: Offering, world: Env, event: WebhookEvent
|
|
46
|
+
) -> str | None:
|
|
47
|
+
"""End the call whose agent the event says was lost, and send its fleet to tell the caller."""
|
|
48
|
+
server = offering.servers[world]
|
|
49
|
+
if not _an_agent_lost(event):
|
|
50
|
+
return None
|
|
51
|
+
call = event.room.name
|
|
52
|
+
kept = await queries.scope_of_call(serving.connections.pool, call)
|
|
53
|
+
if kept is None or kept.sealed or kept.scope is None:
|
|
54
|
+
return None
|
|
55
|
+
if not await rooms.left_alone(server, call):
|
|
56
|
+
return None
|
|
57
|
+
if not await _ended(serving, call, kept):
|
|
58
|
+
return None
|
|
59
|
+
await _call_back_offered(serving, call, kept.agent)
|
|
60
|
+
scope = kept.scope
|
|
61
|
+
fleet = worlds.fleet_of(await worlds.fleets(serving.connections.pool), scope.env)
|
|
62
|
+
dispatch = Dispatch(
|
|
63
|
+
agent=kept.agent,
|
|
64
|
+
org=scope.org,
|
|
65
|
+
env=scope.env,
|
|
66
|
+
holder=scope.holder or None,
|
|
67
|
+
worker_gone=True,
|
|
68
|
+
entries_written=kept.written,
|
|
69
|
+
)
|
|
70
|
+
await offering.offer(call, fleet, dispatch)
|
|
71
|
+
reason = api.DisconnectReason.Name(event.participant.disconnect_reason)
|
|
72
|
+
logger.warning(STRANDED, call, reason)
|
|
73
|
+
return call
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _an_agent_lost(event: WebhookEvent) -> bool:
|
|
77
|
+
return (
|
|
78
|
+
event.event == LEFT
|
|
79
|
+
and event.participant.kind == api.ParticipantInfo.Kind.AGENT
|
|
80
|
+
and event.participant.disconnect_reason in LOST
|
|
81
|
+
and bool(event.room.name)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# A log this process does not serve (it restarted since the call opened) is written and let go,
|
|
86
|
+
# as the reaper does: the told job's own entries then find no call here, and the reaper seals it.
|
|
87
|
+
async def _ended(serving: Serving, call: str, kept: CallScope) -> bool:
|
|
88
|
+
now = serving.logs.store.clock()
|
|
89
|
+
started = now if kept.started_at is None else kept.started_at
|
|
90
|
+
ended = CallEnded(
|
|
91
|
+
reason="drained",
|
|
92
|
+
ended_by="platform",
|
|
93
|
+
ended_at=now,
|
|
94
|
+
duration_s=max(now - started, 0.0),
|
|
95
|
+
)
|
|
96
|
+
served = serving.live.calls.get(call)
|
|
97
|
+
if served is not None:
|
|
98
|
+
return await served.log.append_first("call.ended", ended.written()) is not None
|
|
99
|
+
log = serving.logs.writing(call, kept.agent)
|
|
100
|
+
try:
|
|
101
|
+
return await log.append_first("call.ended", ended.written()) is not None
|
|
102
|
+
finally:
|
|
103
|
+
serving.logs.forget(call)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Only a phone has a number to call back: the caller's, or on an outbound call the one dialled.
|
|
107
|
+
async def _call_back_offered(serving: Serving, call: str, agent: str) -> None:
|
|
108
|
+
facts = (await queries.facts_of_calls(serving.connections.pool, [call])).get(call)
|
|
109
|
+
if facts is None or facts.channel != "phone":
|
|
110
|
+
return
|
|
111
|
+
number = facts.from_number if facts.direction == "inbound" else facts.to_number
|
|
112
|
+
if not number:
|
|
113
|
+
return
|
|
114
|
+
wanted = CallbackRequested(
|
|
115
|
+
channel="phone", number=number, via="overflow", call=call, contact=None
|
|
116
|
+
)
|
|
117
|
+
await serving.logs.agent(agent).append(CALLBACK, wanted.written())
|
pinecall/log/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""A stage's seconds as a fixed histogram: counted in buckets, added across calls, read by rank."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
|
|
6
|
+
# Each bucket is 10 % wider than the one below it, from 10 ms to past a minute: a rank read off a
|
|
7
|
+
# bucket's geometric middle is within 5 % of the true value, and two histograms add bucket by
|
|
8
|
+
# bucket, which exact percentiles cannot.
|
|
9
|
+
SMALLEST_S = 0.01
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
GROWTH = 1.1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Under 10 ms, then 92 buckets growing by GROWTH up to 63 s, then everything slower.
|
|
16
|
+
BUCKETS = 94
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MEDIAN = 0.5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
P95 = 0.95
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Bucket i (1 to BUCKETS - 2) holds [SMALLEST_S * GROWTH ** (i - 1), SMALLEST_S * GROWTH ** i).
|
|
26
|
+
UPPER: tuple[float, ...] = tuple(SMALLEST_S * GROWTH**index for index in range(BUCKETS - 1))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def empty() -> list[int]:
|
|
30
|
+
"""A histogram that counted nothing."""
|
|
31
|
+
return [0] * BUCKETS
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def counted(values: Iterable[float]) -> list[int]:
|
|
35
|
+
"""A histogram of these times; a negative one (livekit's -1 for none) is not counted."""
|
|
36
|
+
buckets = empty()
|
|
37
|
+
for seconds in values:
|
|
38
|
+
if seconds >= 0:
|
|
39
|
+
buckets[_bucket_of(seconds)] += 1
|
|
40
|
+
return buckets
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def added(first: Sequence[int], second: Sequence[int]) -> list[int]:
|
|
44
|
+
"""Two histograms as one."""
|
|
45
|
+
return [mine + theirs for mine, theirs in zip(first, second, strict=True)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Nearest rank: the k-th smallest time, k = ceil(q * n), read as its bucket's middle.
|
|
49
|
+
def rank(buckets: Sequence[int], quantile: float) -> float | None:
|
|
50
|
+
"""The time at this quantile, within 5 %; None for a histogram that counted nothing."""
|
|
51
|
+
total = sum(buckets)
|
|
52
|
+
if total == 0:
|
|
53
|
+
return None
|
|
54
|
+
wanted = max(1, math.ceil(quantile * total))
|
|
55
|
+
seen = 0
|
|
56
|
+
for index, count in enumerate(buckets):
|
|
57
|
+
seen += count
|
|
58
|
+
if seen >= wanted:
|
|
59
|
+
return _middle(index)
|
|
60
|
+
return _middle(BUCKETS - 1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _middle(index: int) -> float:
|
|
64
|
+
if index == 0:
|
|
65
|
+
return SMALLEST_S / 2
|
|
66
|
+
if index == BUCKETS - 1:
|
|
67
|
+
return UPPER[-1]
|
|
68
|
+
return math.sqrt(UPPER[index - 1] * UPPER[index])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _bucket_of(seconds: float) -> int:
|
|
72
|
+
if seconds < SMALLEST_S:
|
|
73
|
+
return 0
|
|
74
|
+
return min(BUCKETS - 1, 1 + math.floor(math.log(seconds / SMALLEST_S, GROWTH)))
|
pinecall/log/_relay.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""The relay: what any gateway wrote, delivered to this process's live readers in seq order."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from pinecall.domain.errors import NotAvailable
|
|
12
|
+
from pinecall.log.store import Store, log_name
|
|
13
|
+
from pinecall.postgres.pool import box_task
|
|
14
|
+
from pinecall.process.signal import Listening, Signal
|
|
15
|
+
from pinecall.wire.frames import Entry
|
|
16
|
+
|
|
17
|
+
# Each log has a channel of its own, so a gateway hears only the logs something local reads;
|
|
18
|
+
# an org's feed in a world has one, and the box's floor one, followed without a sequencer: a
|
|
19
|
+
# feed spans calls and is live-only, each entry carrying its log and seq for a reader that
|
|
20
|
+
# needs a call's order.
|
|
21
|
+
LOG_PREFIX = "log:"
|
|
22
|
+
FEED_PREFIX = "feed:"
|
|
23
|
+
CHANNEL = "log:{name}"
|
|
24
|
+
FEED_CHANNEL = "feed:{org}:{env}"
|
|
25
|
+
BOX_CHANNEL = "box"
|
|
26
|
+
|
|
27
|
+
# A durable entry over this many bytes travels as its address, and the hearer reads it back.
|
|
28
|
+
STUB_OVER_BYTES = 64 * 1024
|
|
29
|
+
|
|
30
|
+
# How long an entry that arrived ahead of its turn waits for the ones before it, before the
|
|
31
|
+
# store is asked for them; and how often the store is asked for every followed log while the
|
|
32
|
+
# signal is down, when durable entries still arrive and ephemerals do not.
|
|
33
|
+
FILL_AFTER_S = 0.1
|
|
34
|
+
POLL_S = 1.0
|
|
35
|
+
|
|
36
|
+
# Both are told the channel: a log's (`log:{name}`), an org feed's, or the box's.
|
|
37
|
+
type Delivered = Callable[[str, Entry], None]
|
|
38
|
+
type Dropped = Callable[[str], None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Stored(BaseModel):
|
|
42
|
+
"""Where a big entry is: the hearer reads it back from the store."""
|
|
43
|
+
|
|
44
|
+
log: str
|
|
45
|
+
seq: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Travelled(BaseModel):
|
|
49
|
+
"""What a message on a channel carries: an entry whole, or the address of a big one."""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(extra="forbid")
|
|
52
|
+
|
|
53
|
+
# The relay that wrote it: its own delivery was local, so it skips it when heard back.
|
|
54
|
+
sender: str
|
|
55
|
+
entry: Entry | None = None
|
|
56
|
+
stored: Stored | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Following:
|
|
61
|
+
"""One channel this process follows: its readers, the last seq delivered, what came early."""
|
|
62
|
+
|
|
63
|
+
# The channel.
|
|
64
|
+
name: str
|
|
65
|
+
# A log is delivered in seq order; a feed as it comes.
|
|
66
|
+
ordered: bool = True
|
|
67
|
+
readers: int = 1
|
|
68
|
+
last: int = 0
|
|
69
|
+
held: dict[int, Entry] = field(default_factory=dict[int, Entry])
|
|
70
|
+
# Set once the subscription is confirmed, or once the store is polled instead.
|
|
71
|
+
ready: asyncio.Event = field(default_factory=asyncio.Event)
|
|
72
|
+
listening: Listening | None = None
|
|
73
|
+
task: asyncio.Task[None] | None = None
|
|
74
|
+
filling: asyncio.Task[None] | None = None
|
|
75
|
+
closed: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Stored before published, and every hearer resumes from the store: a message may be lost, or
|
|
79
|
+
# arrive out of turn, and what a reader is given is still whole and in seq order.
|
|
80
|
+
class Relay:
|
|
81
|
+
"""Follows the logs something local reads, and hands each entry on once, in its turn."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self, store: Store, signal: Signal, delivered: Delivered, dropped: Dropped
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Hand each entry to `delivered`; tell `dropped` when a log's readers must resume."""
|
|
87
|
+
self.store = store
|
|
88
|
+
self.signal = signal
|
|
89
|
+
# Several relays may share one in-process signal: each skips only what it sent itself.
|
|
90
|
+
self.id = uuid4().hex
|
|
91
|
+
self._delivered = delivered
|
|
92
|
+
self._dropped = dropped
|
|
93
|
+
self._following: dict[str, Following] = {}
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def followed(self) -> int:
|
|
97
|
+
"""How many logs this process follows now."""
|
|
98
|
+
return len(self._following)
|
|
99
|
+
|
|
100
|
+
async def follow(self, name: str, *, ordered: bool = True) -> None:
|
|
101
|
+
"""Follow the channel for one more reader; returns once what is published next is heard."""
|
|
102
|
+
following = self._following.get(name)
|
|
103
|
+
if following is None:
|
|
104
|
+
following = self._following[name] = Following(name, ordered=ordered)
|
|
105
|
+
following.task = box_task(self._run(following))
|
|
106
|
+
else:
|
|
107
|
+
following.readers += 1
|
|
108
|
+
await following.ready.wait()
|
|
109
|
+
|
|
110
|
+
def unfollow(self, name: str) -> None:
|
|
111
|
+
"""One reader left the log; the last one leaving lets the channel go."""
|
|
112
|
+
following = self._following.get(name)
|
|
113
|
+
if following is None:
|
|
114
|
+
return
|
|
115
|
+
following.readers -= 1
|
|
116
|
+
if following.readers <= 0:
|
|
117
|
+
self._let_go(following)
|
|
118
|
+
|
|
119
|
+
def local(self, channel: str, entry: Entry) -> None:
|
|
120
|
+
"""An entry this process wrote: told to every gateway, and delivered here in its turn."""
|
|
121
|
+
self.signal.publish(channel, encoded(entry, self.id))
|
|
122
|
+
following = self._following.get(channel)
|
|
123
|
+
if following is None or not following.ordered or not following.ready.is_set():
|
|
124
|
+
self._delivered(channel, entry)
|
|
125
|
+
else:
|
|
126
|
+
self._sequenced(following, entry)
|
|
127
|
+
|
|
128
|
+
async def close(self) -> None:
|
|
129
|
+
"""Let every log go and wait for their tasks."""
|
|
130
|
+
followings = list(self._following.values())
|
|
131
|
+
for following in followings:
|
|
132
|
+
self._let_go(following)
|
|
133
|
+
tasks = [following.task for following in followings if following.task is not None]
|
|
134
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
135
|
+
|
|
136
|
+
async def _run(self, following: Following) -> None:
|
|
137
|
+
try:
|
|
138
|
+
listening = await self.signal.subscribe(following.name)
|
|
139
|
+
except NotAvailable:
|
|
140
|
+
listening = None
|
|
141
|
+
if following.ordered:
|
|
142
|
+
following.last = await self.store.latest_seq(following.name.removeprefix(LOG_PREFIX))
|
|
143
|
+
following.listening = listening
|
|
144
|
+
following.ready.set()
|
|
145
|
+
if following.closed:
|
|
146
|
+
if listening is not None:
|
|
147
|
+
listening.close()
|
|
148
|
+
return
|
|
149
|
+
if listening is None:
|
|
150
|
+
await self._polled(following)
|
|
151
|
+
return
|
|
152
|
+
async for data in listening:
|
|
153
|
+
await self._heard(following, data)
|
|
154
|
+
self._over(following)
|
|
155
|
+
|
|
156
|
+
# The signal is down: a log's durable entries are read off the store each second (a feed
|
|
157
|
+
# spans calls and cannot be, so its readers hear this process alone), until it is back, when
|
|
158
|
+
# the readers are told to resume so the channel is followed again.
|
|
159
|
+
async def _polled(self, following: Following) -> None:
|
|
160
|
+
while not following.closed:
|
|
161
|
+
await asyncio.sleep(POLL_S)
|
|
162
|
+
if following.closed:
|
|
163
|
+
return
|
|
164
|
+
if following.ordered:
|
|
165
|
+
for entry in await self.store.since(
|
|
166
|
+
following.name.removeprefix(LOG_PREFIX), after=following.last
|
|
167
|
+
):
|
|
168
|
+
if entry.seq > following.last:
|
|
169
|
+
self._handed(following, entry)
|
|
170
|
+
if self.signal.up:
|
|
171
|
+
self._over(following)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
async def _heard(self, following: Following, data: bytes) -> None:
|
|
175
|
+
travelled = Travelled.model_validate_json(data)
|
|
176
|
+
if travelled.sender == self.id:
|
|
177
|
+
return
|
|
178
|
+
if travelled.entry is not None:
|
|
179
|
+
self._taken(following, travelled.entry)
|
|
180
|
+
elif travelled.stored is not None:
|
|
181
|
+
seq = travelled.stored.seq
|
|
182
|
+
found = await self.store.since(travelled.stored.log, after=seq - 1, limit=1)
|
|
183
|
+
if found and found[0].seq == seq:
|
|
184
|
+
self._taken(following, found[0])
|
|
185
|
+
|
|
186
|
+
def _taken(self, following: Following, entry: Entry) -> None:
|
|
187
|
+
if following.ordered:
|
|
188
|
+
self._sequenced(following, entry)
|
|
189
|
+
elif not following.closed:
|
|
190
|
+
self._delivered(following.name, entry)
|
|
191
|
+
|
|
192
|
+
def _sequenced(self, following: Following, entry: Entry) -> None:
|
|
193
|
+
if following.closed or entry.seq <= following.last:
|
|
194
|
+
return
|
|
195
|
+
if entry.seq == following.last + 1:
|
|
196
|
+
self._handed(following, entry)
|
|
197
|
+
while (queued := following.held.pop(following.last + 1, None)) is not None:
|
|
198
|
+
self._handed(following, queued)
|
|
199
|
+
return
|
|
200
|
+
following.held[entry.seq] = entry
|
|
201
|
+
if following.filling is None:
|
|
202
|
+
following.filling = box_task(self._filled(following))
|
|
203
|
+
|
|
204
|
+
# What is missing before the held ones is read from the store: by the head's lock every
|
|
205
|
+
# durable entry below a seq given out is committed, so a seq not found is an ephemeral, lost.
|
|
206
|
+
async def _filled(self, following: Following) -> None:
|
|
207
|
+
await asyncio.sleep(FILL_AFTER_S)
|
|
208
|
+
try:
|
|
209
|
+
if following.closed or not following.held:
|
|
210
|
+
return
|
|
211
|
+
highest = max(following.held)
|
|
212
|
+
wanted = highest - following.last
|
|
213
|
+
name = following.name.removeprefix(LOG_PREFIX)
|
|
214
|
+
for entry in await self.store.since(name, after=following.last, limit=wanted):
|
|
215
|
+
if following.last < entry.seq <= highest:
|
|
216
|
+
following.held.setdefault(entry.seq, entry)
|
|
217
|
+
for seq in sorted(seq for seq in following.held if seq <= highest):
|
|
218
|
+
entry = following.held.pop(seq)
|
|
219
|
+
if seq > following.last:
|
|
220
|
+
self._handed(following, entry)
|
|
221
|
+
finally:
|
|
222
|
+
following.filling = None
|
|
223
|
+
if following.held and not following.closed:
|
|
224
|
+
following.filling = box_task(self._filled(following))
|
|
225
|
+
|
|
226
|
+
# The channel was lost: this following is over, so a reader that comes back follows a new one
|
|
227
|
+
# (subscribed again) rather than this one, which nobody listens on; its readers are dropped.
|
|
228
|
+
def _over(self, following: Following) -> None:
|
|
229
|
+
if following.closed:
|
|
230
|
+
return
|
|
231
|
+
following.closed = True
|
|
232
|
+
if self._following.get(following.name) is following:
|
|
233
|
+
del self._following[following.name]
|
|
234
|
+
self._dropped(following.name)
|
|
235
|
+
|
|
236
|
+
def _handed(self, following: Following, entry: Entry) -> None:
|
|
237
|
+
following.last = entry.seq
|
|
238
|
+
self._delivered(following.name, entry)
|
|
239
|
+
|
|
240
|
+
def _let_go(self, following: Following) -> None:
|
|
241
|
+
following.closed = True
|
|
242
|
+
self._following.pop(following.name, None)
|
|
243
|
+
if following.listening is not None:
|
|
244
|
+
following.listening.close()
|
|
245
|
+
if following.filling is not None:
|
|
246
|
+
following.filling.cancel()
|
|
247
|
+
if following.task is not None and following.task is not asyncio.current_task():
|
|
248
|
+
following.task.cancel()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# A big entry's address names its own log, which a feed's channel does not.
|
|
252
|
+
def encoded(entry: Entry, sender: str) -> bytes:
|
|
253
|
+
"""The message an entry travels as: itself, or its address when it is big and durable."""
|
|
254
|
+
whole = f'{{"sender":{json.dumps(sender)},"entry":{entry.written_json()}}}'
|
|
255
|
+
if len(whole) > STUB_OVER_BYTES and not entry.ephemeral:
|
|
256
|
+
log = log_name(entry.call, entry.agent)
|
|
257
|
+
stub = {"sender": sender, "stored": {"log": log, "seq": entry.seq}}
|
|
258
|
+
return json.dumps(stub, separators=(",", ":")).encode()
|
|
259
|
+
return whole.encode()
|