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.
Files changed (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. 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