pinecall-runtime 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/log/refold.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The facts folded again from the log: rebuilt a call at a time, and a sample checked by doctor."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, fields
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from pinecall.domain.call import A_CALL
|
|
7
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
8
|
+
from pinecall.log import facts
|
|
9
|
+
from pinecall.log.facts import CallFacts
|
|
10
|
+
from pinecall.log.store import entry_of
|
|
11
|
+
from pinecall.postgres.pool import Connection, Pool
|
|
12
|
+
from pinecall.wire.events import EVENTS, event_of
|
|
13
|
+
from pinecall.wire.frames import Entry
|
|
14
|
+
|
|
15
|
+
# Calls named per read of the heads; each is refolded in a transaction of its own, so a rebuild
|
|
16
|
+
# never holds a lock longer than one call's fold.
|
|
17
|
+
A_PAGE = 500
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# What doctor refolds and compares, and how many of the heads behind their rows it names.
|
|
21
|
+
A_SAMPLE = 20
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
HEADS_EXAMINED = 20
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CALLS_PAGE = """
|
|
28
|
+
select log from call_log_head
|
|
29
|
+
where call is not null and log > %(after)s
|
|
30
|
+
and (%(call)s::text is null or log = %(call)s)
|
|
31
|
+
and (%(org)s::text is null or org = %(org)s)
|
|
32
|
+
and (%(since)s::float8 is null or started_at >= %(since)s)
|
|
33
|
+
order by log
|
|
34
|
+
limit %(limit)s
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# The append's own lock: an append waits for the refold, and the refold reads what it wrote.
|
|
39
|
+
HEAD_LOCKED = "select log from call_log_head where log = %(call)s for update"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Snapshot reads: an append writes its entries and its facts in one transaction, so one snapshot
|
|
43
|
+
# sees both or neither.
|
|
44
|
+
SNAPSHOT = "set transaction isolation level repeatable read, read only"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ENTRIES = """
|
|
48
|
+
select call, seq, ts, agent, type, ephemeral, data
|
|
49
|
+
from call_log where log = %(call)s order by seq
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
FACTS = "select * from call_facts where call = %(call)s"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# The heads doctor examines: the newest calls by the log's own order (the index of an entry's
|
|
57
|
+
# type and position finds the last call.started without reading the rest), and as many from a
|
|
58
|
+
# random point of the heads' key, round to the start. Each is one probe of the log's primary key:
|
|
59
|
+
# a head is never behind a row it numbered. Every head would be a probe per call on the box.
|
|
60
|
+
HEADS_BEHIND = """
|
|
61
|
+
with newest as (
|
|
62
|
+
select log from call_log where type = 'call.started' order by position desc limit %(limit)s
|
|
63
|
+
), sampled as (
|
|
64
|
+
(select log from call_log_head where log >= %(start)s order by log limit %(limit)s)
|
|
65
|
+
union all
|
|
66
|
+
(select log from call_log_head where log < %(start)s order by log limit %(limit)s)
|
|
67
|
+
limit %(limit)s
|
|
68
|
+
), examined as (select log from newest union select log from sampled)
|
|
69
|
+
select examined.log,
|
|
70
|
+
head.seq < (select max(entry.seq) from call_log entry where entry.log = examined.log)
|
|
71
|
+
as behind
|
|
72
|
+
from examined join call_log_head head on head.log = examined.log
|
|
73
|
+
order by examined.log
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# From a random point of the call ids, round to the start: a sample the primary key finds.
|
|
78
|
+
SAMPLE = """
|
|
79
|
+
(select log from call_log_head where call is not null and sealed and log >= %(start)s
|
|
80
|
+
order by log limit %(limit)s)
|
|
81
|
+
union all
|
|
82
|
+
(select log from call_log_head where call is not null and sealed and log < %(start)s
|
|
83
|
+
order by log limit %(limit)s)
|
|
84
|
+
limit %(limit)s
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Refolding:
|
|
90
|
+
"""Which calls a rebuild refolds: one, an org's, those started since; every call when none."""
|
|
91
|
+
|
|
92
|
+
call: str | None = None
|
|
93
|
+
org: str | None = None
|
|
94
|
+
since: float | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Rebuilt:
|
|
99
|
+
"""What a rebuild read, what it rewrote, and what it left: logs this release cannot read."""
|
|
100
|
+
|
|
101
|
+
calls: int
|
|
102
|
+
rewritten: int
|
|
103
|
+
left: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Heads:
|
|
108
|
+
"""The heads doctor examined, and those among them whose rows are past their head's seq."""
|
|
109
|
+
|
|
110
|
+
examined: int
|
|
111
|
+
behind: tuple[str, ...]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Differs:
|
|
116
|
+
"""A call whose stored facts are not what its log folds to, and the columns that differ."""
|
|
117
|
+
|
|
118
|
+
call: str
|
|
119
|
+
columns: tuple[str, ...]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def rebuild(pool: Pool, refolding: Refolding, *, page: int = A_PAGE) -> Rebuilt:
|
|
123
|
+
"""Fold each call's facts again from its log and write the row where it differs."""
|
|
124
|
+
calls = rewritten = left = 0
|
|
125
|
+
after = ""
|
|
126
|
+
while names := await _calls_after(pool, refolding, after, page):
|
|
127
|
+
for call in names:
|
|
128
|
+
async with pool.connection() as connection, connection.transaction():
|
|
129
|
+
await connection.execute(HEAD_LOCKED, {"call": call})
|
|
130
|
+
stored, folded = await _refolded(connection, call)
|
|
131
|
+
if folded is None:
|
|
132
|
+
left += 1
|
|
133
|
+
elif folded != (stored or CallFacts(call=call)):
|
|
134
|
+
await connection.execute(facts.FACTS_WRITTEN, _row(folded))
|
|
135
|
+
rewritten += 1
|
|
136
|
+
calls += 1
|
|
137
|
+
after = names[-1]
|
|
138
|
+
return Rebuilt(calls=calls, rewritten=rewritten, left=left)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def heads_behind(pool: Pool) -> Heads:
|
|
142
|
+
"""The newest heads and a sample of the rest, and those that gave out fewer seqs than rows."""
|
|
143
|
+
params = {"limit": HEADS_EXAMINED, "start": f"{A_CALL}{uuid4().hex}"}
|
|
144
|
+
async with pool.connection() as connection:
|
|
145
|
+
rows = await (await connection.execute(HEADS_BEHIND, params)).fetchall()
|
|
146
|
+
return Heads(examined=len(rows), behind=tuple(str(row["log"]) for row in rows if row["behind"]))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def differing(pool: Pool, *, sample: int = A_SAMPLE) -> list[Differs]:
|
|
150
|
+
"""Refold a sample of sealed calls and name each whose stored facts differ, and where."""
|
|
151
|
+
start = f"{A_CALL}{uuid4().hex}"
|
|
152
|
+
async with pool.connection() as connection:
|
|
153
|
+
rows = await (
|
|
154
|
+
await connection.execute(SAMPLE, {"start": start, "limit": sample})
|
|
155
|
+
).fetchall()
|
|
156
|
+
found: list[Differs] = []
|
|
157
|
+
for call in (str(row["log"]) for row in rows):
|
|
158
|
+
async with pool.connection() as connection, connection.transaction():
|
|
159
|
+
await connection.execute(SNAPSHOT)
|
|
160
|
+
stored, folded = await _refolded(connection, call)
|
|
161
|
+
if folded is None:
|
|
162
|
+
continue
|
|
163
|
+
columns = _columns_apart(stored or CallFacts(call=call), folded)
|
|
164
|
+
if columns:
|
|
165
|
+
found.append(Differs(call=call, columns=columns))
|
|
166
|
+
return found
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _calls_after(pool: Pool, refolding: Refolding, after: str, page: int) -> list[str]:
|
|
170
|
+
params = {**asdict(refolding), "after": after, "limit": page}
|
|
171
|
+
async with pool.connection() as connection:
|
|
172
|
+
rows = await (await connection.execute(CALLS_PAGE, params)).fetchall()
|
|
173
|
+
return [str(row["log"]) for row in rows]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# The fold record() runs as entries land, run over the whole log from nothing. A log holding an
|
|
177
|
+
# entry this release's wire refuses folds to None: the fold would pass the entry by and lose what
|
|
178
|
+
# the release that wrote it folded from it, so its stored row is the better account.
|
|
179
|
+
async def _refolded(connection: Connection, call: str) -> tuple[CallFacts | None, CallFacts | None]:
|
|
180
|
+
row = await (await connection.execute(FACTS, {"call": call})).fetchone()
|
|
181
|
+
stored = None if row is None else facts.facts_of(row)
|
|
182
|
+
folded = CallFacts(call=call)
|
|
183
|
+
for read in await (await connection.execute(ENTRIES, {"call": call})).fetchall():
|
|
184
|
+
entry = entry_of(read)
|
|
185
|
+
if _refused(entry):
|
|
186
|
+
return stored, None
|
|
187
|
+
folded = facts.fold(folded, entry)
|
|
188
|
+
return stored, folded
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _row(folded: CallFacts) -> dict[str, object]:
|
|
192
|
+
written: dict[str, object] = {
|
|
193
|
+
**asdict(folded),
|
|
194
|
+
"e2e": list(folded.e2e),
|
|
195
|
+
"heard_at": list(folded.heard_at),
|
|
196
|
+
}
|
|
197
|
+
written.pop("agent")
|
|
198
|
+
return written
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _columns_apart(stored: CallFacts, folded: CallFacts) -> tuple[str, ...]:
|
|
202
|
+
return tuple(
|
|
203
|
+
field.name
|
|
204
|
+
for field in fields(CallFacts)
|
|
205
|
+
if field.name in facts.COLUMNS
|
|
206
|
+
and getattr(stored, field.name) != getattr(folded, field.name)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _refused(entry: Entry) -> bool:
|
|
211
|
+
if entry.ephemeral or entry.type not in EVENTS:
|
|
212
|
+
return False
|
|
213
|
+
try:
|
|
214
|
+
event_of(entry)
|
|
215
|
+
except DeclarationRefused:
|
|
216
|
+
return True
|
|
217
|
+
return False
|
pinecall/log/store.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""The log in Postgres: a call's or an agent's entries, the seq each one is born with, its owner."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from psycopg.rows import DictRow
|
|
10
|
+
|
|
11
|
+
from pinecall.domain.agent import Versions
|
|
12
|
+
from pinecall.domain.call import Opener
|
|
13
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
14
|
+
from pinecall.domain.names import Env, JsonObject, parse_env
|
|
15
|
+
from pinecall.domain.scope import Scope
|
|
16
|
+
from pinecall.log._writer import FED_TYPES, Append, Batch, Unnumbered, Writer
|
|
17
|
+
from pinecall.log.reduce import Metered
|
|
18
|
+
from pinecall.postgres.pool import Pool, box_wide
|
|
19
|
+
from pinecall.wire.frames import Entry
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
DEFAULT_LIMIT = 500
|
|
24
|
+
|
|
25
|
+
# An agent's own log is named "@<agent>", a name the CHECK on call_log keeps from any call id.
|
|
26
|
+
AGENT_LOG_PREFIX = "@"
|
|
27
|
+
|
|
28
|
+
# The most entries a worker's batch may carry.
|
|
29
|
+
MOST_IN_A_BATCH = 256
|
|
30
|
+
|
|
31
|
+
PAGE = """
|
|
32
|
+
select call, seq, ts, agent, type, ephemeral, data
|
|
33
|
+
from call_log
|
|
34
|
+
where log = %(log)s and seq > %(after)s
|
|
35
|
+
order by seq
|
|
36
|
+
limit %(limit)s
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# SealCallRequest a log nothing was written to is allowed: recovery may seal before the first entry.
|
|
40
|
+
SEAL = """
|
|
41
|
+
insert into call_log_head (log, call, sealed) values (%(call)s, %(call)s, true)
|
|
42
|
+
on conflict (log) do update set sealed = true
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
HEAD = "select seq, sealed, written, written_seq from call_log_head where log = %(log)s"
|
|
46
|
+
|
|
47
|
+
# Taken by one gateway at a time, and only while the call is open; it runs out by itself. A call
|
|
48
|
+
# that wrote nothing yet has no head: the lease makes it, as the seal does.
|
|
49
|
+
LEASED = """
|
|
50
|
+
insert into call_log_head as head (log, call, sealing_until, sealing_by)
|
|
51
|
+
values (%(call)s, %(call)s, now() + make_interval(secs => %(seconds)s), %(by)s)
|
|
52
|
+
on conflict (log) do update
|
|
53
|
+
set sealing_until = excluded.sealing_until, sealing_by = excluded.sealing_by
|
|
54
|
+
where not head.sealed and (head.sealing_until is null or head.sealing_until < now())
|
|
55
|
+
returning log
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# The holder alone gives the lease back or renews it: a gateway that lost the race to the lease
|
|
59
|
+
# cannot keep the winner's alive, and one that died stops renewing.
|
|
60
|
+
UNLEASED = """
|
|
61
|
+
update call_log_head set sealing_until = null, sealing_by = null
|
|
62
|
+
where log = %(call)s and not sealed and sealing_by = %(by)s
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
RENEWED = """
|
|
66
|
+
update call_log_head set sealing_until = now() + make_interval(secs => %(seconds)s)
|
|
67
|
+
where log = %(call)s and not sealed and sealing_by = %(by)s
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# The first claim wins, and it may come before the first entry.
|
|
72
|
+
CLAIM = """
|
|
73
|
+
insert into call_log_head as head
|
|
74
|
+
(log, agent, call, org, env, holder, config_version, lexicon_version, opened_by)
|
|
75
|
+
values (%(log)s, %(agent)s, %(call)s, %(org)s, %(env)s, %(holder)s, %(config)s, %(lexicon)s,
|
|
76
|
+
%(opened_by)s)
|
|
77
|
+
on conflict (log) do update
|
|
78
|
+
set org = coalesce(head.org, excluded.org),
|
|
79
|
+
env = coalesce(head.env, excluded.env),
|
|
80
|
+
holder = coalesce(head.holder, excluded.holder),
|
|
81
|
+
config_version = coalesce(head.config_version, excluded.config_version),
|
|
82
|
+
lexicon_version = coalesce(head.lexicon_version, excluded.lexicon_version),
|
|
83
|
+
opened_by = coalesce(head.opened_by, excluded.opened_by)
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
OWNER = "select org from call_log_head where log = %(log)s"
|
|
87
|
+
|
|
88
|
+
CLAIMANT = "select org, env from call_log_head where log = %(log)s"
|
|
89
|
+
|
|
90
|
+
MOVED = """
|
|
91
|
+
with moved as (update call_log_head set org = %(org)s where agent = %(agent)s returning log)
|
|
92
|
+
select count(*) as moved from moved
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
NOT_FED = "{types}: only the fed types are read across every log (log/_writer.py FED_TYPES)"
|
|
96
|
+
|
|
97
|
+
# Served by call_log_metered (type, position).
|
|
98
|
+
ACROSS = """
|
|
99
|
+
select entry.position, head.org, entry.call, entry.seq, entry.ts, entry.agent, entry.type,
|
|
100
|
+
entry.ephemeral, entry.data
|
|
101
|
+
from call_log entry
|
|
102
|
+
join call_log_head head on head.log = entry.log
|
|
103
|
+
where entry.type = any(%(types)s) and entry.position > %(after)s
|
|
104
|
+
order by entry.position
|
|
105
|
+
limit %(limit)s
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
NEWEST_CALLS = """
|
|
109
|
+
select call from call_log_head
|
|
110
|
+
where call is not null and (%(agent)s::text is null or agent = %(agent)s)
|
|
111
|
+
order by started_at desc nulls last, log desc
|
|
112
|
+
limit %(limit)s
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
NEWEST_LIVE_CALL = """
|
|
116
|
+
select call from call_log_head
|
|
117
|
+
where call is not null and not sealed
|
|
118
|
+
order by started_at desc nulls last, log desc
|
|
119
|
+
limit 1
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class Claimant:
|
|
125
|
+
"""Whose a log is and the world it was claimed in; no world on an agent's own log."""
|
|
126
|
+
|
|
127
|
+
org: str
|
|
128
|
+
env: Env | None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class Claim:
|
|
133
|
+
"""What a call log is claimed with: its scope, the versions it was built on, who opened it."""
|
|
134
|
+
|
|
135
|
+
scope: Scope
|
|
136
|
+
versions: Versions = field(default_factory=Versions)
|
|
137
|
+
# None for a dial placed before its worker opens it: the open says who.
|
|
138
|
+
opened_by: Opener | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Store:
|
|
142
|
+
"""The log tables on a pool the store is given and never closes."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self, pool: Pool, *, clock: Callable[[], float] = time.time, writing: Pool | None = None
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Keep the pools and the clock entries are stamped with; appends go through a writer."""
|
|
148
|
+
self.pool = pool
|
|
149
|
+
# ts is the runtime's clock, when it saw the event, never the database's.
|
|
150
|
+
self.clock = clock
|
|
151
|
+
# This process, as the holder of the seals it leases.
|
|
152
|
+
self.lease_id = uuid4().hex
|
|
153
|
+
# A gateway's writer has connections of its own; a verb's and a test's share the pool.
|
|
154
|
+
self.writer = Writer(pool if writing is None else writing)
|
|
155
|
+
|
|
156
|
+
async def append(
|
|
157
|
+
self, call: str | None, agent: str, kind: str, data: JsonObject, *, ephemeral: bool
|
|
158
|
+
) -> Entry:
|
|
159
|
+
"""Write the entry with the next seq of its log, folding the call's facts with it."""
|
|
160
|
+
entry = Unnumbered(type=kind, data=data, ephemeral=ephemeral, ts=self.clock())
|
|
161
|
+
append = Append("entry", log_name(call, agent), call, agent, [entry])
|
|
162
|
+
[written] = (await self.writer.written(append)).entries
|
|
163
|
+
return written
|
|
164
|
+
|
|
165
|
+
# Two writers deciding the same end (a webhook delivered twice) write it once: the writer looks
|
|
166
|
+
# under the head's lock, in the transaction that would write it.
|
|
167
|
+
async def append_first(
|
|
168
|
+
self, call: str, agent: str, kind: str, data: JsonObject
|
|
169
|
+
) -> Entry | None:
|
|
170
|
+
"""Write a durable entry unless the call's log holds one of its type or is sealed."""
|
|
171
|
+
entry = Unnumbered(type=kind, data=data, ephemeral=False, ts=self.clock())
|
|
172
|
+
written = (await self.writer.written(Append("first", call, call, agent, [entry]))).entries
|
|
173
|
+
return written[0] if written else None
|
|
174
|
+
|
|
175
|
+
# Of two gateways answering one tool call (the app's answer, a timeout) the first stands.
|
|
176
|
+
async def append_answer(self, call: str, agent: str, data: JsonObject) -> Entry | None:
|
|
177
|
+
"""Write a tool.result unless the log never asked its call id or answered it; None then."""
|
|
178
|
+
entry = Unnumbered(type="tool.result", data=data, ephemeral=False, ts=self.clock())
|
|
179
|
+
written = (await self.writer.written(Append("answer", call, call, agent, [entry]))).entries
|
|
180
|
+
return written[0] if written else None
|
|
181
|
+
|
|
182
|
+
# One writer, in order: `after` is how many entries the log took from it before this batch.
|
|
183
|
+
async def append_many(
|
|
184
|
+
self, call: str | None, agent: str, entries: Sequence[Unnumbered], *, after: int
|
|
185
|
+
) -> Batch:
|
|
186
|
+
"""Write a worker's batch once under the next contiguous seqs, or answer a retry of it."""
|
|
187
|
+
if not entries or len(entries) > MOST_IN_A_BATCH:
|
|
188
|
+
raise DeclarationRefused(
|
|
189
|
+
f"a batch carries 1 to {MOST_IN_A_BATCH} entries, not {len(entries)}"
|
|
190
|
+
)
|
|
191
|
+
stamped = [
|
|
192
|
+
replace(item, ts=stamp)
|
|
193
|
+
for item, stamp in zip(entries, _stamps(entries, self.clock()), strict=True)
|
|
194
|
+
]
|
|
195
|
+
append = Append("batch", log_name(call, agent), call, agent, stamped, after=after)
|
|
196
|
+
return await self.writer.written(append)
|
|
197
|
+
|
|
198
|
+
async def rescored(self, call: str, agent: str, data: JsonObject) -> Entry:
|
|
199
|
+
"""Write a call.score to a sealed log, the one entry a sealed log still takes."""
|
|
200
|
+
score = Unnumbered(type="call.score", data=data, ephemeral=False, ts=self.clock())
|
|
201
|
+
[written] = (await self.writer.written(Append("score", call, call, agent, [score]))).entries
|
|
202
|
+
return written
|
|
203
|
+
|
|
204
|
+
async def since(self, log: str, *, after: int = 0, limit: int = DEFAULT_LIMIT) -> list[Entry]:
|
|
205
|
+
"""Return one page of the log's durable entries above the cursor, in seq order."""
|
|
206
|
+
async with self.pool.connection() as connection:
|
|
207
|
+
cursor = await connection.execute(PAGE, {"log": log, "after": after, "limit": limit})
|
|
208
|
+
return [entry_of(row) for row in await cursor.fetchall()]
|
|
209
|
+
|
|
210
|
+
async def whole(self, log: str, *, after: int = 0) -> list[Entry]:
|
|
211
|
+
"""Return every durable entry of the log above the cursor, oldest first."""
|
|
212
|
+
found: list[Entry] = []
|
|
213
|
+
while True:
|
|
214
|
+
page = await self.since(log, after=after, limit=DEFAULT_LIMIT)
|
|
215
|
+
found += page
|
|
216
|
+
if len(page) < DEFAULT_LIMIT:
|
|
217
|
+
return found
|
|
218
|
+
after = page[-1].seq
|
|
219
|
+
|
|
220
|
+
async def latest_seq(self, log: str) -> int:
|
|
221
|
+
"""Return the last seq the log gave out, ephemerals counted; 0 for a log never written."""
|
|
222
|
+
head = await self._head(log)
|
|
223
|
+
return 0 if head is None else int(head["seq"])
|
|
224
|
+
|
|
225
|
+
async def written(self, log: str) -> int:
|
|
226
|
+
"""Return how many entries the log took from its writer's batches; 0 for none."""
|
|
227
|
+
head = await self._head(log)
|
|
228
|
+
return 0 if head is None else int(head["written"])
|
|
229
|
+
|
|
230
|
+
async def sealed(self, log: str) -> bool:
|
|
231
|
+
"""Return whether the log is sealed."""
|
|
232
|
+
head = await self._head(log)
|
|
233
|
+
return head is not None and bool(head["sealed"])
|
|
234
|
+
|
|
235
|
+
async def seal(self, call: str) -> None:
|
|
236
|
+
"""Seal the call's log; sealing twice changes nothing."""
|
|
237
|
+
async with self.pool.connection() as connection:
|
|
238
|
+
await connection.execute(SEAL, {"call": call})
|
|
239
|
+
|
|
240
|
+
async def lease_seal(self, call: str, seconds: float) -> bool:
|
|
241
|
+
"""Take the right to seal the call for so long; False when another holds it, or it ended."""
|
|
242
|
+
async with self.pool.connection() as connection:
|
|
243
|
+
taken = await (
|
|
244
|
+
await connection.execute(
|
|
245
|
+
LEASED, {"call": call, "seconds": seconds, "by": self.lease_id}
|
|
246
|
+
)
|
|
247
|
+
).fetchone()
|
|
248
|
+
return taken is not None
|
|
249
|
+
|
|
250
|
+
async def renew_seal(self, call: str, seconds: float) -> None:
|
|
251
|
+
"""Hold the right to seal the call for so long again, while sealing it."""
|
|
252
|
+
async with self.pool.connection() as connection:
|
|
253
|
+
await connection.execute(
|
|
254
|
+
RENEWED, {"call": call, "seconds": seconds, "by": self.lease_id}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def release_seal(self, call: str) -> None:
|
|
258
|
+
"""Give the right to seal back, after a seal that broke: the next knock takes it."""
|
|
259
|
+
async with self.pool.connection() as connection:
|
|
260
|
+
await connection.execute(UNLEASED, {"call": call, "by": self.lease_id})
|
|
261
|
+
|
|
262
|
+
async def claim(
|
|
263
|
+
self, call: str | None, agent: str, org: str, claim: Claim | None = None
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Record the log's org, and for a call its scope and versions; the first claim stands."""
|
|
266
|
+
scope = None if call is None or claim is None else claim.scope
|
|
267
|
+
versions = Versions() if call is None or claim is None else claim.versions
|
|
268
|
+
async with self.pool.connection() as connection:
|
|
269
|
+
await connection.execute(
|
|
270
|
+
CLAIM,
|
|
271
|
+
{
|
|
272
|
+
"log": log_name(call, agent),
|
|
273
|
+
"agent": agent,
|
|
274
|
+
"call": call,
|
|
275
|
+
"org": org,
|
|
276
|
+
"env": None if scope is None else scope.env,
|
|
277
|
+
"holder": None if scope is None else scope.holder,
|
|
278
|
+
"config": versions.config,
|
|
279
|
+
"lexicon": versions.lexicon,
|
|
280
|
+
"opened_by": None if call is None or claim is None else claim.opened_by,
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Whose a log is decides who may read it: asked box-wide, so another org's log reads as that
|
|
285
|
+
# org's (a 404 to the asker) and never as nobody's (postgres/pool.py, migration 0096).
|
|
286
|
+
async def owner(self, agent: str) -> str | None:
|
|
287
|
+
"""Return the org the agent's own log belongs to, or None while nobody claimed it."""
|
|
288
|
+
with box_wide():
|
|
289
|
+
async with self.pool.connection() as connection:
|
|
290
|
+
found = await connection.execute(OWNER, {"log": log_name(None, agent)})
|
|
291
|
+
row = await found.fetchone()
|
|
292
|
+
return None if row is None or row["org"] is None else str(row["org"])
|
|
293
|
+
|
|
294
|
+
async def claimant(self, call: str | None, agent: str) -> Claimant | None:
|
|
295
|
+
"""Return whose the log is and in which world, or None while nobody claimed it."""
|
|
296
|
+
with box_wide():
|
|
297
|
+
async with self.pool.connection() as connection:
|
|
298
|
+
found = await connection.execute(CLAIMANT, {"log": log_name(call, agent)})
|
|
299
|
+
row = await found.fetchone()
|
|
300
|
+
if row is None or row["org"] is None:
|
|
301
|
+
return None
|
|
302
|
+
env = None if row["env"] is None else parse_env(str(row["env"]))
|
|
303
|
+
return Claimant(org=str(row["org"]), env=env)
|
|
304
|
+
|
|
305
|
+
# The operator's way back from a claim: an agent registered with the wrong key.
|
|
306
|
+
async def moved(self, agent: str, org: str) -> int:
|
|
307
|
+
"""Move every log of the agent to another org and return how many moved."""
|
|
308
|
+
async with self.pool.connection() as connection:
|
|
309
|
+
row = await (await connection.execute(MOVED, {"agent": agent, "org": org})).fetchone()
|
|
310
|
+
return 0 if row is None else int(row["moved"])
|
|
311
|
+
|
|
312
|
+
async def across(
|
|
313
|
+
self, types: Sequence[str], *, after: int = 0, limit: int = DEFAULT_LIMIT
|
|
314
|
+
) -> list[Metered]:
|
|
315
|
+
"""Return one page of the fed entries of every log, by position, with their owners."""
|
|
316
|
+
unfed = sorted(set(types) - FED_TYPES)
|
|
317
|
+
if unfed:
|
|
318
|
+
raise DeclarationRefused(NOT_FED.format(types=", ".join(unfed)))
|
|
319
|
+
params = {"types": list(types), "after": after, "limit": limit}
|
|
320
|
+
async with self.pool.connection() as connection:
|
|
321
|
+
rows = await (await connection.execute(ACROSS, params)).fetchall()
|
|
322
|
+
return [
|
|
323
|
+
Metered(
|
|
324
|
+
position=int(row["position"]),
|
|
325
|
+
org=None if row["org"] is None else str(row["org"]),
|
|
326
|
+
entry=entry_of(row),
|
|
327
|
+
)
|
|
328
|
+
for row in rows
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
async def newest_calls(self, limit: int, *, agent: str | None = None) -> list[str]:
|
|
332
|
+
"""Return the newest calls of the box, or of one agent."""
|
|
333
|
+
async with self.pool.connection() as connection:
|
|
334
|
+
cursor = await connection.execute(NEWEST_CALLS, {"limit": limit, "agent": agent})
|
|
335
|
+
return [str(row["call"]) for row in await cursor.fetchall()]
|
|
336
|
+
|
|
337
|
+
async def newest_live_call(self) -> str | None:
|
|
338
|
+
"""Return the newest call whose log is not sealed, or None."""
|
|
339
|
+
async with self.pool.connection() as connection:
|
|
340
|
+
row = await (await connection.execute(NEWEST_LIVE_CALL)).fetchone()
|
|
341
|
+
return None if row is None else str(row["call"])
|
|
342
|
+
|
|
343
|
+
async def _head(self, log: str) -> DictRow | None:
|
|
344
|
+
async with self.pool.connection() as connection:
|
|
345
|
+
return await (await connection.execute(HEAD, {"log": log})).fetchone()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def log_name(call: str | None, agent: str) -> str:
|
|
349
|
+
"""Return the name a log goes by: the call id, or the agent's with the prefix."""
|
|
350
|
+
return f"{AGENT_LOG_PREFIX}{agent}" if call is None else call
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# Built, not validated: a row is what an Entry wrote, and its columns are typed (text, bigint,
|
|
354
|
+
# float8, boolean, jsonb), so validating it again re-checked our own write on every read, a tenth
|
|
355
|
+
# of a gateway's core at 300 calls (a seal reads its whole log; 2026-10-01).
|
|
356
|
+
def entry_of(row: DictRow) -> Entry:
|
|
357
|
+
"""Return the entry a call_log row holds."""
|
|
358
|
+
return Entry.model_construct(
|
|
359
|
+
**{name: row[name] for name in ("call", "seq", "ts", "agent", "type", "ephemeral", "data")}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# The worker's clock says when each thing happened; the gateway's bounds it, and a batch never
|
|
364
|
+
# steps back inside itself.
|
|
365
|
+
def _stamps(entries: Sequence[Unnumbered], now: float) -> list[float]:
|
|
366
|
+
stamps: list[float] = []
|
|
367
|
+
for item in entries:
|
|
368
|
+
stamp = min(item.ts, now)
|
|
369
|
+
stamps.append(stamp if not stamps else max(stamp, stamps[-1]))
|
|
370
|
+
return stamps
|