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,65 @@
|
|
|
1
|
+
"""An org's policy: retention, outbound calling rules, what a call says first; one row."""
|
|
2
|
+
|
|
3
|
+
from pinecall.postgres.pool import Pool
|
|
4
|
+
from pinecall.wire.rest.accounts import CallingHours, OrgPolicy, OrgPolicyRow
|
|
5
|
+
|
|
6
|
+
POLICY = """
|
|
7
|
+
SELECT retention_days, calling_from, calling_until, per_number_day, consent_everywhere,
|
|
8
|
+
disclosure, recording_notice, set_by, set_at
|
|
9
|
+
FROM org_policy WHERE org = %(org)s
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
PUT_POLICY = """
|
|
13
|
+
INSERT INTO org_policy (org, retention_days, calling_from, calling_until, per_number_day,
|
|
14
|
+
consent_everywhere, disclosure, recording_notice, set_by)
|
|
15
|
+
VALUES (%(org)s, %(retention_days)s, %(calling_from)s, %(calling_until)s, %(per_number_day)s,
|
|
16
|
+
%(consent_everywhere)s, %(disclosure)s, %(recording_notice)s, %(set_by)s)
|
|
17
|
+
ON CONFLICT (org) DO UPDATE SET retention_days = excluded.retention_days,
|
|
18
|
+
calling_from = excluded.calling_from, calling_until = excluded.calling_until,
|
|
19
|
+
per_number_day = excluded.per_number_day, consent_everywhere = excluded.consent_everywhere,
|
|
20
|
+
disclosure = excluded.disclosure, recording_notice = excluded.recording_notice,
|
|
21
|
+
set_by = excluded.set_by, set_at = now()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def policy_of(pool: Pool, org: str) -> OrgPolicyRow:
|
|
26
|
+
"""The org's policy, and who set it last; an org nobody set has the platform's defaults."""
|
|
27
|
+
async with pool.connection() as connection:
|
|
28
|
+
row = await (await connection.execute(POLICY, {"org": org})).fetchone()
|
|
29
|
+
if row is None:
|
|
30
|
+
return OrgPolicyRow(policy=OrgPolicy(), set_by=None, set_at=None)
|
|
31
|
+
hours = None
|
|
32
|
+
if row["calling_from"] is not None:
|
|
33
|
+
hours = CallingHours.model_validate(
|
|
34
|
+
{"from": row["calling_from"], "until": row["calling_until"]}
|
|
35
|
+
)
|
|
36
|
+
return OrgPolicyRow(
|
|
37
|
+
policy=OrgPolicy(
|
|
38
|
+
retention_days=row["retention_days"],
|
|
39
|
+
calling_hours=hours,
|
|
40
|
+
per_number_day=row["per_number_day"],
|
|
41
|
+
consent_everywhere=row["consent_everywhere"],
|
|
42
|
+
disclosure=row["disclosure"],
|
|
43
|
+
recording_notice=row["recording_notice"],
|
|
44
|
+
),
|
|
45
|
+
set_by=str(row["set_by"]),
|
|
46
|
+
set_at=row["set_at"].timestamp(),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def put_policy(pool: Pool, org: str, policy: OrgPolicy, *, by: str) -> None:
|
|
51
|
+
"""Replace the org's policy whole."""
|
|
52
|
+
hours = policy.calling_hours
|
|
53
|
+
params = {
|
|
54
|
+
"org": org,
|
|
55
|
+
"retention_days": policy.retention_days,
|
|
56
|
+
"calling_from": None if hours is None else hours.from_,
|
|
57
|
+
"calling_until": None if hours is None else hours.until,
|
|
58
|
+
"per_number_day": policy.per_number_day,
|
|
59
|
+
"consent_everywhere": policy.consent_everywhere,
|
|
60
|
+
"disclosure": policy.disclosure,
|
|
61
|
+
"recording_notice": policy.recording_notice,
|
|
62
|
+
"set_by": by,
|
|
63
|
+
}
|
|
64
|
+
async with pool.connection() as connection:
|
|
65
|
+
await connection.execute(PUT_POLICY, params)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Each distinct block of prompt an org's calls were told, kept once under its hash, read back."""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from collections.abc import Collection
|
|
5
|
+
|
|
6
|
+
from pinecall.domain.agent import block_hash
|
|
7
|
+
from pinecall.postgres.pool import Pool
|
|
8
|
+
|
|
9
|
+
# The texts this process wrote, remembered so a block is written once and not per call; past it
|
|
10
|
+
# the oldest is forgotten and, met again, costs one insert that finds its row.
|
|
11
|
+
REMEMBERED = 10_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
KEPT = """
|
|
15
|
+
INSERT INTO prompts (org, hash, text) VALUES (%(org)s, %(hash)s, %(text)s)
|
|
16
|
+
ON CONFLICT (org, hash) DO NOTHING
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
TEXTS = "SELECT hash, text FROM prompts WHERE org = %(org)s AND hash = ANY(%(hashes)s)"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Not durable on purpose: the table is the record, this spares it a round trip.
|
|
24
|
+
class Prompts:
|
|
25
|
+
"""The blocks this gateway process has kept already, by org and hash."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
"""Nothing kept yet."""
|
|
29
|
+
self.kept: OrderedDict[tuple[str, str], None] = OrderedDict()
|
|
30
|
+
|
|
31
|
+
async def keep(self, pool: Pool, org: str, text: str) -> None:
|
|
32
|
+
"""Keep the text under its hash for the org, once; an empty block is nothing to keep."""
|
|
33
|
+
key = (org, block_hash(text))
|
|
34
|
+
if not text or key in self.kept:
|
|
35
|
+
return
|
|
36
|
+
async with pool.connection() as connection:
|
|
37
|
+
await connection.execute(KEPT, {"org": org, "hash": key[1], "text": text})
|
|
38
|
+
self.kept[key] = None
|
|
39
|
+
if len(self.kept) > REMEMBERED:
|
|
40
|
+
self.kept.popitem(last=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def texts_of(pool: Pool, org: str, hashes: Collection[str]) -> dict[str, str]:
|
|
44
|
+
"""The org's texts of these hashes, those it kept."""
|
|
45
|
+
async with pool.connection() as connection:
|
|
46
|
+
found = await connection.execute(TEXTS, {"org": org, "hashes": list(hashes)})
|
|
47
|
+
rows = await found.fetchall()
|
|
48
|
+
return {str(row["hash"]): str(row["text"]) for row in rows}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Who read what: a key's or the operator's read of a call, a number or memory, once an hour."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.scope import Scope
|
|
9
|
+
from pinecall.postgres.pool import Pool
|
|
10
|
+
from pinecall.wire.rest.calls import ReadKind, ReadRow
|
|
11
|
+
|
|
12
|
+
# The same reader reading the same thing again within the hour is the same read; an org erased
|
|
13
|
+
# has no access log left to write to (a traceback still finds its calls' records).
|
|
14
|
+
RECORD = """
|
|
15
|
+
INSERT INTO reads (org, env, subject, what, reader)
|
|
16
|
+
SELECT %(org)s, %(env)s, %(subject)s, %(what)s, %(reader)s
|
|
17
|
+
WHERE EXISTS (SELECT 1 FROM orgs WHERE id = %(org)s) AND NOT EXISTS (
|
|
18
|
+
SELECT 1 FROM reads
|
|
19
|
+
WHERE org = %(org)s AND subject = %(subject)s AND what = %(what)s AND reader = %(reader)s
|
|
20
|
+
AND env IS NOT DISTINCT FROM %(env)s AND at > now() - interval '1 hour'
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
OF_ORG = """
|
|
25
|
+
SELECT subject, what, env, reader, at FROM reads
|
|
26
|
+
WHERE org = %(org)s AND (%(subject)s::text IS NULL OR subject = %(subject)s)
|
|
27
|
+
ORDER BY at DESC, id DESC
|
|
28
|
+
LIMIT %(limit)s
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Who reads, or erases, on the box's own behalf: off the box, or through its own doors.
|
|
32
|
+
OPERATOR = "operator"
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
A_PAGE = 200
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Read:
|
|
41
|
+
"""One read: the call or the number, what of it, and who."""
|
|
42
|
+
|
|
43
|
+
subject: str
|
|
44
|
+
what: ReadKind
|
|
45
|
+
reader: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# One statement, awaited: a read waits on its record and on nothing more. A record that cannot be
|
|
49
|
+
# written (the database away, the pool spent) is said in the log and the read goes on: an audit
|
|
50
|
+
# that fails a read would be a way to stop every read.
|
|
51
|
+
async def record(pool: Pool, scope: Scope, read: Read) -> None:
|
|
52
|
+
"""Write the read down, unless the same reader read the same thing within the hour."""
|
|
53
|
+
params = {"org": scope.org, "env": scope.env, **vars(read)}
|
|
54
|
+
try:
|
|
55
|
+
async with pool.connection() as connection:
|
|
56
|
+
await connection.execute(RECORD, params)
|
|
57
|
+
except psycopg.Error:
|
|
58
|
+
logger.warning(
|
|
59
|
+
"the read of %s by %s went unrecorded; the read goes on",
|
|
60
|
+
read.subject,
|
|
61
|
+
read.reader,
|
|
62
|
+
exc_info=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def of_org(
|
|
67
|
+
pool: Pool, org: str, *, subject: str | None = None, limit: int = A_PAGE
|
|
68
|
+
) -> list[ReadRow]:
|
|
69
|
+
"""The org's reads, newest first, of one call or number when named."""
|
|
70
|
+
params = {"org": org, "subject": subject, "limit": limit}
|
|
71
|
+
async with pool.connection() as connection:
|
|
72
|
+
rows = await (await connection.execute(OF_ORG, params)).fetchall()
|
|
73
|
+
return [ReadRow.model_validate({**row, "at": row["at"].timestamp()}) for row in rows]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""A recording's own key: made once per call, kept sealed under the vault, erased with the call."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
from cryptography.fernet import MultiFernet
|
|
6
|
+
|
|
7
|
+
from pinecall.postgres.pool import Pool
|
|
8
|
+
from pinecall.process.sealed_audio import new_key
|
|
9
|
+
from pinecall.tenancy.vault import opened, sealed
|
|
10
|
+
|
|
11
|
+
# The insert is the mint: of two asks at once, or a retry, the first key stands and is the answer.
|
|
12
|
+
# Asked again, the same key; asked for another org's call, nothing (the update's WHERE fails).
|
|
13
|
+
MINT = """
|
|
14
|
+
INSERT INTO recording_keys (call, org, sealed) VALUES (%(call)s, %(org)s, %(sealed)s)
|
|
15
|
+
ON CONFLICT (call) DO UPDATE SET call = excluded.call WHERE recording_keys.org = excluded.org
|
|
16
|
+
RETURNING sealed
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
KEY = "SELECT sealed FROM recording_keys WHERE call = %(call)s"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def key_for(pool: Pool, vault: MultiFernet, org: str, call: str) -> bytes | None:
|
|
23
|
+
"""The call's recording key, made now or before; None for another org's, or one unopened."""
|
|
24
|
+
fresh = sealed(vault, base64.urlsafe_b64encode(new_key()).decode())
|
|
25
|
+
async with pool.connection() as connection:
|
|
26
|
+
row = await (
|
|
27
|
+
await connection.execute(MINT, {"call": call, "org": org, "sealed": fresh})
|
|
28
|
+
).fetchone()
|
|
29
|
+
return None if row is None else _opened(vault, str(row["sealed"]))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def key_of(pool: Pool, vault: MultiFernet, call: str) -> bytes | None:
|
|
33
|
+
"""The call's recording key; None when it has none, or the vault no longer opens it."""
|
|
34
|
+
async with pool.connection() as connection:
|
|
35
|
+
row = await (await connection.execute(KEY, {"call": call})).fetchone()
|
|
36
|
+
return None if row is None else _opened(vault, str(row["sealed"]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _opened(vault: MultiFernet, token: str) -> bytes | None:
|
|
40
|
+
secret = opened(vault, token)
|
|
41
|
+
return base64.urlsafe_b64decode(secret) if isinstance(secret, str) else None
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""A verified key remembered on this gateway for seconds, forgotten once it or its person change."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from pinecall.domain.errors import NotAvailable
|
|
14
|
+
from pinecall.postgres.pool import Pool, box_task
|
|
15
|
+
from pinecall.process.signal import Signal
|
|
16
|
+
from pinecall.tenancy import keys
|
|
17
|
+
from pinecall.tenancy.keys import Bearer
|
|
18
|
+
from pinecall.tenancy.people import fingerprint
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Verifying a key is a round trip per request, 15 % of a gateway's core under load (measured
|
|
24
|
+
# 2026-09-30). A key is remembered this long; a revocation or a change to its person is said on the
|
|
25
|
+
# signal and forgets it everywhere at once, so the seconds are only what a gateway that cannot hear
|
|
26
|
+
# the signal keeps, and what a change made outside the doors (a shell, psql) waits.
|
|
27
|
+
REMEMBERED_FOR_S = 5.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# What every gateway hears when a key is revoked, or a person's standing changes (a role, an agent
|
|
31
|
+
# list, production, operator, disabled or removed): what it remembered of them no longer holds.
|
|
32
|
+
CHANNEL = "keys:forgotten"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
RETRY_S = 1.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Forgotten(BaseModel):
|
|
39
|
+
"""What to forget: one key by its fingerprint, or every key of a person."""
|
|
40
|
+
|
|
41
|
+
fingerprint: str | None = None
|
|
42
|
+
subject: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Remembered:
|
|
47
|
+
"""A verified key, and the clock reading it is forgotten at."""
|
|
48
|
+
|
|
49
|
+
bearer: Bearer
|
|
50
|
+
until: float
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RememberedKeys:
|
|
54
|
+
"""The keys this gateway verified lately, by fingerprint, and the revocations it hears."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
pool: Pool,
|
|
59
|
+
signal: Signal,
|
|
60
|
+
*,
|
|
61
|
+
for_s: float = REMEMBERED_FOR_S,
|
|
62
|
+
clock: Callable[[], float] = time.monotonic,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Nothing remembered yet; `start` listens for revocations."""
|
|
65
|
+
self.pool = pool
|
|
66
|
+
self.signal = signal
|
|
67
|
+
self.for_s = for_s
|
|
68
|
+
self.clock = clock
|
|
69
|
+
self.kept: dict[str, Remembered] = {}
|
|
70
|
+
self.listening: asyncio.Task[None] | None = None
|
|
71
|
+
self.swept_at = clock()
|
|
72
|
+
|
|
73
|
+
async def start(self) -> None:
|
|
74
|
+
"""Listen for revocations said by any gateway."""
|
|
75
|
+
if self.listening is None:
|
|
76
|
+
self.listening = box_task(self._listened())
|
|
77
|
+
|
|
78
|
+
async def close(self) -> None:
|
|
79
|
+
"""Stop listening and forget everything."""
|
|
80
|
+
if self.listening is not None:
|
|
81
|
+
self.listening.cancel()
|
|
82
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
83
|
+
await self.listening
|
|
84
|
+
self.listening = None
|
|
85
|
+
self.kept.clear()
|
|
86
|
+
|
|
87
|
+
async def verify(self, bearer: str) -> Bearer | None:
|
|
88
|
+
"""The key and its person, from memory within the seconds, else as keys.verify answers."""
|
|
89
|
+
hashed = fingerprint(bearer)
|
|
90
|
+
now = self.clock()
|
|
91
|
+
remembered = self.kept.get(hashed)
|
|
92
|
+
if remembered is not None and remembered.until > now:
|
|
93
|
+
return remembered.bearer
|
|
94
|
+
verified = await keys.verify(self.pool, bearer)
|
|
95
|
+
if verified is None:
|
|
96
|
+
self.kept.pop(hashed, None)
|
|
97
|
+
return None
|
|
98
|
+
until = now + self.for_s
|
|
99
|
+
if verified.key.expires_at is not None:
|
|
100
|
+
left = (verified.key.expires_at - datetime.now(UTC)).total_seconds()
|
|
101
|
+
until = min(until, now + max(left, 0.0))
|
|
102
|
+
self.kept[hashed] = Remembered(verified, until)
|
|
103
|
+
self._swept(now)
|
|
104
|
+
return verified
|
|
105
|
+
|
|
106
|
+
# Forgotten here before the door answers, and said to every other gateway: a signal that is
|
|
107
|
+
# away queues it, so the other gateways keep the key for the seconds and no longer.
|
|
108
|
+
def forget(self, *, fingerprint: str | None = None, subject: str | None = None) -> None:
|
|
109
|
+
"""Forget a key, or a person's keys, on every gateway."""
|
|
110
|
+
forgotten = Forgotten(fingerprint=fingerprint, subject=subject)
|
|
111
|
+
self._forgotten(forgotten)
|
|
112
|
+
self.signal.publish(CHANNEL, forgotten.model_dump_json().encode())
|
|
113
|
+
|
|
114
|
+
def _forgotten(self, forgotten: Forgotten) -> None:
|
|
115
|
+
if forgotten.fingerprint is not None:
|
|
116
|
+
self.kept.pop(forgotten.fingerprint, None)
|
|
117
|
+
if forgotten.subject is not None:
|
|
118
|
+
for hashed in [h for h, kept in self.kept.items() if _of(kept, forgotten.subject)]:
|
|
119
|
+
del self.kept[hashed]
|
|
120
|
+
|
|
121
|
+
# Keys verified once and never again would stay: swept each period.
|
|
122
|
+
def _swept(self, now: float) -> None:
|
|
123
|
+
if now - self.swept_at < self.for_s:
|
|
124
|
+
return
|
|
125
|
+
self.swept_at = now
|
|
126
|
+
for hashed in [h for h, kept in self.kept.items() if kept.until <= now]:
|
|
127
|
+
del self.kept[hashed]
|
|
128
|
+
|
|
129
|
+
async def _listened(self) -> None:
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
listening = await self.signal.subscribe(CHANNEL)
|
|
133
|
+
except NotAvailable:
|
|
134
|
+
await asyncio.sleep(RETRY_S)
|
|
135
|
+
continue
|
|
136
|
+
# What was revoked while the signal was away is unknown: nothing is trusted from before.
|
|
137
|
+
self.kept.clear()
|
|
138
|
+
try:
|
|
139
|
+
async for data in listening:
|
|
140
|
+
self._forgotten(Forgotten.model_validate_json(data))
|
|
141
|
+
finally:
|
|
142
|
+
listening.close()
|
|
143
|
+
with contextlib.suppress(TimeoutError):
|
|
144
|
+
await asyncio.wait_for(self.signal.connected(), RETRY_S)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _of(kept: Remembered, subject: str) -> bool:
|
|
148
|
+
return kept.bearer.key.subject == subject
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Retention: calls past their org's days (policy.py); call records and dials past 24 months."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.names import parse_env
|
|
6
|
+
from pinecall.domain.scope import Scope
|
|
7
|
+
from pinecall.postgres.pool import Pool, unbounded
|
|
8
|
+
from pinecall.process.recordings import Recordings
|
|
9
|
+
from pinecall.tenancy import erasure
|
|
10
|
+
|
|
11
|
+
# Every statement of the nightly run is unbounded: it reads and deletes across every org's years.
|
|
12
|
+
# Sealed calls only: a call still running is the caller's, not the calendar's. Oldest first, so a
|
|
13
|
+
# run cut short by its limit takes up where it stopped.
|
|
14
|
+
DUE = """
|
|
15
|
+
SELECT head.call, head.org, head.env, head.holder
|
|
16
|
+
FROM call_log_head head JOIN org_policy policy ON policy.org = head.org
|
|
17
|
+
WHERE head.call IS NOT NULL AND head.sealed AND head.started_at IS NOT NULL
|
|
18
|
+
AND policy.retention_days IS NOT NULL
|
|
19
|
+
AND head.started_at < %(now)s - policy.retention_days * 86400
|
|
20
|
+
ORDER BY head.started_at
|
|
21
|
+
LIMIT %(limit)s
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# An erased phone call's record (erasure.py) and every dial (dial_policy.py) are kept for a
|
|
25
|
+
# carrier's traceback this long, then forgotten. A record of a call that never started goes by
|
|
26
|
+
# when it ended, or when it was erased.
|
|
27
|
+
RECORDS_KEPT_S = 730 * 86400
|
|
28
|
+
|
|
29
|
+
FORGET_RECORDS = """
|
|
30
|
+
DELETE FROM call_records
|
|
31
|
+
WHERE coalesce(started_at, ended_at, extract(epoch FROM erased_at)) < %(before)s
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
FORGET_DIALS = "DELETE FROM dials WHERE at < to_timestamp(%(before)s)"
|
|
35
|
+
|
|
36
|
+
# Who asked, in the erasure trail, for what the calendar erased.
|
|
37
|
+
RETENTION = "retention"
|
|
38
|
+
|
|
39
|
+
# One night's work at most; what is left over is the next night's.
|
|
40
|
+
A_RUN_ERASES = 5000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Due:
|
|
45
|
+
"""A sealed call past its org's days: whose it is, and which."""
|
|
46
|
+
|
|
47
|
+
scope: Scope
|
|
48
|
+
call: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def due(pool: Pool, now: float, *, limit: int = A_RUN_ERASES) -> list[Due]:
|
|
52
|
+
"""The sealed calls whose org keeps fewer days than they have, oldest first."""
|
|
53
|
+
async with unbounded(pool) as connection:
|
|
54
|
+
rows = await (await connection.execute(DUE, {"now": now, "limit": limit})).fetchall()
|
|
55
|
+
return [
|
|
56
|
+
Due(
|
|
57
|
+
Scope(str(row["org"]), parse_env(str(row["env"])), str(row["holder"] or "")),
|
|
58
|
+
str(row["call"]),
|
|
59
|
+
)
|
|
60
|
+
for row in rows
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def purge(
|
|
65
|
+
pool: Pool, recordings: Recordings, now: float, *, limit: int = A_RUN_ERASES
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
"""Erase every call past its org's days, one erasure each; the calls erased."""
|
|
68
|
+
erased: list[str] = []
|
|
69
|
+
for call in await due(pool, now, limit=limit):
|
|
70
|
+
await erasure.call(pool, recordings, call.scope, call.call, by=RETENTION)
|
|
71
|
+
erased.append(call.call)
|
|
72
|
+
return erased
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def forget_records(pool: Pool, now: float) -> int:
|
|
76
|
+
"""Forget the records of erased calls more than 24 months old; how many."""
|
|
77
|
+
async with unbounded(pool) as connection:
|
|
78
|
+
done = await connection.execute(FORGET_RECORDS, {"before": now - RECORDS_KEPT_S})
|
|
79
|
+
return done.rowcount
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def forget_dials(pool: Pool, now: float) -> int:
|
|
83
|
+
"""Forget the dials placed or refused more than 24 months ago; how many."""
|
|
84
|
+
async with unbounded(pool) as connection:
|
|
85
|
+
done = await connection.execute(FORGET_DIALS, {"before": now - RECORDS_KEPT_S})
|
|
86
|
+
return done.rowcount
|