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,534 @@
|
|
|
1
|
+
"""Settings: every variable the gateway and the worker read, and the three places it comes from."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Iterator, Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal, Self
|
|
7
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
8
|
+
|
|
9
|
+
from dotenv import dotenv_values
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
from pinecall.domain.errors import SettingsRefused
|
|
13
|
+
from pinecall.domain.names import SANDBOX, Env
|
|
14
|
+
from pinecall.postgres.pool import POOL_SIZE
|
|
15
|
+
|
|
16
|
+
# Checked in each directory up to the repository root; `runtime/.env` serves a process started
|
|
17
|
+
# from the checkout root. When both exist the later wins.
|
|
18
|
+
ENV_FILES = (".env", "runtime/.env")
|
|
19
|
+
|
|
20
|
+
# Slug-shaped: it ends up in Twilio usernames and SFU trunk names. Never empty: an empty
|
|
21
|
+
# agent_name makes LiveKit dispatch to every room (livekit worker.py:219).
|
|
22
|
+
A_FLEET_NAME = r"^[a-z0-9][a-z0-9-]{0,62}$"
|
|
23
|
+
|
|
24
|
+
UNREADABLE_ENV = "cannot read {file}: {why}. A .env that is there is never skipped in silence"
|
|
25
|
+
|
|
26
|
+
NO_STORE = (
|
|
27
|
+
"PINECALL_RECORDINGS_BUCKET is a bucket of the object store, which is named by "
|
|
28
|
+
"PINECALL_S3_ENDPOINT, PINECALL_S3_REGION, PINECALL_S3_ACCESS_KEY_ID and the "
|
|
29
|
+
"PINECALL_S3_SECRET_ACCESS_KEY credential together; missing: {missing}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Spoken by the overflow agent when every worker is full, before hanging up.
|
|
33
|
+
OVERFLOW_SAYS = (
|
|
34
|
+
"En este momento todas nuestras líneas están ocupadas. Hemos tomado nota de su número "
|
|
35
|
+
"y le devolveremos la llamada en cuanto se libere una. Gracias por su paciencia."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Settings(BaseModel):
|
|
40
|
+
"""Typed, frozen process settings; `load()` builds them."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(
|
|
43
|
+
frozen=True, extra="ignore", validate_by_name=True, validate_by_alias=True
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# ── the media plane ──
|
|
47
|
+
livekit_url: str = Field(
|
|
48
|
+
"ws://127.0.0.1:7880",
|
|
49
|
+
alias="LIVEKIT_URL",
|
|
50
|
+
description="LiveKit, which both processes talk to. One port serves ws and http.",
|
|
51
|
+
)
|
|
52
|
+
# A world's rooms live on its own LiveKit, so a sandbox call never shares a machine with a
|
|
53
|
+
# production one; a worker is given its world's in LIVEKIT_URL.
|
|
54
|
+
livekit_sandbox_url: str | None = Field(
|
|
55
|
+
None,
|
|
56
|
+
alias="LIVEKIT_SANDBOX_URL",
|
|
57
|
+
description="The sandbox's own LiveKit, as the gateway reaches it. Unset: LIVEKIT_URL's.",
|
|
58
|
+
)
|
|
59
|
+
livekit_api_key: str | None = Field(
|
|
60
|
+
None, alias="LIVEKIT_API_KEY", description="The LiveKit API key, as its own config says."
|
|
61
|
+
)
|
|
62
|
+
# Also signs call tokens, which are LiveKit room tokens. Without the pair, no tokens.
|
|
63
|
+
livekit_api_secret: str | None = Field(
|
|
64
|
+
None, alias="LIVEKIT_API_SECRET", repr=False, description="Its secret."
|
|
65
|
+
)
|
|
66
|
+
livekit_public_url: str | None = Field(
|
|
67
|
+
None,
|
|
68
|
+
alias="LIVEKIT_PUBLIC_URL",
|
|
69
|
+
description="The LiveKit URL a browser joins when the box has no name. Unset: LIVEKIT_URL.",
|
|
70
|
+
)
|
|
71
|
+
# One name serves both worlds: a request's world is its key's or its header's, never the name
|
|
72
|
+
# it came in by (tenancy/keys.py, world_of).
|
|
73
|
+
domain: str | None = Field(
|
|
74
|
+
None,
|
|
75
|
+
alias="PINECALL_DOMAIN",
|
|
76
|
+
description="The box's name: where the public, the SDKs and a carrier reach it.",
|
|
77
|
+
)
|
|
78
|
+
# Where a carrier sends a world's calls, when that is not the box's name: in a cluster the
|
|
79
|
+
# name is Google's HTTPS load balancer, which carries no SIP, and each world's SIP node has
|
|
80
|
+
# an address of its own (infra/README.md).
|
|
81
|
+
sip_domain: str | None = Field(
|
|
82
|
+
None,
|
|
83
|
+
alias="PINECALL_SIP_DOMAIN",
|
|
84
|
+
description="The name a carrier sends production's calls to. Unset: the box's name.",
|
|
85
|
+
)
|
|
86
|
+
sandbox_sip_domain: str | None = Field(
|
|
87
|
+
None,
|
|
88
|
+
alias="PINECALL_SANDBOX_SIP_DOMAIN",
|
|
89
|
+
description="The name a carrier sends the sandbox's calls to. Unset: production's.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# ── Postgres ──
|
|
93
|
+
database_url: str = Field(
|
|
94
|
+
"postgresql://pinecall:pinecall@127.0.0.1:5432/pinecall",
|
|
95
|
+
alias="DATABASE_URL",
|
|
96
|
+
repr=False,
|
|
97
|
+
description="Postgres 17 with pgvector and pg_textsearch: the one stateful service.",
|
|
98
|
+
)
|
|
99
|
+
# Where the gateway connects as a role that reads and writes rows and changes no table: the
|
|
100
|
+
# migrations, run as the owner, grant it that and no more each time they run.
|
|
101
|
+
db_app_role: str | None = Field(
|
|
102
|
+
None,
|
|
103
|
+
alias="PINECALL_DB_APP_ROLE",
|
|
104
|
+
description="The role `migrate up` grants rows to; unset, the owner is the app's role too.",
|
|
105
|
+
)
|
|
106
|
+
db_pool: int = Field(
|
|
107
|
+
POOL_SIZE,
|
|
108
|
+
alias="PINECALL_DB_POOL",
|
|
109
|
+
ge=3,
|
|
110
|
+
description="Connections to Postgres: two its writer holds, the rest the doors share.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# ── the signal between gateways ──
|
|
114
|
+
# A URL may carry a password. Unset, the gateway is alone on its box and keeps it in memory.
|
|
115
|
+
redis_url: str | None = Field(
|
|
116
|
+
None,
|
|
117
|
+
alias="PINECALL_REDIS_URL",
|
|
118
|
+
repr=False,
|
|
119
|
+
description="The Redis the gateways tell each other what just happened on. Unset: one.",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# ── recordings ──
|
|
123
|
+
recordings_root: str = Field(
|
|
124
|
+
"recordings",
|
|
125
|
+
alias="PINECALL_RECORDINGS",
|
|
126
|
+
description="Where a kept recording lands, absolute or relative to the working directory.",
|
|
127
|
+
)
|
|
128
|
+
# A bucket of the object store below; never the backup's bucket, whose 35-day lifecycle rule
|
|
129
|
+
# would forget a recording its org keeps longer.
|
|
130
|
+
recordings_bucket: str | None = Field(
|
|
131
|
+
None,
|
|
132
|
+
alias="PINECALL_RECORDINGS_BUCKET",
|
|
133
|
+
description="The bucket a finished recording moves to, under its org. Unset: the disk.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# A box's WAL spool: archive_command copied each segment there and a timer shipped it. A
|
|
137
|
+
# cluster's Postgres archives through its operator, and has no spool to read.
|
|
138
|
+
wal_spool: str | None = Field(
|
|
139
|
+
"/var/lib/pinecall/wal",
|
|
140
|
+
alias="PINECALL_WAL_SPOOL",
|
|
141
|
+
description="The box's WAL spool doctor reads the backlog of; empty in a cluster.",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# ── the object store ──
|
|
145
|
+
# Any S3-compatible endpoint: AWS, Google Cloud Storage by HMAC key, R2, B2, a MinIO.
|
|
146
|
+
s3_endpoint: str | None = Field(
|
|
147
|
+
None,
|
|
148
|
+
alias="PINECALL_S3_ENDPOINT",
|
|
149
|
+
description="The S3-compatible object store what leaves the disk goes to.",
|
|
150
|
+
)
|
|
151
|
+
s3_region: str | None = Field(
|
|
152
|
+
None,
|
|
153
|
+
alias="PINECALL_S3_REGION",
|
|
154
|
+
description="The region the store's signature names: the buckets' own, or `auto`.",
|
|
155
|
+
)
|
|
156
|
+
s3_access_key_id: str | None = Field(
|
|
157
|
+
None,
|
|
158
|
+
alias="PINECALL_S3_ACCESS_KEY_ID",
|
|
159
|
+
description="The key the box reads and writes the store with.",
|
|
160
|
+
)
|
|
161
|
+
s3_secret_access_key: str | None = Field(
|
|
162
|
+
None,
|
|
163
|
+
alias="PINECALL_S3_SECRET_ACCESS_KEY",
|
|
164
|
+
repr=False,
|
|
165
|
+
description="The key's secret: a sealed credential on a box, never a file.",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# ── the worker ──
|
|
169
|
+
# Read from the environment because livekit runs each job in a child process that inherits
|
|
170
|
+
# only the environment. The gateway also binds this URL's host and port.
|
|
171
|
+
gateway_url: str = Field(
|
|
172
|
+
"http://127.0.0.1:8080",
|
|
173
|
+
alias="PINECALL_GATEWAY_URL",
|
|
174
|
+
description="The instance's gateway: where it binds, on loopback, and what a worker asks.",
|
|
175
|
+
)
|
|
176
|
+
# A box's gateway binds PINECALL_GATEWAY_URL's loopback address, behind Caddy; a pod's binds
|
|
177
|
+
# its own network, behind the cluster's load balancer, and says so here (infra/charts).
|
|
178
|
+
gateway_listen: str | None = Field(
|
|
179
|
+
None,
|
|
180
|
+
alias="PINECALL_GATEWAY_LISTEN",
|
|
181
|
+
description="host:port a pod's gateway binds (0.0.0.0:8080); unset, the URL's loopback.",
|
|
182
|
+
)
|
|
183
|
+
trusted_proxies: str = Field(
|
|
184
|
+
"127.0.0.1",
|
|
185
|
+
alias="PINECALL_TRUSTED_PROXIES",
|
|
186
|
+
description="Addresses or networks whose X-Forwarded-For the gateway believes.",
|
|
187
|
+
)
|
|
188
|
+
metrics_from: str = Field(
|
|
189
|
+
"127.0.0.1,::1",
|
|
190
|
+
alias="PINECALL_METRICS_FROM",
|
|
191
|
+
description="Addresses or networks /metrics answers; never a forwarded request.",
|
|
192
|
+
)
|
|
193
|
+
agent: str | None = Field(
|
|
194
|
+
None, alias="PINECALL_AGENT", description="The agent a job that names none is for."
|
|
195
|
+
)
|
|
196
|
+
max_jobs: int | None = Field(
|
|
197
|
+
None,
|
|
198
|
+
alias="PINECALL_MAX_JOBS",
|
|
199
|
+
description="Calls this worker holds at once, measured on its machine. Unset: gate on CPU.",
|
|
200
|
+
)
|
|
201
|
+
worker_http_port: int = Field(
|
|
202
|
+
8082,
|
|
203
|
+
alias="PINECALL_WORKER_HTTP_PORT",
|
|
204
|
+
description="Where the worker's own health server binds, on loopback.",
|
|
205
|
+
)
|
|
206
|
+
worker_http_host: str = Field(
|
|
207
|
+
"127.0.0.1",
|
|
208
|
+
alias="PINECALL_WORKER_HTTP_HOST",
|
|
209
|
+
description="The health server's address: loopback on a box, 0.0.0.0 for a pod's probes.",
|
|
210
|
+
)
|
|
211
|
+
worker_name: str | None = Field(
|
|
212
|
+
None,
|
|
213
|
+
alias="PINECALL_WORKER_NAME",
|
|
214
|
+
description="What this worker is called in its heartbeats. Unset: the short hostname.",
|
|
215
|
+
)
|
|
216
|
+
# Per-instance LiveKit agent name, so production and sandbox on one SFU never take each
|
|
217
|
+
# other's calls. Also prefixes trunk and rule names.
|
|
218
|
+
fleet: str = Field(
|
|
219
|
+
"pinecall",
|
|
220
|
+
alias="PINECALL_FLEET",
|
|
221
|
+
pattern=A_FLEET_NAME,
|
|
222
|
+
description="The fleet this worker unit joins: the name it registers under with LiveKit.",
|
|
223
|
+
)
|
|
224
|
+
# livekit prewarms one process per CPU, wasting RAM when two instances share a machine.
|
|
225
|
+
idle_processes: int | None = Field(
|
|
226
|
+
None,
|
|
227
|
+
alias="PINECALL_IDLE_PROCESSES",
|
|
228
|
+
description="Job processes the worker keeps warm. Unset: livekit's, one per CPU.",
|
|
229
|
+
)
|
|
230
|
+
# systemd's own, set on a Type=notify unit: a release waits for the worker to say it is ready.
|
|
231
|
+
notify_socket: str | None = Field(
|
|
232
|
+
None,
|
|
233
|
+
alias="NOTIFY_SOCKET",
|
|
234
|
+
description="Where a worker tells systemd it is registered and heard. Set by systemd.",
|
|
235
|
+
)
|
|
236
|
+
overflow_says: str = Field(
|
|
237
|
+
OVERFLOW_SAYS,
|
|
238
|
+
alias="PINECALL_OVERFLOW_SAYS",
|
|
239
|
+
description="What the overflow agent says when the fleet is full, before it hangs up.",
|
|
240
|
+
)
|
|
241
|
+
# A developer's worker sets this so calls reach their own app socket.
|
|
242
|
+
app: str | None = Field(
|
|
243
|
+
None,
|
|
244
|
+
alias="PINECALL_APP",
|
|
245
|
+
description="The app socket a job's call claims. Unset, the call takes the newest holder.",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# ── the box ──
|
|
249
|
+
role: Literal["all", "hub", "worker"] = Field(
|
|
250
|
+
"all",
|
|
251
|
+
alias="PINECALL_ROLE",
|
|
252
|
+
description="What this box runs: all · hub · worker. The doctor asks after what it has.",
|
|
253
|
+
)
|
|
254
|
+
# Brute force is limited by argon2id and rate limiting, not length.
|
|
255
|
+
min_password: int = Field(
|
|
256
|
+
8,
|
|
257
|
+
alias="PINECALL_MIN_PASSWORD",
|
|
258
|
+
ge=0,
|
|
259
|
+
description="How short a member's password may be. 0 is no rule.",
|
|
260
|
+
)
|
|
261
|
+
ops_key: str | None = Field(
|
|
262
|
+
None,
|
|
263
|
+
alias="PINECALL_OPS_KEY",
|
|
264
|
+
repr=False,
|
|
265
|
+
description="The key /v1/ops/* is authenticated by. Unset, the operator API is closed.",
|
|
266
|
+
)
|
|
267
|
+
cloud: bool = Field(
|
|
268
|
+
default=False, alias="PINECALL_CLOUD", description="Pinecall's hosted gateway: billed."
|
|
269
|
+
)
|
|
270
|
+
signup: bool = Field(
|
|
271
|
+
default=False,
|
|
272
|
+
alias="PINECALL_SIGNUP",
|
|
273
|
+
description="Whether a stranger may make an org at this gateway. Off unless you say.",
|
|
274
|
+
)
|
|
275
|
+
# A number an org hooks itself proves nothing of whose it is; on, it rings nobody until the
|
|
276
|
+
# operator approves it (POST /v1/ops/numbers/{number}/approve). Off, it rings at once.
|
|
277
|
+
approve_hooked: bool = Field(
|
|
278
|
+
default=False,
|
|
279
|
+
alias="PINECALL_APPROVE_HOOKED",
|
|
280
|
+
description="Whether a number an org hooks itself waits for the operator. Off by default.",
|
|
281
|
+
)
|
|
282
|
+
billing_url: str | None = Field(
|
|
283
|
+
None,
|
|
284
|
+
alias="PINECALL_BILLING_URL",
|
|
285
|
+
description="Where this box's orgs pay, https://…; unset, the box bills nobody.",
|
|
286
|
+
)
|
|
287
|
+
# Restricts sign-up to the bot-protected site in front of it, whose X-Pinecall-Client header
|
|
288
|
+
# is then trusted as the client address.
|
|
289
|
+
signup_key: str | None = Field(
|
|
290
|
+
None,
|
|
291
|
+
alias="PINECALL_SIGNUP_KEY",
|
|
292
|
+
repr=False,
|
|
293
|
+
description="The Bearer key the sign-up doors take; unset, they take anybody.",
|
|
294
|
+
)
|
|
295
|
+
# The mobile app's two origins are always allowed. No wildcards.
|
|
296
|
+
app_origins: str = Field(
|
|
297
|
+
"",
|
|
298
|
+
alias="PINECALL_APP_ORIGINS",
|
|
299
|
+
description="Origins besides the mobile app's two that may call /v1, comma separated.",
|
|
300
|
+
)
|
|
301
|
+
# Not PINECALL_API_KEY: the CLI and the SDK read that name, and a stale export once
|
|
302
|
+
# registered agents into the wrong org.
|
|
303
|
+
worker_key: str | None = Field(
|
|
304
|
+
None,
|
|
305
|
+
alias="PINECALL_WORKER_KEY",
|
|
306
|
+
repr=False,
|
|
307
|
+
description="The org key the worker knocks its gateway with, as `keys issue` printed it.",
|
|
308
|
+
)
|
|
309
|
+
# ── the runner ──
|
|
310
|
+
runner_key: str | None = Field(
|
|
311
|
+
None,
|
|
312
|
+
alias="PINECALL_RUNNER_KEY",
|
|
313
|
+
repr=False,
|
|
314
|
+
description="The key a world's runner knocks with, as `keys runner` printed it.",
|
|
315
|
+
)
|
|
316
|
+
runner_image: str = Field(
|
|
317
|
+
"docker.io/library/node:24-slim",
|
|
318
|
+
alias="PINECALL_RUNNER_IMAGE",
|
|
319
|
+
description="The image every hosted app installs and runs in.",
|
|
320
|
+
)
|
|
321
|
+
runner_runtime_class: str = Field(
|
|
322
|
+
"gvisor",
|
|
323
|
+
alias="PINECALL_RUNNER_RUNTIME_CLASS",
|
|
324
|
+
description="The runtime class a hosted app's pod runs under: gvisor, GKE Sandbox's.",
|
|
325
|
+
)
|
|
326
|
+
runner_namespace: str = Field(
|
|
327
|
+
"pinecall-apps",
|
|
328
|
+
alias="PINECALL_RUNNER_NAMESPACE",
|
|
329
|
+
description="The namespace of the hosting cluster the runner starts every app's pod in.",
|
|
330
|
+
)
|
|
331
|
+
runner_sources_url: str = Field(
|
|
332
|
+
"http://127.0.0.1:8080",
|
|
333
|
+
alias="PINECALL_RUNNER_SOURCES_URL",
|
|
334
|
+
description="The runner as an app's pod reaches it, to fetch its release's sources.",
|
|
335
|
+
)
|
|
336
|
+
# Loopback unless told: in a pod, its chart says every address, which its Service reaches.
|
|
337
|
+
runner_listen: str = Field(
|
|
338
|
+
"127.0.0.1",
|
|
339
|
+
alias="PINECALL_RUNNER_LISTEN",
|
|
340
|
+
description="The address the runner serves the releases' sources on.",
|
|
341
|
+
)
|
|
342
|
+
runner_port: int = Field(
|
|
343
|
+
8080,
|
|
344
|
+
alias="PINECALL_RUNNER_PORT",
|
|
345
|
+
description="The port the runner serves the releases' sources on, to its world's pods.",
|
|
346
|
+
)
|
|
347
|
+
# Kept out of the database so a stolen dump does not expose tenants' secrets. To rotate: a
|
|
348
|
+
# comma-separated list, the new key first; a secret seals under the first and opens under
|
|
349
|
+
# whichever sealed it.
|
|
350
|
+
vault_key: str | None = Field(
|
|
351
|
+
None,
|
|
352
|
+
alias="PINECALL_VAULT_KEY",
|
|
353
|
+
repr=False,
|
|
354
|
+
description="The Fernet key every sealed secret is under; the gateway needs it.",
|
|
355
|
+
)
|
|
356
|
+
# The gateway's own, held by no worker and no LiveKit: a token that reads a log or asks after
|
|
357
|
+
# a caller code is signed with it, so the LiveKit pair every worker holds mints none.
|
|
358
|
+
token_key: str | None = Field(
|
|
359
|
+
None,
|
|
360
|
+
alias="PINECALL_TOKEN_KEY",
|
|
361
|
+
repr=False,
|
|
362
|
+
description="The key the gateway signs its own tokens with; the gateway needs it.",
|
|
363
|
+
)
|
|
364
|
+
smtp_url: str | None = Field(
|
|
365
|
+
None,
|
|
366
|
+
alias="PINECALL_SMTP_URL",
|
|
367
|
+
repr=False,
|
|
368
|
+
description="smtp://user:pass@host:587, or smtps://…:465: what this box posts mail with.",
|
|
369
|
+
)
|
|
370
|
+
mail_from: str | None = Field(
|
|
371
|
+
None,
|
|
372
|
+
alias="PINECALL_MAIL_FROM",
|
|
373
|
+
description='Who the box\'s letters are from: "Pinecall <no-reply@example.com>".',
|
|
374
|
+
)
|
|
375
|
+
log_level: str = Field(
|
|
376
|
+
"INFO",
|
|
377
|
+
alias="PINECALL_LOG_LEVEL",
|
|
378
|
+
description="How much both processes say: DEBUG, INFO, WARNING or ERROR.",
|
|
379
|
+
)
|
|
380
|
+
# json matches the worker's own JSON logs (livekit's JsonFormatter).
|
|
381
|
+
log_format: Literal["text", "json"] = Field(
|
|
382
|
+
"text",
|
|
383
|
+
alias="PINECALL_LOG_FORMAT",
|
|
384
|
+
description="The gateway's lines: text for a terminal, json for a journal.",
|
|
385
|
+
)
|
|
386
|
+
otlp_endpoint: str | None = Field(
|
|
387
|
+
None,
|
|
388
|
+
alias="PINECALL_OTLP_ENDPOINT",
|
|
389
|
+
description="Where the worker sends a call's traces, OTLP over HTTP. Unset: no trace.",
|
|
390
|
+
)
|
|
391
|
+
otlp_headers: str | None = Field(
|
|
392
|
+
None,
|
|
393
|
+
alias="PINECALL_OTLP_HEADERS",
|
|
394
|
+
repr=False,
|
|
395
|
+
description="Headers on every trace export, `name=value` comma-separated: a credential.",
|
|
396
|
+
)
|
|
397
|
+
otlp_pii: bool = Field(
|
|
398
|
+
default=False,
|
|
399
|
+
alias="PINECALL_OTLP_PII",
|
|
400
|
+
description="Whether a trace carries what was said and what a tool got.",
|
|
401
|
+
)
|
|
402
|
+
# Calls are dated in the caller's zone, not the box's, so "tomorrow" means the right day.
|
|
403
|
+
timezone: str = Field(
|
|
404
|
+
"UTC",
|
|
405
|
+
alias="PINECALL_TIMEZONE",
|
|
406
|
+
description="The IANA zone a call's `today` is read in (Europe/Madrid). UTC unless set.",
|
|
407
|
+
)
|
|
408
|
+
# ── memory and retrieval ──
|
|
409
|
+
# Unset budgets use the session's defaults.
|
|
410
|
+
voice_lookup_budget_ms: int | None = Field(
|
|
411
|
+
None,
|
|
412
|
+
alias="PINECALL_VOICE_LOOKUP_BUDGET_MS",
|
|
413
|
+
description="What a spoken turn waits for recall and search, in ms: silence on the line.",
|
|
414
|
+
)
|
|
415
|
+
text_lookup_budget_ms: int | None = Field(
|
|
416
|
+
None,
|
|
417
|
+
alias="PINECALL_TEXT_LOOKUP_BUDGET_MS",
|
|
418
|
+
description="What a written turn waits for recall and search, in ms.",
|
|
419
|
+
)
|
|
420
|
+
remember_budget_s: float | None = Field(
|
|
421
|
+
None,
|
|
422
|
+
alias="PINECALL_REMEMBER_BUDGET_S",
|
|
423
|
+
description="What a hang-up waits for memory to be written, in s. Past it the call seals.",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Vendor keys keep the vendor's own variable name, so the SDK that reads ANTHROPIC_API_KEY
|
|
427
|
+
# by itself and this runtime agree; the catalog asks for them by name.
|
|
428
|
+
variables: Mapping[str, str] = Field(
|
|
429
|
+
default_factory=dict,
|
|
430
|
+
repr=False,
|
|
431
|
+
description="Every variable read, for the vendor keys the catalog names.",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
@field_validator("timezone")
|
|
435
|
+
@classmethod
|
|
436
|
+
def _a_zone_that_exists(cls, zone: str) -> str:
|
|
437
|
+
try:
|
|
438
|
+
ZoneInfo(zone)
|
|
439
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
440
|
+
raise ValueError(f"{zone!r} is not an IANA time zone (Europe/Madrid, UTC)") from None
|
|
441
|
+
return zone
|
|
442
|
+
|
|
443
|
+
# Refused where the process starts, not on the first recording a call leaves.
|
|
444
|
+
@model_validator(mode="after")
|
|
445
|
+
def _a_recordings_bucket_has_its_store(self) -> Self:
|
|
446
|
+
named = {
|
|
447
|
+
"PINECALL_S3_ENDPOINT": self.s3_endpoint,
|
|
448
|
+
"PINECALL_S3_REGION": self.s3_region,
|
|
449
|
+
"PINECALL_S3_ACCESS_KEY_ID": self.s3_access_key_id,
|
|
450
|
+
"PINECALL_S3_SECRET_ACCESS_KEY": self.s3_secret_access_key,
|
|
451
|
+
}
|
|
452
|
+
missing = [name for name, value in named.items() if value is None]
|
|
453
|
+
if self.recordings_bucket is not None and missing:
|
|
454
|
+
raise ValueError(NO_STORE.format(missing=", ".join(missing)))
|
|
455
|
+
return self
|
|
456
|
+
|
|
457
|
+
def sip_name_of(self, world: Env) -> str | None:
|
|
458
|
+
"""The name a carrier sends that world's calls to: its SIP name, else the box's name."""
|
|
459
|
+
named = self.sandbox_sip_domain if world == SANDBOX else self.sip_domain
|
|
460
|
+
return named or self.sip_domain or self.domain
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def address(self) -> str | None:
|
|
464
|
+
"""The https address of the box's name, or None where it has none."""
|
|
465
|
+
return None if self.domain is None else f"https://{self.domain}"
|
|
466
|
+
|
|
467
|
+
def livekit_url_of(self, world: Env) -> str:
|
|
468
|
+
"""The LiveKit that world's rooms live on, as this process reaches it."""
|
|
469
|
+
if world == SANDBOX and self.livekit_sandbox_url:
|
|
470
|
+
return self.livekit_sandbox_url
|
|
471
|
+
return self.livekit_url
|
|
472
|
+
|
|
473
|
+
# The load balancer sends `/sandbox/rtc…` to the sandbox's own LiveKit, the prefix stripped.
|
|
474
|
+
def browser_livekit_url(self, world: Env) -> str:
|
|
475
|
+
"""The LiveKit URL a browser is told to join for a call of the world."""
|
|
476
|
+
own = self.livekit_sandbox_url if world == SANDBOX else None
|
|
477
|
+
if self.domain:
|
|
478
|
+
return f"wss://{self.domain}/sandbox" if own else f"wss://{self.domain}"
|
|
479
|
+
return own or self.livekit_public_url or self.livekit_url
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def load() -> Settings:
|
|
483
|
+
"""Build Settings from the systemd credentials, the .env files found and the environment."""
|
|
484
|
+
read: dict[str, str] = {}
|
|
485
|
+
credentials = os.environ.get("CREDENTIALS_DIRECTORY")
|
|
486
|
+
if credentials:
|
|
487
|
+
read.update(_credentials_in(Path(credentials)))
|
|
488
|
+
for file in env_files():
|
|
489
|
+
read.update(_env_file(file))
|
|
490
|
+
read.update(os.environ)
|
|
491
|
+
# A bare `NAME=` means unset, so a copied example does not hand a vendor an empty value.
|
|
492
|
+
variables = {name: value for name, value in read.items() if value != ""}
|
|
493
|
+
try:
|
|
494
|
+
return Settings.model_validate({**variables, "variables": variables})
|
|
495
|
+
except ValidationError as refused:
|
|
496
|
+
raise SettingsRefused(str(refused)) from refused
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def env_files() -> list[Path]:
|
|
500
|
+
"""Return the .env files of the first directory up to the repository root that has any."""
|
|
501
|
+
for folder in _up_to_the_repository_root(Path.cwd()):
|
|
502
|
+
found = [folder / name for name in ENV_FILES if (folder / name).is_file()]
|
|
503
|
+
if found:
|
|
504
|
+
return found
|
|
505
|
+
return []
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# Bounded at the repository root: a stray .env above the project would silently load wrong keys.
|
|
509
|
+
def _up_to_the_repository_root(start: Path) -> Iterator[Path]:
|
|
510
|
+
folder = start.resolve()
|
|
511
|
+
for candidate in (folder, *folder.parents):
|
|
512
|
+
yield candidate
|
|
513
|
+
if (candidate / ".git").exists():
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# On a box, secrets arrive as systemd credentials: one file per variable name.
|
|
518
|
+
def _credentials_in(directory: Path) -> dict[str, str]:
|
|
519
|
+
return {
|
|
520
|
+
file.name: file.read_text(encoding="utf-8").strip()
|
|
521
|
+
for file in sorted(directory.iterdir())
|
|
522
|
+
if file.is_file()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# An unreadable .env fails loudly: it may hold the key that selects the right database.
|
|
527
|
+
def _env_file(path: Path) -> dict[str, str]:
|
|
528
|
+
try:
|
|
529
|
+
values = dotenv_values(path, encoding="utf-8")
|
|
530
|
+
except OSError as failed:
|
|
531
|
+
raise SettingsRefused(
|
|
532
|
+
UNREADABLE_ENV.format(file=path, why=failed.strerror or failed)
|
|
533
|
+
) from failed
|
|
534
|
+
return {name: value for name, value in values.items() if value is not None}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""State each gateway holds a share of, told to the others on the signal and merged by each."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, TypeAdapter
|
|
11
|
+
|
|
12
|
+
from pinecall.domain.errors import NotAvailable
|
|
13
|
+
from pinecall.postgres.pool import box_task
|
|
14
|
+
from pinecall.process.signal import Signal
|
|
15
|
+
|
|
16
|
+
# Each process says its whole share this often, and forgets another's share once it has been
|
|
17
|
+
# silent this long: a gateway that died takes its share with it within half a minute.
|
|
18
|
+
TOLD_EVERY_S = 10.0
|
|
19
|
+
SILENT_AT_MOST_S = 30.0
|
|
20
|
+
|
|
21
|
+
# A share said changes nothing past the next beat: a lost message is repaired by it.
|
|
22
|
+
RETRY_S = 1.0
|
|
23
|
+
|
|
24
|
+
# A key set to nobody is kept this long, so an older setting said by another process does not
|
|
25
|
+
# come back while that process still says it.
|
|
26
|
+
FORGOTTEN_AFTER_S = 60.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Said(BaseModel):
|
|
30
|
+
"""One message on a share's channel: which process said it, and its share whole."""
|
|
31
|
+
|
|
32
|
+
sender: str
|
|
33
|
+
share: object
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Assigned:
|
|
38
|
+
"""A key of a world as one process last set it: to a holder, or to nobody, and when."""
|
|
39
|
+
|
|
40
|
+
env: str
|
|
41
|
+
key: str
|
|
42
|
+
holder: str | None
|
|
43
|
+
at: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Heard[T]:
|
|
48
|
+
"""Another process's share, and when it last said it."""
|
|
49
|
+
|
|
50
|
+
share: T
|
|
51
|
+
at: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Lossy by design, as the signal is: every share is said whole, again and again, so a message lost
|
|
55
|
+
# or a Redis that restarted costs at most one beat. What a share holds must be small (sockets,
|
|
56
|
+
# lines, phones): it travels whole every TOLD_EVERY_S.
|
|
57
|
+
class Shared[T]:
|
|
58
|
+
"""One share of this process, the others' as last heard, and a callback when either moves."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
signal: Signal,
|
|
63
|
+
channel: str,
|
|
64
|
+
adapter: TypeAdapter[T],
|
|
65
|
+
empty: T,
|
|
66
|
+
changed: Callable[[], None],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Nothing heard yet; `changed` is called whenever the merged view must be rebuilt."""
|
|
69
|
+
self.signal = signal
|
|
70
|
+
# How often the share is said whole: TOLD_EVERY_S unless its owner says sooner.
|
|
71
|
+
self.every_s = TOLD_EVERY_S
|
|
72
|
+
# Set by an owner whose share changes too often to put: read as it is said.
|
|
73
|
+
self.gathered: Callable[[], T] | None = None
|
|
74
|
+
self.channel = channel
|
|
75
|
+
self.id = uuid4().hex
|
|
76
|
+
self.mine = empty
|
|
77
|
+
self.theirs: dict[str, Heard[T]] = {}
|
|
78
|
+
self._adapter = adapter
|
|
79
|
+
self._changed = changed
|
|
80
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
81
|
+
|
|
82
|
+
def put(self, share: T) -> None:
|
|
83
|
+
"""This process's share changed: keep it, and say it at once."""
|
|
84
|
+
self.mine = share
|
|
85
|
+
self._say()
|
|
86
|
+
|
|
87
|
+
async def start(self) -> None:
|
|
88
|
+
"""Listen to the others and say this share every beat."""
|
|
89
|
+
self._tasks = [box_task(self._listened()), box_task(self._beating())]
|
|
90
|
+
|
|
91
|
+
async def close(self) -> None:
|
|
92
|
+
"""Stop listening and saying."""
|
|
93
|
+
for task in self._tasks:
|
|
94
|
+
task.cancel()
|
|
95
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
96
|
+
|
|
97
|
+
def _say(self) -> None:
|
|
98
|
+
mine = self.mine if self.gathered is None else self.gathered()
|
|
99
|
+
message = Said(sender=self.id, share=self._adapter.dump_python(mine, mode="json"))
|
|
100
|
+
self.signal.publish(self.channel, message.model_dump_json().encode())
|
|
101
|
+
|
|
102
|
+
async def _beating(self) -> None:
|
|
103
|
+
while True:
|
|
104
|
+
self._say()
|
|
105
|
+
await asyncio.sleep(self.every_s)
|
|
106
|
+
now = time.monotonic()
|
|
107
|
+
silent = [gw for gw, heard in self.theirs.items() if now - heard.at > SILENT_AT_MOST_S]
|
|
108
|
+
for gateway in silent:
|
|
109
|
+
del self.theirs[gateway]
|
|
110
|
+
if silent:
|
|
111
|
+
self._changed()
|
|
112
|
+
|
|
113
|
+
async def _listened(self) -> None:
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
listening = await self.signal.subscribe(self.channel)
|
|
117
|
+
except NotAvailable:
|
|
118
|
+
await asyncio.sleep(RETRY_S)
|
|
119
|
+
continue
|
|
120
|
+
# Said again once subscribed: a process that just started is told everyone's share.
|
|
121
|
+
self._say()
|
|
122
|
+
try:
|
|
123
|
+
async for data in listening:
|
|
124
|
+
self._heard(data)
|
|
125
|
+
finally:
|
|
126
|
+
listening.close()
|
|
127
|
+
with contextlib.suppress(TimeoutError):
|
|
128
|
+
await asyncio.wait_for(self.signal.connected(), RETRY_S)
|
|
129
|
+
|
|
130
|
+
def _heard(self, data: bytes) -> None:
|
|
131
|
+
message = Said.model_validate_json(data)
|
|
132
|
+
if message.sender == self.id:
|
|
133
|
+
return
|
|
134
|
+
new = message.sender not in self.theirs
|
|
135
|
+
share = self._adapter.validate_python(message.share)
|
|
136
|
+
self.theirs[message.sender] = Heard(share, time.monotonic())
|
|
137
|
+
self._changed()
|
|
138
|
+
if new:
|
|
139
|
+
self._say()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# The newest setting of each key stands, whichever process said it. A setting of ours another
|
|
143
|
+
# overruled is dropped, and one to nobody is kept only a while: what is said every beat stays the
|
|
144
|
+
# size of what is set.
|
|
145
|
+
def newest(
|
|
146
|
+
mine: dict[tuple[str, str], Assigned], theirs: Iterable[Assigned]
|
|
147
|
+
) -> dict[tuple[str, str], str]:
|
|
148
|
+
"""Each key's holder by its newest setting, ours and theirs; ours pruned of what is spent."""
|
|
149
|
+
latest = dict(mine)
|
|
150
|
+
for assigned in theirs:
|
|
151
|
+
key = (assigned.env, assigned.key)
|
|
152
|
+
if key not in latest or latest[key].at < assigned.at:
|
|
153
|
+
latest[key] = assigned
|
|
154
|
+
now = time.time()
|
|
155
|
+
for key, kept in list(mine.items()):
|
|
156
|
+
if latest[key] is not kept or (kept.holder is None and now - kept.at > FORGOTTEN_AFTER_S):
|
|
157
|
+
del mine[key]
|
|
158
|
+
return {key: setting.holder for key, setting in latest.items() if setting.holder is not None}
|