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/log/logs.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""The logs open in this process: append, the live readers, replay, the feeds."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.call import CallContext
|
|
9
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
10
|
+
from pinecall.domain.names import ENVS, Env, JsonObject, Medium, parse_env
|
|
11
|
+
from pinecall.log import private
|
|
12
|
+
from pinecall.log._relay import BOX_CHANNEL, CHANNEL, FEED_CHANNEL, FEED_PREFIX, LOG_PREFIX, Relay
|
|
13
|
+
from pinecall.log.private import Privacy
|
|
14
|
+
from pinecall.log.readers import EVERYTHING, Filter
|
|
15
|
+
from pinecall.log.reduce import reduce
|
|
16
|
+
from pinecall.log.store import Claimant, Store, Unnumbered, log_name
|
|
17
|
+
from pinecall.process.signal import LocalSignal, Signal
|
|
18
|
+
from pinecall.wire.events import (
|
|
19
|
+
EPHEMERAL_EVENTS,
|
|
20
|
+
TERMINAL_EVENT,
|
|
21
|
+
CallDialing,
|
|
22
|
+
CallRinging,
|
|
23
|
+
CallStarted,
|
|
24
|
+
)
|
|
25
|
+
from pinecall.wire.frames import Entry
|
|
26
|
+
from pinecall.wire.parts import Route
|
|
27
|
+
from pinecall.wire.rest.calls import BatchedEntry
|
|
28
|
+
from pinecall.wire.state import State
|
|
29
|
+
|
|
30
|
+
# Runs inline on every append, before append returns: for work that is part of the write.
|
|
31
|
+
type Tap = Callable[[Entry], Awaitable[None]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# How far a reader may fall behind before it is dropped: about a minute of a busy call, and the
|
|
35
|
+
# most a dead socket can pin.
|
|
36
|
+
QUEUE_DEPTH = 256
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# What an org's feed and the box's carry: the lifecycle, never the content of a call.
|
|
40
|
+
ORG_EVENTS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"agent.registered",
|
|
43
|
+
"agent.detached",
|
|
44
|
+
"call.ringing",
|
|
45
|
+
"call.dialing",
|
|
46
|
+
"call.started",
|
|
47
|
+
"call.ended",
|
|
48
|
+
"attention.requested",
|
|
49
|
+
"attention.answered",
|
|
50
|
+
"supervisor.took_over",
|
|
51
|
+
"supervisor.released",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Subscription:
|
|
57
|
+
"""One live reader's queue; iteration ends when the log closes or the reader fell behind."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
only: Filter,
|
|
62
|
+
leave: Callable[["Subscription"], None],
|
|
63
|
+
after: Callable[[], None] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Keep the filter, the way out of the fanout, and what to do once out (unfollow)."""
|
|
66
|
+
self.only = only
|
|
67
|
+
self.dropped = False
|
|
68
|
+
self._leave = leave
|
|
69
|
+
self._after = after
|
|
70
|
+
self._ended = False
|
|
71
|
+
self._left = False
|
|
72
|
+
# One slot past the depth for the end marker, so closing a full queue never blocks.
|
|
73
|
+
self._queue: asyncio.Queue[Entry | None] = asyncio.Queue(maxsize=QUEUE_DEPTH + 1)
|
|
74
|
+
|
|
75
|
+
def offer(self, entry: Entry) -> bool:
|
|
76
|
+
"""Queue the entry if the filter passes it; False once this reader is gone."""
|
|
77
|
+
if self._ended:
|
|
78
|
+
return False
|
|
79
|
+
if not self.only.passes(entry):
|
|
80
|
+
return True
|
|
81
|
+
if self._queue.qsize() >= QUEUE_DEPTH:
|
|
82
|
+
self.dropped = True
|
|
83
|
+
self._end()
|
|
84
|
+
return False
|
|
85
|
+
self._queue.put_nowait(entry)
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
"""End the iteration after what is queued, and leave the fanout."""
|
|
90
|
+
self._end()
|
|
91
|
+
self._leave(self)
|
|
92
|
+
if not self._left:
|
|
93
|
+
self._left = True
|
|
94
|
+
if self._after is not None:
|
|
95
|
+
self._after()
|
|
96
|
+
|
|
97
|
+
def _end(self) -> None:
|
|
98
|
+
if not self._ended:
|
|
99
|
+
self._ended = True
|
|
100
|
+
self._queue.put_nowait(None)
|
|
101
|
+
|
|
102
|
+
def __aiter__(self) -> Self:
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
async def __anext__(self) -> Entry:
|
|
106
|
+
"""Return the next entry in log order; the end marker ends the iteration."""
|
|
107
|
+
entry = await self._queue.get()
|
|
108
|
+
if entry is None:
|
|
109
|
+
raise StopAsyncIteration
|
|
110
|
+
return entry
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Fanout:
|
|
114
|
+
"""The live readers of one log or feed; publishing never waits for any of them."""
|
|
115
|
+
|
|
116
|
+
def __init__(self) -> None:
|
|
117
|
+
"""Start with no reader."""
|
|
118
|
+
self._readers: set[Subscription] = set()
|
|
119
|
+
self._closed = False
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def readers(self) -> int:
|
|
123
|
+
"""Return how many readers are subscribed."""
|
|
124
|
+
return len(self._readers)
|
|
125
|
+
|
|
126
|
+
def subscribe(
|
|
127
|
+
self, only: Filter = EVERYTHING, after: Callable[[], None] | None = None
|
|
128
|
+
) -> Subscription:
|
|
129
|
+
"""Return a reader of what is published from now on; on a closed fanout, an ended one."""
|
|
130
|
+
subscription = Subscription(only, self._readers.discard, after)
|
|
131
|
+
if self._closed:
|
|
132
|
+
subscription.close()
|
|
133
|
+
else:
|
|
134
|
+
self._readers.add(subscription)
|
|
135
|
+
return subscription
|
|
136
|
+
|
|
137
|
+
def publish(self, entry: Entry) -> None:
|
|
138
|
+
"""Offer the entry to every reader, dropping the ones that fell behind."""
|
|
139
|
+
for reader in tuple(self._readers):
|
|
140
|
+
if not reader.offer(entry):
|
|
141
|
+
reader.close()
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
"""End every reader after what it was given; a later subscriber gets nothing."""
|
|
145
|
+
self._closed = True
|
|
146
|
+
for reader in tuple(self._readers):
|
|
147
|
+
reader.close()
|
|
148
|
+
self._readers.clear()
|
|
149
|
+
|
|
150
|
+
def drop(self) -> None:
|
|
151
|
+
"""End every reader as one that fell behind: each resumes from the store."""
|
|
152
|
+
for reader in tuple(self._readers):
|
|
153
|
+
reader.dropped = True
|
|
154
|
+
reader.close()
|
|
155
|
+
self._readers.clear()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Readers and the writer share the one Log of a name: a reader often arrives before the writer.
|
|
159
|
+
class Log:
|
|
160
|
+
"""One call's log, or one agent's: written here, read live here, replayed from the store."""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self, store: Store, call: str | None, agent: str, relay: Relay | None = None
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Name the log: a call's, or an agent's own when call is None; alone with no relay."""
|
|
166
|
+
self.call = call
|
|
167
|
+
self.agent = agent
|
|
168
|
+
self.name = log_name(call, agent)
|
|
169
|
+
self.fanout = Fanout()
|
|
170
|
+
self.sealed = False
|
|
171
|
+
# Set by Logs.writing: a held log outlives its readers until the process forgets it.
|
|
172
|
+
self.kept_open = False
|
|
173
|
+
# Set by the call's writer: what it masks by. None on a log nobody declared anything for.
|
|
174
|
+
self.privacy: Privacy | None = None
|
|
175
|
+
# The private values of the entries appended here, by seq, for the socket that runs them.
|
|
176
|
+
self.private_values: dict[int, JsonObject] = {}
|
|
177
|
+
self._store = store
|
|
178
|
+
# The clock its entries are stamped with.
|
|
179
|
+
self.clock = store.clock
|
|
180
|
+
# None: a log of this process alone, its readers hearing its own appends only.
|
|
181
|
+
self._relay = relay
|
|
182
|
+
self._taps: list[Tap] = []
|
|
183
|
+
self._snapshot: tuple[int, State] | None = None
|
|
184
|
+
|
|
185
|
+
def tapped(self, tap: Tap) -> None:
|
|
186
|
+
"""Run the tap on every append from now on, before append returns."""
|
|
187
|
+
self._taps.append(tap)
|
|
188
|
+
|
|
189
|
+
# Stored before published, so no reader sees an entry the store lacks and every cursor resumes.
|
|
190
|
+
# A private value is sealed aside before the entry is published: a tool call whose seal broke
|
|
191
|
+
# never reached the app, so the worker's retry runs the tool once.
|
|
192
|
+
async def append(
|
|
193
|
+
self, kind: str, data: JsonObject | None = None, *, ephemeral: bool | None = None
|
|
194
|
+
) -> Entry:
|
|
195
|
+
"""Write the entry, its private values masked, publish it, run the taps, seal at the end."""
|
|
196
|
+
forgettable = kind in EPHEMERAL_EVENTS if ephemeral is None else ephemeral
|
|
197
|
+
written = self._split(kind, data or {})
|
|
198
|
+
entry = await self._store.append(
|
|
199
|
+
self.call, self.agent, kind, written.kept, ephemeral=forgettable
|
|
200
|
+
)
|
|
201
|
+
await self._sealed_aside(self._kept_open([(entry, written)]))
|
|
202
|
+
await self._published(entry)
|
|
203
|
+
return entry
|
|
204
|
+
|
|
205
|
+
# Two writers deciding the same end (a webhook delivered twice) write it once.
|
|
206
|
+
async def append_first(self, kind: str, data: JsonObject) -> Entry | None:
|
|
207
|
+
"""Write and publish a durable entry unless the log holds one of its type; None then."""
|
|
208
|
+
if self.call is None:
|
|
209
|
+
raise DeclarationRefused(f"agent {self.agent}: only a call's log is looked at")
|
|
210
|
+
written = self._split(kind, data)
|
|
211
|
+
entry = await self._store.append_first(self.call, self.agent, kind, written.kept)
|
|
212
|
+
if entry is not None:
|
|
213
|
+
await self._sealed_aside(self._kept_open([(entry, written)]))
|
|
214
|
+
await self._published(entry)
|
|
215
|
+
return entry
|
|
216
|
+
|
|
217
|
+
# Once per call id, whichever gateway writes it: the app's answer where its socket is, or the
|
|
218
|
+
# timeout where the worker asked.
|
|
219
|
+
async def answer(self, data: JsonObject) -> Entry | None:
|
|
220
|
+
"""Write and publish a tool.result unless the log holds its answer already; None then."""
|
|
221
|
+
if self.call is None:
|
|
222
|
+
raise DeclarationRefused(f"agent {self.agent}: only a call's log asks tools")
|
|
223
|
+
written = self._split("tool.result", data)
|
|
224
|
+
entry = await self._store.append_answer(self.call, self.agent, written.kept)
|
|
225
|
+
if entry is not None:
|
|
226
|
+
await self._sealed_aside(self._kept_open([(entry, written)]))
|
|
227
|
+
await self._published(entry)
|
|
228
|
+
return entry
|
|
229
|
+
|
|
230
|
+
# An entry stored by a gateway that may have died before telling the others: told again. A
|
|
231
|
+
# reader that heard it already skips it by its seq.
|
|
232
|
+
def told_again(self, entry: Entry) -> None:
|
|
233
|
+
"""Publish a stored entry once more to every gateway's readers."""
|
|
234
|
+
if self._relay is None:
|
|
235
|
+
self.fanout.publish(entry)
|
|
236
|
+
else:
|
|
237
|
+
self._relay.local(CHANNEL.format(name=self.name), entry)
|
|
238
|
+
|
|
239
|
+
# A replayed batch was published when it was first taken: publishing it again would repeat it.
|
|
240
|
+
# Its private values are sealed aside after it is published and again on a replay, which keeps
|
|
241
|
+
# the ones kept already: a batch whose seal broke is kept whole by the worker's retry.
|
|
242
|
+
async def append_many(self, entries: Sequence[BatchedEntry], *, after: int) -> list[Entry]:
|
|
243
|
+
"""Write a worker's batch once, then publish and tap each entry in order as append does."""
|
|
244
|
+
written = [self._split(item.type, item.data) for item in entries]
|
|
245
|
+
unnumbered = [
|
|
246
|
+
Unnumbered(
|
|
247
|
+
type=item.type,
|
|
248
|
+
data=kept.kept,
|
|
249
|
+
ephemeral=item.type in EPHEMERAL_EVENTS
|
|
250
|
+
if item.ephemeral is None
|
|
251
|
+
else item.ephemeral,
|
|
252
|
+
ts=item.ts,
|
|
253
|
+
)
|
|
254
|
+
for item, kept in zip(entries, written, strict=True)
|
|
255
|
+
]
|
|
256
|
+
batch = await self._store.append_many(self.call, self.agent, unnumbered, after=after)
|
|
257
|
+
aside = self._kept_open(zip(batch.entries, written, strict=True))
|
|
258
|
+
if not batch.replayed:
|
|
259
|
+
for entry in batch.entries:
|
|
260
|
+
await self._published(entry)
|
|
261
|
+
await self._sealed_aside(aside)
|
|
262
|
+
return batch.entries
|
|
263
|
+
|
|
264
|
+
def opened(self, entry: Entry) -> Entry:
|
|
265
|
+
"""The entry as its writer wrote it, for the app's socket: the private values back in."""
|
|
266
|
+
return private.restored(entry, self.private_values.get(entry.seq))
|
|
267
|
+
|
|
268
|
+
async def whole(self) -> list[Entry]:
|
|
269
|
+
"""Every entry of the log as its writer wrote it, the private values sealed aside opened."""
|
|
270
|
+
if self.privacy is None:
|
|
271
|
+
return await self._store.whole(self.name)
|
|
272
|
+
return await private.opened_whole(self._store, self.privacy.vault, self.name)
|
|
273
|
+
|
|
274
|
+
async def seal(self) -> None:
|
|
275
|
+
"""Seal the call's log in the store and end every live reader."""
|
|
276
|
+
if self.call is None:
|
|
277
|
+
raise DeclarationRefused(f"agent {self.agent}: an agent's log has no end")
|
|
278
|
+
self.sealed = True
|
|
279
|
+
await self._store.seal(self.call)
|
|
280
|
+
self.fanout.close()
|
|
281
|
+
|
|
282
|
+
# Compared against the store's seq, ephemerals counted, or a live call never hits the memo.
|
|
283
|
+
async def snapshot(self) -> State:
|
|
284
|
+
"""Return the state the log reduces to, folded again only when the log moved."""
|
|
285
|
+
latest = await self._store.latest_seq(self.name)
|
|
286
|
+
if self._snapshot is None or self._snapshot[0] != latest:
|
|
287
|
+
self._snapshot = latest, reduce(await self._store.whole(self.name))
|
|
288
|
+
return self._snapshot[1]
|
|
289
|
+
|
|
290
|
+
# Followed before the fanout is subscribed, so an entry another gateway commits after the
|
|
291
|
+
# backlog is read was published after the subscription was confirmed, and is heard.
|
|
292
|
+
async def followed(self, only: Filter = EVERYTHING) -> Subscription:
|
|
293
|
+
"""A live reader that hears what any gateway writes to this log, from now on."""
|
|
294
|
+
if self._relay is None:
|
|
295
|
+
return self.fanout.subscribe(only)
|
|
296
|
+
await self._relay.follow(CHANNEL.format(name=self.name))
|
|
297
|
+
return self.fanout.subscribe(only, after=self._unfollowed)
|
|
298
|
+
|
|
299
|
+
async def stream(self, *, after: int = 0, only: Filter = EVERYTHING) -> AsyncIterator[Entry]:
|
|
300
|
+
"""Yield the backlog above the cursor, log.caught_up, then what is written from then on."""
|
|
301
|
+
cursor = after
|
|
302
|
+
while True:
|
|
303
|
+
# Subscribed before the backlog is read, so nothing written between the two is lost.
|
|
304
|
+
subscription = await self.followed(only)
|
|
305
|
+
try:
|
|
306
|
+
# Asked before the backlog: a log sealed by then is whole in it, and one sealed
|
|
307
|
+
# after ends its readers here, once they drained what they were given.
|
|
308
|
+
over = self.sealed or await self._store.sealed(self.name)
|
|
309
|
+
for entry in await self._store.whole(self.name, after=cursor):
|
|
310
|
+
cursor = entry.seq
|
|
311
|
+
if only.passes(entry):
|
|
312
|
+
yield entry
|
|
313
|
+
yield self._marker(cursor, "log.caught_up", {"seq": cursor})
|
|
314
|
+
if over:
|
|
315
|
+
return
|
|
316
|
+
async for entry in subscription:
|
|
317
|
+
if entry.seq > cursor:
|
|
318
|
+
cursor = entry.seq
|
|
319
|
+
yield entry
|
|
320
|
+
if not subscription.dropped:
|
|
321
|
+
return
|
|
322
|
+
finally:
|
|
323
|
+
subscription.close()
|
|
324
|
+
# Fell behind: a gap says what was missed and, for a call, carries the state instead
|
|
325
|
+
# of the entries. An agent's log reduces to no state, so its gap re-reads the store.
|
|
326
|
+
if self.call is None:
|
|
327
|
+
yield self._marker(
|
|
328
|
+
cursor, "log.gap", {"from_seq": cursor + 1, "to_seq": cursor, "snapshot": None}
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
to_seq = max(await self._store.latest_seq(self.name), cursor)
|
|
332
|
+
snapshot = (await self.snapshot()).written()
|
|
333
|
+
yield self._marker(
|
|
334
|
+
to_seq, "log.gap", {"from_seq": cursor + 1, "to_seq": to_seq, "snapshot": snapshot}
|
|
335
|
+
)
|
|
336
|
+
cursor = to_seq
|
|
337
|
+
|
|
338
|
+
def _split(self, kind: str, data: JsonObject) -> private.Split:
|
|
339
|
+
if self.privacy is None:
|
|
340
|
+
return private.Split(data, {})
|
|
341
|
+
return private.split(kind, data, self.privacy.config)
|
|
342
|
+
|
|
343
|
+
def _kept_open(
|
|
344
|
+
self, written: Iterable[tuple[Entry, private.Split]]
|
|
345
|
+
) -> list[tuple[int, JsonObject]]:
|
|
346
|
+
aside = [(entry.seq, split.private) for entry, split in written if split.private]
|
|
347
|
+
self.private_values.update(aside)
|
|
348
|
+
return aside
|
|
349
|
+
|
|
350
|
+
async def _sealed_aside(self, aside: list[tuple[int, JsonObject]]) -> None:
|
|
351
|
+
if self.privacy is not None:
|
|
352
|
+
await private.kept_aside(self._store, self.privacy.vault, self.name, aside)
|
|
353
|
+
|
|
354
|
+
def _unfollowed(self) -> None:
|
|
355
|
+
if self._relay is not None:
|
|
356
|
+
self._relay.unfollow(CHANNEL.format(name=self.name))
|
|
357
|
+
|
|
358
|
+
async def _published(self, entry: Entry) -> None:
|
|
359
|
+
if self._relay is None:
|
|
360
|
+
self.fanout.publish(entry)
|
|
361
|
+
else:
|
|
362
|
+
self._relay.local(CHANNEL.format(name=self.name), entry)
|
|
363
|
+
for tap in list(self._taps):
|
|
364
|
+
await tap(entry)
|
|
365
|
+
if entry.type == TERMINAL_EVENT and self.call is not None:
|
|
366
|
+
await self.seal()
|
|
367
|
+
|
|
368
|
+
def _marker(self, seq: int, kind: str, data: JsonObject) -> Entry:
|
|
369
|
+
return Entry(
|
|
370
|
+
seq=seq,
|
|
371
|
+
ts=time.time(),
|
|
372
|
+
call=self.call,
|
|
373
|
+
agent=self.agent,
|
|
374
|
+
type=kind,
|
|
375
|
+
ephemeral=True,
|
|
376
|
+
data=data,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class Logs:
|
|
381
|
+
"""The logs of this process by name, the org feeds and the box's, and who owns what."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, store: Store, signal: Signal | None = None) -> None:
|
|
384
|
+
"""Start with no log open and no feed listened to; alone with no signal."""
|
|
385
|
+
self.store = store
|
|
386
|
+
self.relay = Relay(store, signal or LocalSignal(), self._delivered, self._dropped)
|
|
387
|
+
self.box = Fanout()
|
|
388
|
+
self._logs: dict[str, Log] = {}
|
|
389
|
+
self._feeds: dict[tuple[str, Env], Fanout] = {}
|
|
390
|
+
# A log's owner never changes; caching it keeps a store read off the append path.
|
|
391
|
+
self._owners: dict[str, Claimant] = {}
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def readers(self) -> int:
|
|
395
|
+
"""How many live readers every log and feed of this process holds."""
|
|
396
|
+
fanouts = [self.box, *self._feeds.values(), *(log.fanout for log in self._logs.values())]
|
|
397
|
+
return sum(fanout.readers for fanout in fanouts)
|
|
398
|
+
|
|
399
|
+
def writing(self, call: str, agent: str) -> Log:
|
|
400
|
+
"""Return the call's log for its writer, held until the process forgets the call."""
|
|
401
|
+
log = self._log(call, agent)
|
|
402
|
+
log.agent = agent
|
|
403
|
+
if not log.kept_open:
|
|
404
|
+
log.kept_open = True
|
|
405
|
+
log.tapped(self._fed)
|
|
406
|
+
return log
|
|
407
|
+
|
|
408
|
+
def reading(self, call: str) -> Log:
|
|
409
|
+
"""Return the call's log for a reader, whoever writes it."""
|
|
410
|
+
self._prune(but=call)
|
|
411
|
+
return self._log(call, "")
|
|
412
|
+
|
|
413
|
+
def agent(self, slug: str) -> Log:
|
|
414
|
+
"""Return the agent's own log, for writing or reading."""
|
|
415
|
+
self._prune(but=log_name(None, slug))
|
|
416
|
+
log = self._log(None, slug)
|
|
417
|
+
if not log.kept_open:
|
|
418
|
+
log.kept_open = True
|
|
419
|
+
log.tapped(self._fed)
|
|
420
|
+
return log
|
|
421
|
+
|
|
422
|
+
def opened(self, call: str) -> Log | None:
|
|
423
|
+
"""Return the call's log if this process writes it."""
|
|
424
|
+
log = self._logs.get(call)
|
|
425
|
+
return log if log is not None and log.kept_open else None
|
|
426
|
+
|
|
427
|
+
def forget(self, call: str) -> None:
|
|
428
|
+
"""Drop the call's log, its readers and its cached owner."""
|
|
429
|
+
self._logs.pop(call, None)
|
|
430
|
+
self._owners.pop(call, None)
|
|
431
|
+
|
|
432
|
+
async def close(self) -> None:
|
|
433
|
+
"""Stop following every log."""
|
|
434
|
+
await self.relay.close()
|
|
435
|
+
|
|
436
|
+
def feed(self, org: str, env: Env) -> Fanout:
|
|
437
|
+
"""Return the org's feed in that world: the lifecycle entries of every log it owns there."""
|
|
438
|
+
self._prune()
|
|
439
|
+
feed = self._feeds.get((org, env))
|
|
440
|
+
if feed is None:
|
|
441
|
+
feed = self._feeds[(org, env)] = Fanout()
|
|
442
|
+
return feed
|
|
443
|
+
|
|
444
|
+
# Followed before it is subscribed, as a log's stream is: what any gateway writes from now on.
|
|
445
|
+
async def feed_reader(self, org: str, env: Env) -> Subscription:
|
|
446
|
+
"""A reader of the org's feed in that world, hearing every gateway."""
|
|
447
|
+
channel = FEED_CHANNEL.format(org=org, env=env)
|
|
448
|
+
await self.relay.follow(channel, ordered=False)
|
|
449
|
+
return self.feed(org, env).subscribe(after=lambda: self.relay.unfollow(channel))
|
|
450
|
+
|
|
451
|
+
async def box_reader(self) -> Subscription:
|
|
452
|
+
"""A reader of every org's floor, hearing every gateway."""
|
|
453
|
+
await self.relay.follow(BOX_CHANNEL, ordered=False)
|
|
454
|
+
return self.box.subscribe(after=lambda: self.relay.unfollow(BOX_CHANNEL))
|
|
455
|
+
|
|
456
|
+
# Only a found owner is cached: a log nobody claimed yet may be claimed later.
|
|
457
|
+
async def claimant_of(self, entry: Entry) -> Claimant | None:
|
|
458
|
+
"""Return whose the entry's log is and in which world, asked of the store once per log."""
|
|
459
|
+
name = log_name(entry.call, entry.agent)
|
|
460
|
+
found = self._owners.get(name)
|
|
461
|
+
if found is None:
|
|
462
|
+
found = await self.store.claimant(entry.call, entry.agent)
|
|
463
|
+
if found is not None:
|
|
464
|
+
self._owners[name] = found
|
|
465
|
+
return found
|
|
466
|
+
|
|
467
|
+
def _log(self, call: str | None, agent: str) -> Log:
|
|
468
|
+
name = log_name(call, agent)
|
|
469
|
+
log = self._logs.get(name)
|
|
470
|
+
if log is None:
|
|
471
|
+
log = self._logs[name] = Log(self.store, call, agent, self.relay)
|
|
472
|
+
return log
|
|
473
|
+
|
|
474
|
+
# What the relay hands on, this process's own appends included: a log's readers hear it, and
|
|
475
|
+
# the terminal entry ends them, sealed here or on another gateway; a feed's hear it as it came.
|
|
476
|
+
def _delivered(self, channel: str, entry: Entry) -> None:
|
|
477
|
+
fanout = self._fanout_of(channel)
|
|
478
|
+
if fanout is None:
|
|
479
|
+
return
|
|
480
|
+
fanout.publish(entry)
|
|
481
|
+
log = self._logs.get(log_name(entry.call, entry.agent))
|
|
482
|
+
if entry.type == TERMINAL_EVENT and log is not None and fanout is log.fanout:
|
|
483
|
+
log.sealed = log.call is not None
|
|
484
|
+
if log.sealed:
|
|
485
|
+
fanout.close()
|
|
486
|
+
|
|
487
|
+
def _dropped(self, channel: str) -> None:
|
|
488
|
+
fanout = self._fanout_of(channel)
|
|
489
|
+
if fanout is not None:
|
|
490
|
+
fanout.drop()
|
|
491
|
+
|
|
492
|
+
def _fanout_of(self, channel: str) -> Fanout | None:
|
|
493
|
+
if channel == BOX_CHANNEL:
|
|
494
|
+
return self.box
|
|
495
|
+
if channel.startswith(FEED_PREFIX):
|
|
496
|
+
org, _, env = channel.removeprefix(FEED_PREFIX).rpartition(":")
|
|
497
|
+
return self._feeds.get((org, parse_env(env)))
|
|
498
|
+
log = self._logs.get(channel.removeprefix(LOG_PREFIX))
|
|
499
|
+
return None if log is None else log.fanout
|
|
500
|
+
|
|
501
|
+
# An agent's own entries serve both worlds, so they reach the org's feed in each.
|
|
502
|
+
async def _fed(self, entry: Entry) -> None:
|
|
503
|
+
if entry.type not in ORG_EVENTS:
|
|
504
|
+
return
|
|
505
|
+
claimant = await self.claimant_of(entry)
|
|
506
|
+
if claimant is None:
|
|
507
|
+
return
|
|
508
|
+
self.relay.local(BOX_CHANNEL, entry)
|
|
509
|
+
worlds = ENVS if claimant.env is None else (claimant.env,)
|
|
510
|
+
for env in worlds:
|
|
511
|
+
self.relay.local(FEED_CHANNEL.format(org=claimant.org, env=env), entry)
|
|
512
|
+
|
|
513
|
+
# On every read, so readers asking for arbitrary names cannot grow the tables without bound.
|
|
514
|
+
def _prune(self, *, but: str = "") -> None:
|
|
515
|
+
idle = [
|
|
516
|
+
name
|
|
517
|
+
for name, log in self._logs.items()
|
|
518
|
+
if name != but and not log.kept_open and not log.fanout.readers
|
|
519
|
+
]
|
|
520
|
+
for name in idle:
|
|
521
|
+
del self._logs[name]
|
|
522
|
+
for key in [k for k, feed in self._feeds.items() if not feed.readers]:
|
|
523
|
+
del self._feeds[key]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# The caller is always the far end: on an outbound call that is the `to`.
|
|
527
|
+
def arrival_entry(
|
|
528
|
+
context: CallContext, door: str, *, asked_by: str | None = None
|
|
529
|
+
) -> tuple[str, JsonObject]:
|
|
530
|
+
"""Return a call's first entry: call.dialing when placed, call.ringing when offered."""
|
|
531
|
+
from_, to = _two_ends(context, door)
|
|
532
|
+
data: JsonObject = {
|
|
533
|
+
"channel": context.channel,
|
|
534
|
+
"from": from_,
|
|
535
|
+
"to": to,
|
|
536
|
+
"run": context.run,
|
|
537
|
+
"caller": None,
|
|
538
|
+
}
|
|
539
|
+
if context.direction == "outbound":
|
|
540
|
+
# The one record of who placed an outbound call, which is what audits dial spend.
|
|
541
|
+
dialing = CallDialing.read({**data, "asked_by": asked_by}, "call.dialing")
|
|
542
|
+
return "call.dialing", dialing.written()
|
|
543
|
+
route = Route(channel=context.route.channel, number=context.route.number)
|
|
544
|
+
ringing = CallRinging.read({**data, "route": route.written()}, "call.ringing")
|
|
545
|
+
return "call.ringing", ringing.written()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def started_entry(
|
|
549
|
+
context: CallContext, door: str, at: float, worker: str | None = None, *, medium: Medium
|
|
550
|
+
) -> JsonObject:
|
|
551
|
+
"""Return the call.started entry, written once media is up: spoken or written, and by whom."""
|
|
552
|
+
from_, to = _two_ends(context, door)
|
|
553
|
+
ran_by: JsonObject = {} if worker is None else {"worker": worker}
|
|
554
|
+
opened_in: JsonObject = {"state": dict(context.state)} if context.state else {}
|
|
555
|
+
started = CallStarted.read(
|
|
556
|
+
{
|
|
557
|
+
"channel": context.channel,
|
|
558
|
+
"direction": context.direction,
|
|
559
|
+
"from": from_,
|
|
560
|
+
"to": to,
|
|
561
|
+
"run": context.run,
|
|
562
|
+
# The persona and its rules, frozen here for the facts and the persona judge.
|
|
563
|
+
"persona": context.persona,
|
|
564
|
+
"accepts_when": context.accepts_when,
|
|
565
|
+
"declines_when": context.declines_when,
|
|
566
|
+
"caller": None,
|
|
567
|
+
"started_at": at,
|
|
568
|
+
"env": context.env,
|
|
569
|
+
"medium": medium,
|
|
570
|
+
**ran_by,
|
|
571
|
+
**opened_in,
|
|
572
|
+
},
|
|
573
|
+
"call.started",
|
|
574
|
+
)
|
|
575
|
+
return started.written()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _two_ends(context: CallContext, door: str) -> tuple[str, str]:
|
|
579
|
+
if context.direction == "outbound":
|
|
580
|
+
return door, context.caller
|
|
581
|
+
return context.caller, door
|
pinecall/log/openings.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""What a call is, kept at its open: its context and its config, for any gateway to serve it."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
|
|
7
|
+
from psycopg.types.json import Jsonb
|
|
8
|
+
from pydantic import TypeAdapter
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.agent import AgentConfig
|
|
11
|
+
from pinecall.domain.call import CallContext
|
|
12
|
+
from pinecall.postgres.pool import Pool
|
|
13
|
+
|
|
14
|
+
KEPT_CONFIG = """
|
|
15
|
+
INSERT INTO call_configs (org, hash, config) VALUES (%(org)s, %(hash)s, %(config)s)
|
|
16
|
+
ON CONFLICT (org, hash) DO NOTHING
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# The first opening stands: a reopen of the same call says it again and changes nothing.
|
|
20
|
+
KEPT_OPENING = """
|
|
21
|
+
INSERT INTO call_openings (call, org, context, config_hash)
|
|
22
|
+
VALUES (%(call)s, %(org)s, %(context)s, %(hash)s)
|
|
23
|
+
ON CONFLICT (call) DO NOTHING
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
OPENING = """
|
|
27
|
+
SELECT opening.context, config.config
|
|
28
|
+
FROM call_openings opening
|
|
29
|
+
JOIN call_configs config ON config.org = opening.org AND config.hash = opening.config_hash
|
|
30
|
+
WHERE opening.call = %(call)s
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_CONTEXT: TypeAdapter[CallContext] = TypeAdapter(CallContext)
|
|
34
|
+
_CONFIG: TypeAdapter[AgentConfig] = TypeAdapter(AgentConfig)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Opening:
|
|
39
|
+
"""A call as it opened: what the worker said of it, and the config it runs on."""
|
|
40
|
+
|
|
41
|
+
context: CallContext
|
|
42
|
+
config: AgentConfig
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def kept(pool: Pool, org: str, context: CallContext, config: AgentConfig) -> None:
|
|
46
|
+
"""Keep what the call is, in one transaction: its config once per hash, its opening once."""
|
|
47
|
+
written = _CONFIG.dump_python(config, mode="json")
|
|
48
|
+
hashed = sha256(json.dumps(written, sort_keys=True).encode()).hexdigest()
|
|
49
|
+
opening = {
|
|
50
|
+
"call": context.call,
|
|
51
|
+
"org": org,
|
|
52
|
+
"context": Jsonb(_CONTEXT.dump_python(context, mode="json")),
|
|
53
|
+
"hash": hashed,
|
|
54
|
+
}
|
|
55
|
+
async with pool.connection() as connection, connection.transaction():
|
|
56
|
+
await connection.execute(
|
|
57
|
+
KEPT_CONFIG, {"org": org, "hash": hashed, "config": Jsonb(written)}
|
|
58
|
+
)
|
|
59
|
+
await connection.execute(KEPT_OPENING, opening)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def opening_of(pool: Pool, call: str) -> Opening | None:
|
|
63
|
+
"""What the call is, as it was kept when it opened; None for one opened before it was kept."""
|
|
64
|
+
async with pool.connection() as connection:
|
|
65
|
+
row = await (await connection.execute(OPENING, {"call": call})).fetchone()
|
|
66
|
+
if row is None:
|
|
67
|
+
return None
|
|
68
|
+
return Opening(
|
|
69
|
+
context=_CONTEXT.validate_python(row["context"]),
|
|
70
|
+
config=_CONFIG.validate_python(row["config"]),
|
|
71
|
+
)
|