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,444 @@
|
|
|
1
|
+
"""Who holds each agent: the app sockets registered in this process, per scope, and the line."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pydantic import TypeAdapter
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.agent import AgentConfig
|
|
11
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
12
|
+
from pinecall.domain.names import PRODUCTION, Env, JsonObject
|
|
13
|
+
from pinecall.domain.scope import THE_ORGS_OWN, Scope
|
|
14
|
+
from pinecall.log.logs import Logs
|
|
15
|
+
from pinecall.process.shared import Assigned, Shared, newest
|
|
16
|
+
from pinecall.wire.commands import AgentRegister
|
|
17
|
+
from pinecall.wire.events import (
|
|
18
|
+
AgentConfigured,
|
|
19
|
+
AgentDetached,
|
|
20
|
+
AgentDraining,
|
|
21
|
+
AgentRegistered,
|
|
22
|
+
)
|
|
23
|
+
from pinecall.wire.frames import Entry
|
|
24
|
+
|
|
25
|
+
# Minted, never id(websocket): CPython reuses addresses, and the id travels back as `?app=`.
|
|
26
|
+
type SocketId = str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# A slug is one org's: the scope names the org, the world and whose copy it is.
|
|
30
|
+
type Held = tuple[Scope, str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
AN_APP = "app_"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
NOT_HOLDING = "agent {slug} is not held in {env} by an app of yours that answers an unclaimed call"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ANOTHER_ORGS = "agent {slug} belongs to another org: a slug is one org's"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
NOT_REGISTERED = "agent {slug} is not registered on this socket: register it first"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
NO_AGENT = "no app is holding agent {slug}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
NOT_THAT_APP = "app {app} is not holding agent {slug}: it disconnected, or it never held it"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
NO_UNCLAIMED = (
|
|
52
|
+
"agent {slug} is held only by apps that take no call they did not open: run `pinecall start`"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
NO_DEV_ANSWERER = (
|
|
57
|
+
"agent {slug} is held, but nothing answers the console for it: "
|
|
58
|
+
"run `pinecall start` in its directory"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
type Stop = Callable[[str], Awaitable[None]]
|
|
63
|
+
|
|
64
|
+
# Every gateway's sockets, lines and phones, said on this channel and merged by each.
|
|
65
|
+
HELD_CHANNEL = "held"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Registration:
|
|
70
|
+
"""One agent as one app socket holds it."""
|
|
71
|
+
|
|
72
|
+
slug: str
|
|
73
|
+
scope: Scope
|
|
74
|
+
owner: SocketId
|
|
75
|
+
config: AgentConfig
|
|
76
|
+
sdk: str | None = None
|
|
77
|
+
# False for a console: it takes only the calls that name it.
|
|
78
|
+
takes_unclaimed: bool = True
|
|
79
|
+
# True for the CLI's companion: the console's dev verbs go to it, never to the class's socket.
|
|
80
|
+
answers_dev: bool = False
|
|
81
|
+
# When it was claimed, on any gateway, so the newest holder is known across all of them.
|
|
82
|
+
claimed: float = 0.0
|
|
83
|
+
# Set by agent.drain: its tools still answer, it takes no new call.
|
|
84
|
+
draining: bool = False
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def held_as(self) -> Held:
|
|
88
|
+
"""The registry's key for this agent in its scope."""
|
|
89
|
+
return (self.scope, self.slug)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Share:
|
|
94
|
+
"""What one gateway holds: its sockets' agents, and the lines and phones it set."""
|
|
95
|
+
|
|
96
|
+
registrations: tuple[Registration, ...] = ()
|
|
97
|
+
lines: tuple[Assigned, ...] = ()
|
|
98
|
+
phones: tuple[Assigned, ...] = ()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Durable facts are in the log; this is which sockets are open now, on every gateway of the box:
|
|
102
|
+
# each says its own share on the signal and reads the merged view. The line is where a ring
|
|
103
|
+
# lands: the org's own scope in production, one developer's in the sandbox, which an org shares.
|
|
104
|
+
class Sockets:
|
|
105
|
+
"""The agents the app sockets hold, per scope, the line of each, and the developers' phones."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, logs: Logs) -> None:
|
|
108
|
+
"""Nobody holds anything yet."""
|
|
109
|
+
self.logs = logs
|
|
110
|
+
# This gateway's own sockets, and the lines and phones it set.
|
|
111
|
+
self.mine: dict[Held, list[Registration]] = {}
|
|
112
|
+
self.mine_lines: dict[tuple[str, str], Assigned] = {}
|
|
113
|
+
self.mine_phones: dict[tuple[str, str], Assigned] = {}
|
|
114
|
+
# The merged view every read answers from: every gateway's.
|
|
115
|
+
self.holders: dict[Held, list[Registration]] = {}
|
|
116
|
+
self.lines: dict[tuple[str, str], str] = {}
|
|
117
|
+
self.phones: dict[tuple[str, str], str] = {}
|
|
118
|
+
self.owned: dict[SocketId, set[Held]] = {}
|
|
119
|
+
self.shared = Shared(logs.relay.signal, HELD_CHANNEL, _SHARE, Share(), self._merged)
|
|
120
|
+
self._last_claim = 0.0
|
|
121
|
+
|
|
122
|
+
async def start(self) -> None:
|
|
123
|
+
"""Hear the other gateways' sockets, and say this one's."""
|
|
124
|
+
await self.shared.start()
|
|
125
|
+
|
|
126
|
+
async def close(self) -> None:
|
|
127
|
+
"""Stop hearing and saying."""
|
|
128
|
+
await self.shared.close()
|
|
129
|
+
|
|
130
|
+
# ── reading ──
|
|
131
|
+
|
|
132
|
+
def of(self, scope: Scope, slug: str) -> Registration | None:
|
|
133
|
+
"""The newest holder in this scope, else in the org's own."""
|
|
134
|
+
return self._newest((scope, slug)) or self._newest((orgs_own(scope), slug))
|
|
135
|
+
|
|
136
|
+
def serving(self, scope: Scope, slug: str, app: SocketId | None) -> Registration | None:
|
|
137
|
+
"""The socket a call opened in this scope goes to: the one named, else the newest taker."""
|
|
138
|
+
if app is not None:
|
|
139
|
+
found = self.on(app, scope.env, slug)
|
|
140
|
+
return found if found is not None and found.scope.org == scope.org else None
|
|
141
|
+
return self._taker((scope, slug)) or self._taker((orgs_own(scope), slug))
|
|
142
|
+
|
|
143
|
+
def answering_dev(self, scope: Scope, slug: str) -> Registration | None:
|
|
144
|
+
"""The socket the console's dev verbs go to: the newest that answers them, scope first."""
|
|
145
|
+
return self._answerer((scope, slug)) or self._answerer((orgs_own(scope), slug))
|
|
146
|
+
|
|
147
|
+
# A caller's registered phone reaches their own scope first; then the line.
|
|
148
|
+
def taking(self, scope: Scope, slug: str, caller: str | None) -> Registration | None:
|
|
149
|
+
"""The socket a ring at the agent lands in."""
|
|
150
|
+
if caller is not None and (holder := self.phones.get((scope.env, caller))) is not None:
|
|
151
|
+
theirs = self._taker((replace(scope, holder=holder), slug))
|
|
152
|
+
if theirs is not None:
|
|
153
|
+
return theirs
|
|
154
|
+
holder = self.lines.get((scope.env, slug))
|
|
155
|
+
if holder is None:
|
|
156
|
+
return None
|
|
157
|
+
return self._taker((replace(scope, holder=holder), slug))
|
|
158
|
+
|
|
159
|
+
def on(self, app: SocketId, env: Env, slug: str) -> Registration | None:
|
|
160
|
+
"""The agent as this socket holds it in this world."""
|
|
161
|
+
for found in self.owned.get(app, ()):
|
|
162
|
+
if found[0].env == env and found[1] == slug:
|
|
163
|
+
return next(
|
|
164
|
+
(item for item in self.holders.get(found, ()) if item.owner == app), None
|
|
165
|
+
)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def owned_by(self, app: SocketId) -> list[Registration]:
|
|
169
|
+
"""Every agent one socket holds, in claim order."""
|
|
170
|
+
found = (item for name in self.owned.get(app, ()) for item in self.holders.get(name, ()))
|
|
171
|
+
return sorted(
|
|
172
|
+
(holder for holder in found if holder.owner == app),
|
|
173
|
+
key=lambda item_found: item_found.claimed,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# One row per slug, the reader's scope over the org's; every scope for a team reader.
|
|
177
|
+
def holding(self, scope: Scope, *, every_corner: bool) -> list[Registration]:
|
|
178
|
+
"""The org's held agents in the world, as this reader sees them."""
|
|
179
|
+
seen: dict[str | Held, Registration] = {}
|
|
180
|
+
for (owner, slug), holding in self.holders.items():
|
|
181
|
+
if owner.org != scope.org or owner.env != scope.env or not holding:
|
|
182
|
+
continue
|
|
183
|
+
mine = owner.holder in (THE_ORGS_OWN, scope.holder)
|
|
184
|
+
if not every_corner and not mine:
|
|
185
|
+
continue
|
|
186
|
+
under: str | Held = (owner, slug) if every_corner else slug
|
|
187
|
+
if under not in seen or owner.holder == scope.holder:
|
|
188
|
+
seen[under] = holding[-1]
|
|
189
|
+
return list(seen.values())
|
|
190
|
+
|
|
191
|
+
def slugs(self, org: str) -> frozenset[str]:
|
|
192
|
+
"""Every slug the org holds, in any world and scope: the agents quota counts these."""
|
|
193
|
+
return frozenset(
|
|
194
|
+
slug
|
|
195
|
+
for (owner_scope, slug), kept in self.holders.items()
|
|
196
|
+
if owner_scope.org == org and kept
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# A log reader knows the slug alone: production's declaration, else any world's.
|
|
200
|
+
def declared(self, slug: str) -> AgentConfig | None:
|
|
201
|
+
"""The agent's declared config, production's first."""
|
|
202
|
+
found = sorted(
|
|
203
|
+
(kept[-1] for (_, name), kept in self.holders.items() if name == slug and kept),
|
|
204
|
+
key=lambda item_found: (item_found.scope.env != PRODUCTION, -item_found.claimed),
|
|
205
|
+
)
|
|
206
|
+
return found[0].config if found else None
|
|
207
|
+
|
|
208
|
+
# ── the line ──
|
|
209
|
+
|
|
210
|
+
def line_of(self, env: Env, slug: str) -> str | None:
|
|
211
|
+
"""The scope holding the agent's line; None when nobody does."""
|
|
212
|
+
return self.lines.get((env, slug))
|
|
213
|
+
|
|
214
|
+
# Taking the line is said out loud: starting later never takes a colleague's calls.
|
|
215
|
+
def take_the_line(self, scope: Scope, slug: str) -> None:
|
|
216
|
+
"""Give the agent's line to this scope, which must hold an app that answers a ring."""
|
|
217
|
+
if self._taker((scope, slug)) is None:
|
|
218
|
+
raise DeclarationRefused(NOT_HOLDING.format(slug=slug, env=scope.env))
|
|
219
|
+
self._assigned(self.mine_lines, scope.env, slug, scope.holder)
|
|
220
|
+
|
|
221
|
+
def drop_the_line(self, scope: Scope, slug: str) -> bool:
|
|
222
|
+
"""Let the line go, to the newest other scope that could take it."""
|
|
223
|
+
if self.lines.get((scope.env, slug)) != scope.holder:
|
|
224
|
+
return False
|
|
225
|
+
self._assigned(self.mine_lines, scope.env, slug, None)
|
|
226
|
+
self._next_takes_the_line(scope, slug, leaving=scope.holder)
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
def waiting_for_the_line(self, scope: Scope, slug: str) -> list[Registration]:
|
|
230
|
+
"""Every scope of the org that could take the line, newest first."""
|
|
231
|
+
taking = [
|
|
232
|
+
item
|
|
233
|
+
for (owner_scope, name) in self.holders
|
|
234
|
+
if name == slug and owner_scope.org == scope.org and owner_scope.env == scope.env
|
|
235
|
+
if (item := self._taker((owner_scope, name))) is not None
|
|
236
|
+
]
|
|
237
|
+
return sorted(taking, key=lambda item: item.claimed, reverse=True)
|
|
238
|
+
|
|
239
|
+
# ── the developers' phones ──
|
|
240
|
+
|
|
241
|
+
def calls_from(self, env: Env, number: str, holder: str) -> None:
|
|
242
|
+
"""Send rings from this phone to this person's scope; the last one said wins."""
|
|
243
|
+
self._assigned(self.mine_phones, env, number, holder)
|
|
244
|
+
|
|
245
|
+
def forget_calls_from(self, env: Env, holder: str) -> list[str]:
|
|
246
|
+
"""Forget every phone of this person, and say which."""
|
|
247
|
+
gone = sorted(
|
|
248
|
+
n
|
|
249
|
+
for (world, n), owner_scope in self.phones.items()
|
|
250
|
+
if world == env and owner_scope == holder
|
|
251
|
+
)
|
|
252
|
+
for number in gone:
|
|
253
|
+
self._assigned(self.mine_phones, env, number, None)
|
|
254
|
+
return gone
|
|
255
|
+
|
|
256
|
+
def phone_of(self, env: Env, number: str) -> str | None:
|
|
257
|
+
"""Whose own phone the number is in the world, or None."""
|
|
258
|
+
return self.phones.get((env, number.strip()))
|
|
259
|
+
|
|
260
|
+
def calling(self, env: Env, holder: str) -> list[str]:
|
|
261
|
+
"""The phones this person rings from."""
|
|
262
|
+
return sorted(
|
|
263
|
+
n
|
|
264
|
+
for (world, n), owner_scope in self.phones.items()
|
|
265
|
+
if world == env and owner_scope == holder
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# ── claiming ──
|
|
269
|
+
|
|
270
|
+
async def register(
|
|
271
|
+
self, owner: SocketId, scope: Scope, slug: str, wanted: AgentRegister
|
|
272
|
+
) -> Entry:
|
|
273
|
+
"""This socket holds the agent from now on; agent.registered is written on its log."""
|
|
274
|
+
store = self.logs.store
|
|
275
|
+
owner_org = await store.owner(slug)
|
|
276
|
+
if owner_org is not None and owner_org != scope.org:
|
|
277
|
+
raise DeclarationRefused(ANOTHER_ORGS.format(slug=slug))
|
|
278
|
+
await store.claim(None, slug, scope.org)
|
|
279
|
+
# A call that arrives before agent.configure still runs on what the agent declared.
|
|
280
|
+
registration = self.on(owner, scope.env, slug) or self.of(scope, slug)
|
|
281
|
+
config = registration.config if registration is not None else AgentConfig(slug=slug)
|
|
282
|
+
self._replace(
|
|
283
|
+
Registration(
|
|
284
|
+
slug=slug,
|
|
285
|
+
scope=scope,
|
|
286
|
+
owner=owner,
|
|
287
|
+
config=config,
|
|
288
|
+
sdk=wanted.sdk,
|
|
289
|
+
takes_unclaimed=wanted.takes_unclaimed,
|
|
290
|
+
answers_dev=wanted.answers_dev,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
data = AgentRegistered(routes=[], app=owner, sdk=wanted.sdk, env=scope.env)
|
|
294
|
+
return await self._written(scope.env, slug, "agent.registered", data.written())
|
|
295
|
+
|
|
296
|
+
async def configure(
|
|
297
|
+
self, owner: SocketId, env: Env, slug: str, config: AgentConfig, changed: Sequence[str]
|
|
298
|
+
) -> Entry:
|
|
299
|
+
"""The declaration this socket sent; agent.configured names what changed."""
|
|
300
|
+
found = self.on(owner, env, slug)
|
|
301
|
+
if found is None:
|
|
302
|
+
raise DeclarationRefused(NOT_REGISTERED.format(slug=slug))
|
|
303
|
+
self._replace(replace(found, config=config))
|
|
304
|
+
data = AgentConfigured(changed=list(changed))
|
|
305
|
+
return await self._written(env, slug, "agent.configured", data.written())
|
|
306
|
+
|
|
307
|
+
# A deploy: the socket keeps the agent so its tools still answer, and takes no new call.
|
|
308
|
+
def drain(self, owner: SocketId, env: Env, slug: str) -> None:
|
|
309
|
+
"""Mark the socket as draining the agent."""
|
|
310
|
+
found = self.on(owner, env, slug)
|
|
311
|
+
if found is None:
|
|
312
|
+
raise DeclarationRefused(NOT_REGISTERED.format(slug=slug))
|
|
313
|
+
self._replace(replace(found, draining=True))
|
|
314
|
+
|
|
315
|
+
async def drained(
|
|
316
|
+
self, owner: SocketId, env: Env, slug: str, *, handed: int, parked: int
|
|
317
|
+
) -> Entry:
|
|
318
|
+
"""agent.draining, once the socket's calls moved."""
|
|
319
|
+
data = AgentDraining(app=owner, env=env, handed=handed, parked=parked)
|
|
320
|
+
return await self._written(env, slug, "agent.draining", data.written())
|
|
321
|
+
|
|
322
|
+
async def release(self, owner: SocketId) -> None:
|
|
323
|
+
"""A socket closed: its agents let go, the line handed on, agent.detached for each."""
|
|
324
|
+
theirs = [key for key, kept in self.mine.items() if any(i.owner == owner for i in kept)]
|
|
325
|
+
for found in theirs:
|
|
326
|
+
kept = [item for item in self.mine[found] if item.owner != owner]
|
|
327
|
+
if kept:
|
|
328
|
+
self.mine[found] = kept
|
|
329
|
+
else:
|
|
330
|
+
del self.mine[found]
|
|
331
|
+
self._merged()
|
|
332
|
+
left = self.holders.get(found, [])
|
|
333
|
+
scope, slug = found
|
|
334
|
+
if self.lines.get((scope.env, slug)) == scope.holder and not left:
|
|
335
|
+
self._assigned(self.mine_lines, scope.env, slug, None)
|
|
336
|
+
self._next_takes_the_line(scope, slug)
|
|
337
|
+
data = AgentDetached(app=owner, env=scope.env, left=not left)
|
|
338
|
+
await self._written(scope.env, slug, "agent.detached", data.written())
|
|
339
|
+
self._say()
|
|
340
|
+
|
|
341
|
+
# Claimed at the gateway's clock, never earlier than its last claim: the newest is the last.
|
|
342
|
+
def _replace(self, registration: Registration) -> None:
|
|
343
|
+
self._last_claim = max(time.time(), self._last_claim + 1e-6)
|
|
344
|
+
claim = replace(registration, claimed=self._last_claim)
|
|
345
|
+
holding = self.mine.setdefault(claim.held_as, [])
|
|
346
|
+
at = next(
|
|
347
|
+
(n for n, item_found in enumerate(holding) if item_found.owner == claim.owner), None
|
|
348
|
+
)
|
|
349
|
+
if at is None:
|
|
350
|
+
holding.append(claim)
|
|
351
|
+
else:
|
|
352
|
+
holding[at] = claim
|
|
353
|
+
self._merged()
|
|
354
|
+
# The first scope able to take a ring gets the line; later ones claim it.
|
|
355
|
+
taking = claim.takes_unclaimed and not claim.draining
|
|
356
|
+
if taking and (claim.scope.env, claim.slug) not in self.lines:
|
|
357
|
+
self._assigned(self.mine_lines, claim.scope.env, claim.slug, claim.scope.holder)
|
|
358
|
+
self._say()
|
|
359
|
+
|
|
360
|
+
# A line or a phone set here: said to every gateway, the newest setting of a key standing.
|
|
361
|
+
def _assigned(
|
|
362
|
+
self, table: dict[tuple[str, str], Assigned], env: Env, key: str, holder: str | None
|
|
363
|
+
) -> None:
|
|
364
|
+
table[(env, key)] = Assigned(env, key, holder, time.time())
|
|
365
|
+
self._merged()
|
|
366
|
+
self._say()
|
|
367
|
+
|
|
368
|
+
def _say(self) -> None:
|
|
369
|
+
self.shared.put(
|
|
370
|
+
Share(
|
|
371
|
+
registrations=tuple(item for kept in self.mine.values() for item in kept),
|
|
372
|
+
lines=tuple(self.mine_lines.values()),
|
|
373
|
+
phones=tuple(self.mine_phones.values()),
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Every gateway's sockets, oldest claim first; each line and phone as its newest setting says.
|
|
378
|
+
def _merged(self) -> None:
|
|
379
|
+
theirs = [heard.share for heard in self.shared.theirs.values()]
|
|
380
|
+
holders: dict[Held, list[Registration]] = {k: list(v) for k, v in self.mine.items()}
|
|
381
|
+
for share in theirs:
|
|
382
|
+
for item in share.registrations:
|
|
383
|
+
holders.setdefault(item.held_as, []).append(item)
|
|
384
|
+
owned: dict[SocketId, set[Held]] = {}
|
|
385
|
+
for key, kept in holders.items():
|
|
386
|
+
kept.sort(key=lambda item: item.claimed)
|
|
387
|
+
for item in kept:
|
|
388
|
+
owned.setdefault(item.owner, set()).add(key)
|
|
389
|
+
self.holders, self.owned = holders, owned
|
|
390
|
+
self.lines = newest(
|
|
391
|
+
self.mine_lines, (setting for share in theirs for setting in share.lines)
|
|
392
|
+
)
|
|
393
|
+
self.phones = newest(
|
|
394
|
+
self.mine_phones, (setting for share in theirs for setting in share.phones)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def _newest(self, holding_found: Held) -> Registration | None:
|
|
398
|
+
holding = self.holders.get(holding_found)
|
|
399
|
+
return holding[-1] if holding else None
|
|
400
|
+
|
|
401
|
+
def _taker(self, holding_found: Held) -> Registration | None:
|
|
402
|
+
holding = reversed(self.holders.get(holding_found, ()))
|
|
403
|
+
return next((item for item in holding if item.takes_unclaimed and not item.draining), None)
|
|
404
|
+
|
|
405
|
+
def _answerer(self, holding_found: Held) -> Registration | None:
|
|
406
|
+
holding = reversed(self.holders.get(holding_found, ()))
|
|
407
|
+
return next((item for item in holding if item.answers_dev and not item.draining), None)
|
|
408
|
+
|
|
409
|
+
def _next_takes_the_line(self, scope: Scope, slug: str, *, leaving: str | None = None) -> None:
|
|
410
|
+
waiting = [
|
|
411
|
+
item for item in self.waiting_for_the_line(scope, slug) if item.scope.holder != leaving
|
|
412
|
+
]
|
|
413
|
+
if waiting:
|
|
414
|
+
self._assigned(self.mine_lines, scope.env, slug, waiting[0].scope.holder)
|
|
415
|
+
|
|
416
|
+
# A laptop restarts often: in the sandbox these entries are forgettable, or they flood the log.
|
|
417
|
+
async def _written(self, env: Env, slug: str, kind: str, data: JsonObject) -> Entry:
|
|
418
|
+
forgettable = None if env == PRODUCTION else True
|
|
419
|
+
return await self.logs.agent(slug).append(kind, data, ephemeral=forgettable)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass(frozen=True)
|
|
423
|
+
class Process:
|
|
424
|
+
"""One connected app socket, as the org's list of processes shows it."""
|
|
425
|
+
|
|
426
|
+
app: SocketId
|
|
427
|
+
scope: Scope
|
|
428
|
+
address: str | None
|
|
429
|
+
connected_at: float
|
|
430
|
+
# Sends `error stopped` and closes: the SDK exits instead of reconnecting.
|
|
431
|
+
stop: Stop
|
|
432
|
+
host: str | None = None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
_SHARE: TypeAdapter[Share] = TypeAdapter(Share)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def orgs_own(scope: Scope) -> Scope:
|
|
439
|
+
return replace(scope, holder=THE_ORGS_OWN)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def new_socket_id() -> SocketId:
|
|
443
|
+
"""A new id for an app socket."""
|
|
444
|
+
return f"{AN_APP}{uuid4().hex[:12]}"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Server-sent events: the frames, the pings that keep a stream alive, its end on shutdown."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.names import JsonObject
|
|
10
|
+
|
|
11
|
+
SSE = "text/event-stream"
|
|
12
|
+
|
|
13
|
+
# Short: a reconnect resumes losslessly from Last-Event-ID.
|
|
14
|
+
RETRY_MS = 1000
|
|
15
|
+
|
|
16
|
+
# Under the 30 s a proxy lets a quiet connection idle.
|
|
17
|
+
PING_S = 25.0
|
|
18
|
+
|
|
19
|
+
PING = ": ping\n\n"
|
|
20
|
+
|
|
21
|
+
# x-accel-buffering: nginx would hold the stream in its buffer.
|
|
22
|
+
SSE_HEADERS = {"cache-control": "no-store", "connection": "keep-alive", "x-accel-buffering": "no"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def wants_sse(accept: str | None) -> bool:
|
|
26
|
+
"""Whether the Accept header asks for the stream rather than a page."""
|
|
27
|
+
return SSE in (accept or "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def frame(event: str, data: JsonObject, *, seq: int | None = None) -> str:
|
|
31
|
+
"""One SSE frame: its id (the seq), its event, its data as one line of JSON."""
|
|
32
|
+
head = "" if seq is None else f"id: {seq}\n"
|
|
33
|
+
return f"{head}event: {event}\ndata: {json.dumps(data, separators=(',', ':'))}\n\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def streamed(frames: AsyncIterator[str], closing: asyncio.Event) -> StreamingResponse:
|
|
37
|
+
"""Frames as an SSE response, ended when they run out or the process stops."""
|
|
38
|
+
return StreamingResponse(until(closing, frames), media_type=SSE, headers=SSE_HEADERS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# One pending __anext__ across pings: a new one after a ping would lose the item the first waits
|
|
42
|
+
# for.
|
|
43
|
+
async def paced[T](coming: AsyncIterator[T], every_s: float = PING_S) -> AsyncIterator[T | None]:
|
|
44
|
+
"""The items as they come, and None after every quiet stretch."""
|
|
45
|
+
pending: asyncio.Task[T] | None = None
|
|
46
|
+
try:
|
|
47
|
+
while True:
|
|
48
|
+
if pending is None:
|
|
49
|
+
pending = asyncio.ensure_future(anext(coming))
|
|
50
|
+
done, _ = await asyncio.wait({pending}, timeout=every_s)
|
|
51
|
+
if not done:
|
|
52
|
+
yield None
|
|
53
|
+
continue
|
|
54
|
+
finished, pending = pending, None
|
|
55
|
+
try:
|
|
56
|
+
yield finished.result()
|
|
57
|
+
except StopAsyncIteration:
|
|
58
|
+
return
|
|
59
|
+
finally:
|
|
60
|
+
if pending is not None:
|
|
61
|
+
pending.cancel()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def until[T](closing: asyncio.Event, coming: AsyncIterator[T]) -> AsyncIterator[T]:
|
|
65
|
+
"""The items until they end or the process starts closing."""
|
|
66
|
+
stopping = asyncio.ensure_future(closing.wait())
|
|
67
|
+
pending: asyncio.Task[T] | None = None
|
|
68
|
+
try:
|
|
69
|
+
while True:
|
|
70
|
+
if pending is None:
|
|
71
|
+
pending = asyncio.ensure_future(anext(coming))
|
|
72
|
+
await asyncio.wait({pending, stopping}, return_when=asyncio.FIRST_COMPLETED)
|
|
73
|
+
if not pending.done():
|
|
74
|
+
return
|
|
75
|
+
finished, pending = pending, None
|
|
76
|
+
try:
|
|
77
|
+
yield finished.result()
|
|
78
|
+
except StopAsyncIteration:
|
|
79
|
+
return
|
|
80
|
+
finally:
|
|
81
|
+
stopping.cancel()
|
|
82
|
+
if pending is not None:
|
|
83
|
+
pending.cancel()
|