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
@@ -0,0 +1,454 @@
1
+ """The writer: every log's appends written a group per transaction, fed ones in a lane apart."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections import deque
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import Literal, LiteralString
9
+
10
+ import psycopg
11
+ from psycopg.rows import DictRow
12
+ from psycopg.types.json import Jsonb
13
+
14
+ from pinecall.domain.errors import Conflict
15
+ from pinecall.domain.names import JsonObject
16
+ from pinecall.log.facts import record
17
+ from pinecall.log.reduce import METERED_TYPES
18
+ from pinecall.postgres.pool import Connection, Pool, box_task
19
+ from pinecall.wire.frames import Entry
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # The most entries one group carries.
24
+ MOST_IN_A_GROUP = 500
25
+
26
+ # The types read across every log by position: the usage feed, the callbacks, the codes, and
27
+ # WhatsApp's queue. A position is given at insert, not at commit, so a transaction that writes one
28
+ # of these takes FEED_ORDER first and holds it to its commit: their positions come in commit order,
29
+ # and a reader whose cursor passed one has seen every one before it.
30
+ FED_TYPES = frozenset(
31
+ {
32
+ *METERED_TYPES,
33
+ "code.issued",
34
+ "code.claimed",
35
+ "callback.requested",
36
+ "message.waiting",
37
+ "message.taken",
38
+ }
39
+ )
40
+
41
+ # Outside the 32 bits of hashtext, which keys the runtime's other advisory locks.
42
+ FEED_ORDER_KEY = 0x9E3_C411_FEED
43
+
44
+ FEED_ORDER = "select pg_advisory_xact_lock(%(key)s)"
45
+
46
+ # Locked in the order of their names, so two writers never wait on each other's rows in a circle.
47
+ HEADS_LOCKED = """
48
+ select log, seq, sealed, written, written_seq from call_log_head
49
+ where log = any(%(logs)s)
50
+ order by log
51
+ for update
52
+ """
53
+
54
+ # A head at seq 0 for each log that begins in this group, rare beside the ones already there. One
55
+ # another writer made first is locked and read as it is: the update changes nothing of it.
56
+ HEADS_BEGUN = """
57
+ insert into call_log_head as head (log, agent, call)
58
+ select begun.log, begun.agent, begun.call
59
+ from unnest(%(logs)s::text[], %(agents)s::text[], %(calls)s::text[]) as begun(log, agent, call)
60
+ order by begun.log
61
+ on conflict (log) do update set log = head.log
62
+ returning log, seq, sealed, written, written_seq
63
+ """
64
+
65
+ # The heads moved and the durable rows written in one statement: two tables, nothing between them
66
+ # to read. The rows travel as one JSON document, read apart by Postgres: six arrays of N were six
67
+ # parameters adapted a value at a time in Python, a fifth of the gateway's core at 100 calls.
68
+ WRITTEN = """
69
+ with moved as (
70
+ update call_log_head as head
71
+ set seq = moved.seq,
72
+ written = moved.written,
73
+ written_seq = moved.written_seq,
74
+ agent = coalesce(head.agent, moved.agent),
75
+ call = coalesce(head.call, moved.call),
76
+ started_at = coalesce(head.started_at, moved.started_at)
77
+ from unnest(%(logs)s::text[], %(seqs)s::bigint[], %(written)s::bigint[],
78
+ %(written_seqs)s::bigint[], %(agents)s::text[], %(heads_calls)s::text[],
79
+ %(heads_stamps)s::float8[])
80
+ as moved(log, seq, written, written_seq, agent, call, started_at)
81
+ where head.log = moved.log
82
+ )
83
+ insert into call_log (call, seq, ts, agent, type, ephemeral, data)
84
+ select kept.call, kept.seq, kept.ts, kept.agent, kept.type, false, kept.data
85
+ from jsonb_to_recordset(%(rows)s::jsonb)
86
+ as kept(call text, seq bigint, ts float8, agent text, type text, data jsonb)
87
+ """
88
+
89
+ # The first-of-its-type requests whose log already holds an entry of that type: read under the
90
+ # heads' locks, so a second writer deciding the same end sees the first one's row.
91
+ HELD_TYPES = """
92
+ select distinct wanted.log
93
+ from unnest(%(logs)s::text[], %(types)s::text[]) as wanted(log, type)
94
+ where exists (select 1 from call_log where log = wanted.log and type = wanted.type)
95
+ """
96
+
97
+ # The answers of a tool call not needed: the log never asked that call id, or already holds its
98
+ # answer (the app's, or the timeout another gateway wrote). Read under the heads' locks, so of two
99
+ # gateways answering one call id the second sees the first's row.
100
+ UNANSWERABLE = """
101
+ select wanted.log
102
+ from unnest(%(logs)s::text[], %(ids)s::text[]) as wanted(log, call_id)
103
+ where not exists (select 1 from call_log where log = wanted.log and type = 'tool.call'
104
+ and data ->> 'call_id' = wanted.call_id)
105
+ or exists (select 1 from call_log where log = wanted.log and type = 'tool.result'
106
+ and data ->> 'call_id' = wanted.call_id)
107
+ """
108
+
109
+ # A request that writes a fed type goes in the fed lane, which alone takes FEED_ORDER; every other
110
+ # goes in the plain lane, which never waits on it.
111
+ type Lane = Literal["fed", "plain"]
112
+
113
+ # The gateway's own entry, a worker's batch, a verdict written again on a sealed log, or a durable
114
+ # entry written only when its log is open and holds none of its type.
115
+ type Kind = Literal["entry", "batch", "score", "first", "answer"]
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class Unnumbered:
120
+ """An entry the log has not numbered yet: what it is, whether a store keeps it, and when."""
121
+
122
+ type: str
123
+ data: JsonObject
124
+ ephemeral: bool
125
+ ts: float
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class Batch:
130
+ """A request's entries as the log numbered them, and whether the log had taken them before."""
131
+
132
+ entries: list[Entry]
133
+ replayed: bool
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class Append:
138
+ """One request to the writer: its kind, its log, its entries stamped, and a batch's `after`."""
139
+
140
+ kind: Kind
141
+ log: str
142
+ call: str | None
143
+ agent: str
144
+ entries: Sequence[Unnumbered]
145
+ after: int = 0
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class _Queued:
150
+ """A request waiting for its group, its lane, and where its answer goes."""
151
+
152
+ append: Append
153
+ lane: Lane
154
+ answer: asyncio.Future[Batch]
155
+
156
+
157
+ @dataclass(frozen=True, slots=True)
158
+ class _Moved:
159
+ """A request the log takes: its head as it will be, and the first seq of its entries."""
160
+
161
+ first: int
162
+ seq: int
163
+ written: int
164
+ written_seq: int | None
165
+
166
+
167
+ class Writer:
168
+ """The appends not yet written, in the order they came, and a task per lane writing them."""
169
+
170
+ def __init__(self, pool: Pool) -> None:
171
+ """Keep the pool; a lane's task starts with its first request and ends when none is left."""
172
+ self.pool = pool
173
+ self._queue: deque[_Queued] = deque()
174
+ # The logs a lane's transaction writes now: the other lane leaves them until it commits.
175
+ self._writing: set[str] = set()
176
+ self._tasks: dict[Lane, asyncio.Task[None]] = {}
177
+
178
+ @property
179
+ def waiting(self) -> int:
180
+ """How many requests wait for a transaction now: what /metrics says a writer is behind."""
181
+ return len(self._queue)
182
+
183
+ async def written(self, append: Append) -> Batch:
184
+ """Queue the request; its entries numbered, once the transaction that wrote them commits."""
185
+ answer: asyncio.Future[Batch] = asyncio.get_running_loop().create_future()
186
+ self._queue.append(_Queued(append, "fed" if _fed(append) else "plain", answer))
187
+ self._started()
188
+ return await answer
189
+
190
+ async def drained(self) -> None:
191
+ """Return once every request queued so far is written and answered."""
192
+ while running := [task for task in self._tasks.values() if not task.done()]:
193
+ await asyncio.shield(asyncio.gather(*running))
194
+
195
+ # A lane idle with a request of its own waiting starts: on a request, and whenever a group
196
+ # ends, since the other lane may have been waiting on its logs.
197
+ def _started(self) -> None:
198
+ for lane in LANES:
199
+ task = self._tasks.get(lane)
200
+ idle = task is None or task.done()
201
+ if idle and any(queued.lane == lane for queued in self._queue):
202
+ self._tasks[lane] = box_task(self._lane(lane))
203
+
204
+ # No timer: what arrives while a transaction is out is the next group. A lane ends when it has
205
+ # nothing it may write; the other lane starts it again when it lets a log go.
206
+ async def _lane(self, lane: Lane) -> None:
207
+ while group := self._taken(lane):
208
+ logs = {queued.append.log for queued in group}
209
+ self._writing |= logs
210
+ try:
211
+ await self._settled(group)
212
+ finally:
213
+ self._writing -= logs
214
+ self._started()
215
+
216
+ # A log is in a group once, and never while the other lane writes it or holds an earlier request
217
+ # of it: its later requests, and every one past the group's size, wait in the order they came.
218
+ # A request its caller gave up on is not written.
219
+ def _taken(self, lane: Lane) -> list[_Queued]:
220
+ group: list[_Queued] = []
221
+ later: deque[_Queued] = deque()
222
+ logs = set(self._writing)
223
+ size = 0
224
+ while self._queue:
225
+ queued = self._queue.popleft()
226
+ if queued.answer.done():
227
+ continue
228
+ entries = len(queued.append.entries)
229
+ fits = not group or size + entries <= MOST_IN_A_GROUP
230
+ if queued.lane == lane and queued.append.log not in logs and fits:
231
+ group.append(queued)
232
+ size += entries
233
+ else:
234
+ later.append(queued)
235
+ logs.add(queued.append.log)
236
+ self._queue = later
237
+ return group
238
+
239
+ # A member whose own entries break the transaction (a constraint, bytes Postgres refuses) would
240
+ # take the group down with it, so the group is written again a member at a time and only that
241
+ # one is refused. Anything else (the pool, the network) is every member's answer.
242
+ async def _settled(self, group: list[_Queued]) -> None:
243
+ try:
244
+ outcomes = await _transaction(self.pool, [queued.append for queued in group])
245
+ except (psycopg.errors.IntegrityError, psycopg.errors.DataError):
246
+ logger.warning("a group of %d broke; each is written alone", len(group), exc_info=True)
247
+ for queued in group:
248
+ await self._alone(queued)
249
+ return
250
+ except Exception as broke:
251
+ logger.warning("a group of %d was not written", len(group), exc_info=True)
252
+ for queued in group:
253
+ _answered(queued.answer, broke)
254
+ return
255
+ for queued, outcome in zip(group, outcomes, strict=True):
256
+ _answered(queued.answer, outcome)
257
+
258
+ async def _alone(self, queued: _Queued) -> None:
259
+ try:
260
+ [outcome] = await _transaction(self.pool, [queued.append])
261
+ except Exception as broke:
262
+ logger.warning("an append to %s was refused", queued.append.log, exc_info=True)
263
+ _answered(queued.answer, broke)
264
+ return
265
+ _answered(queued.answer, outcome)
266
+
267
+
268
+ LANES: tuple[Lane, ...] = ("fed", "plain")
269
+
270
+
271
+ # What a first-of-its-type request is answered with when its log did not need it.
272
+ NOT_NEEDED = Batch(entries=[], replayed=False)
273
+
274
+
275
+ # Every statement covers the whole group: the heads begun, locked, read and moved at once, the
276
+ # rows written at once, the facts folded at once. The answers are known before the commit and
277
+ # handed out only after it.
278
+ async def _transaction(pool: Pool, group: Sequence[Append]) -> list[Batch | Conflict]:
279
+ begun = [
280
+ append
281
+ for append in group
282
+ if append.kind == "entry" or (append.kind == "batch" and append.after == 0)
283
+ ]
284
+ async with pool.connection() as connection, connection.transaction():
285
+ if any(_fed(append) for append in group):
286
+ await connection.execute(FEED_ORDER, {"key": FEED_ORDER_KEY})
287
+ heads = await _heads(connection, HEADS_LOCKED, {"logs": [append.log for append in group]})
288
+ missing = [append for append in begun if append.log not in heads]
289
+ if missing:
290
+ heads |= await _heads(connection, HEADS_BEGUN, _begun(missing))
291
+ typed = await _held_types(
292
+ connection, [append for append in group if append.kind == "first"]
293
+ )
294
+ typed |= await _unanswerable(
295
+ connection, [append for append in group if append.kind == "answer"]
296
+ )
297
+ outcomes = [_outcome_of(append, heads.get(append.log), typed) for append in group]
298
+ moved = [
299
+ (append, outcome)
300
+ for append, outcome in zip(group, outcomes, strict=True)
301
+ if isinstance(outcome, _Moved)
302
+ ]
303
+ numbered = [_numbered(append, first=outcome.first) for append, outcome in moved]
304
+ entries = [entry for batch in numbered for entry in batch]
305
+ if moved:
306
+ await connection.execute(WRITTEN, {**_heads_moved(moved), **_rows(entries)})
307
+ await record(connection, entries)
308
+ taken = iter(numbered)
309
+ return [
310
+ Batch(entries=next(taken), replayed=False) if isinstance(outcome, _Moved) else outcome
311
+ for outcome in outcomes
312
+ ]
313
+
314
+
315
+ async def _held_types(connection: Connection, firsts: Sequence[Append]) -> set[str]:
316
+ if not firsts:
317
+ return set()
318
+ wanted = {
319
+ "logs": [append.log for append in firsts],
320
+ "types": [append.entries[0].type for append in firsts],
321
+ }
322
+ rows = await (await connection.execute(HELD_TYPES, wanted)).fetchall()
323
+ return {str(row["log"]) for row in rows}
324
+
325
+
326
+ async def _unanswerable(connection: Connection, answers: Sequence[Append]) -> set[str]:
327
+ if not answers:
328
+ return set()
329
+ wanted = {
330
+ "logs": [append.log for append in answers],
331
+ "ids": [str(append.entries[0].data.get("call_id")) for append in answers],
332
+ }
333
+ rows = await (await connection.execute(UNANSWERABLE, wanted)).fetchall()
334
+ return {str(row["log"]) for row in rows}
335
+
336
+
337
+ def _fed(append: Append) -> bool:
338
+ return any(not item.ephemeral and item.type in FED_TYPES for item in append.entries)
339
+
340
+
341
+ async def _heads(
342
+ connection: Connection, statement: LiteralString, params: Mapping[str, object]
343
+ ) -> dict[str, DictRow]:
344
+ rows = await (await connection.execute(statement, params)).fetchall()
345
+ return {str(row["log"]): row for row in rows}
346
+
347
+
348
+ def _begun(missing: Sequence[Append]) -> dict[str, object]:
349
+ return {
350
+ "logs": [append.log for append in missing],
351
+ "agents": [append.agent for append in missing],
352
+ "calls": [append.call for append in missing],
353
+ }
354
+
355
+
356
+ # A first-of-its-type request is not needed on a log never written, sealed, or holding its type;
357
+ # an answer, on one that never asked its call id or holds its answer already.
358
+ def _outcome_of(append: Append, head: DictRow | None, typed: set[str]) -> _Moved | Batch | Conflict:
359
+ guarded = append.kind in {"first", "answer"}
360
+ if guarded and (append.log in typed or head is None or head["sealed"]):
361
+ return NOT_NEEDED
362
+ return _outcome(append, head)
363
+
364
+
365
+ def _outcome(append: Append, head: DictRow | None) -> _Moved | Batch | Conflict:
366
+ if head is None and append.kind == "score":
367
+ return Conflict(f"call {append.call} has no log to judge")
368
+ if append.kind == "entry" and (head is None or head["sealed"]):
369
+ kind = append.entries[0].type
370
+ return Conflict(f"call {append.call} has ended: {kind} cannot be appended")
371
+ if head is not None and append.kind != "batch":
372
+ seq = int(head["seq"]) + 1
373
+ return _Moved(
374
+ first=seq, seq=seq, written=int(head["written"]), written_seq=head["written_seq"]
375
+ )
376
+ n = len(append.entries)
377
+ if head is not None and not head["sealed"] and int(head["written"]) == append.after:
378
+ seq = int(head["seq"]) + n
379
+ return _Moved(first=seq - n + 1, seq=seq, written=append.after + n, written_seq=seq)
380
+ first = _replayed_from(append, head)
381
+ if isinstance(first, Conflict):
382
+ return first
383
+ return Batch(entries=_numbered(append, first=first), replayed=True)
384
+
385
+
386
+ # A retry of a batch the log took is answered even after the seal, so the replay is asked first.
387
+ def _replayed_from(append: Append, head: DictRow | None) -> int | Conflict:
388
+ n, after = len(append.entries), append.after
389
+ written = 0 if head is None else int(head["written"])
390
+ last = None if head is None else head["written_seq"]
391
+ if after + n == written and last is not None:
392
+ return int(last) - n + 1
393
+ if head is not None and head["sealed"]:
394
+ return Conflict(f"call {append.call} has ended: its batch cannot be appended")
395
+ return Conflict(
396
+ f"call {append.call}: the log took {written} entries from its worker, "
397
+ f"and the batch says {after} before its {n}"
398
+ )
399
+
400
+
401
+ def _numbered(append: Append, *, first: int) -> list[Entry]:
402
+ return [
403
+ Entry(
404
+ seq=seq,
405
+ ts=item.ts,
406
+ call=append.call,
407
+ agent=append.agent,
408
+ type=item.type,
409
+ ephemeral=item.ephemeral,
410
+ data=item.data,
411
+ )
412
+ for seq, item in enumerate(append.entries, first)
413
+ ]
414
+
415
+
416
+ # A verdict on a sealed log names nothing of the log: it only takes the next seq.
417
+ def _heads_moved(moved: Sequence[tuple[Append, _Moved]]) -> dict[str, object]:
418
+ return {
419
+ "logs": [append.log for append, _ in moved],
420
+ "seqs": [outcome.seq for _, outcome in moved],
421
+ "written": [outcome.written for _, outcome in moved],
422
+ "written_seqs": [outcome.written_seq for _, outcome in moved],
423
+ "agents": [None if append.kind == "score" else append.agent for append, _ in moved],
424
+ "heads_calls": [None if append.kind == "score" else append.call for append, _ in moved],
425
+ "heads_stamps": [
426
+ None if append.kind == "score" else append.entries[0].ts for append, _ in moved
427
+ ],
428
+ }
429
+
430
+
431
+ def _rows(entries: Sequence[Entry]) -> dict[str, object]:
432
+ rows = [
433
+ {
434
+ "call": entry.call,
435
+ "seq": entry.seq,
436
+ "ts": entry.ts,
437
+ "agent": entry.agent,
438
+ "type": entry.type,
439
+ "data": entry.data,
440
+ }
441
+ for entry in entries
442
+ if not entry.ephemeral
443
+ ]
444
+ return {"rows": Jsonb(rows)}
445
+
446
+
447
+ # A caller that gave up has a cancelled future: its entries were written all the same.
448
+ def _answered(answer: asyncio.Future[Batch], outcome: Batch | Exception) -> None:
449
+ if answer.done():
450
+ return
451
+ if isinstance(outcome, Exception):
452
+ answer.set_exception(outcome)
453
+ return
454
+ answer.set_result(outcome)
pinecall/log/days.py ADDED
@@ -0,0 +1,154 @@
1
+ """The log's days: a partition of call_log per UTC day, made ahead and dropped once emptied."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+
7
+ from psycopg import sql
8
+
9
+ from pinecall.postgres.pool import Pool
10
+
11
+ A_DAY_S = 86400
12
+
13
+ # Made a week ahead each night: a box whose nightly run stops has a week before the default fills.
14
+ AHEAD = 7
15
+
16
+ # Doctor speaks up while there are fewer days ahead than this.
17
+ FEWEST_AHEAD = 2
18
+
19
+ # A day is call_log_<YYYYMMDD>; the table as it was before the days is one partition of its own.
20
+ A_DAY = re.compile(r"^call_log_(\d{8})$")
21
+
22
+ BEFORE = "call_log_before_the_days"
23
+
24
+ DEFAULT = "call_log_default"
25
+
26
+ PARTITIONS = """
27
+ SELECT child.relname AS name, pg_get_expr(child.relpartbound, child.oid) AS bound
28
+ FROM pg_inherits link JOIN pg_class child ON child.oid = link.inhrelid
29
+ WHERE link.inhparent = 'call_log'::regclass
30
+ """
31
+
32
+ # The upper bound a partition was made with, as pg_get_expr prints it.
33
+ UPPER = re.compile(r"TO \('?(-?[0-9.e+]+)'?\)")
34
+
35
+ # A partition made or dropped waits a second at most for its lock, and tries again the next night.
36
+ BRIEF = "SET LOCAL lock_timeout = '1s'"
37
+
38
+ MADE = "CREATE TABLE {day} PARTITION OF call_log FOR VALUES FROM ({start}) TO ({end})"
39
+
40
+ DEFAULT_HOLDS = "SELECT EXISTS (SELECT 1 FROM {default} WHERE ts >= %(start)s AND ts < %(end)s)"
41
+
42
+ HOLDS = "SELECT EXISTS (SELECT 1 FROM {day}) AS holds"
43
+
44
+ DROPPED = "DROP TABLE {day}"
45
+
46
+ IN_THE_DEFAULT = "SELECT count(*) AS rows FROM {default}"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Partition:
51
+ """A partition of the log: its name, and the time it holds rows up to (None: the default)."""
52
+
53
+ name: str
54
+ upto: float | None
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Kept:
59
+ """What a night did to the days: made, dropped, refused for rows the default holds of them."""
60
+
61
+ made: list[str]
62
+ dropped: list[str]
63
+ refused: list[str]
64
+
65
+
66
+ async def kept(pool: Pool, now: float) -> Kept:
67
+ """Make the days ahead that are missing and drop the past days nothing is left in."""
68
+ found = await _partitions(pool)
69
+ made, refused = await _made_ahead(pool, found, now)
70
+ return Kept(made=made, dropped=await _dropped(pool, found, now), refused=refused)
71
+
72
+
73
+ async def examined(pool: Pool, now: float) -> str | None:
74
+ """What is wrong with the days: rows the default caught, or too few days made ahead."""
75
+ troubles: list[str] = []
76
+ async with pool.connection() as connection:
77
+ query = sql.SQL(IN_THE_DEFAULT).format(default=sql.Identifier(DEFAULT))
78
+ row = await (await connection.execute(query)).fetchone()
79
+ caught = 0 if row is None else int(row["rows"])
80
+ if caught:
81
+ troubles.append(f"{caught} rows in {DEFAULT}: a day was missing when they were written")
82
+ ahead = sum(
83
+ 1 for part in await _partitions(pool) if part.upto is not None and part.upto > _day_of(now)
84
+ )
85
+ if ahead < FEWEST_AHEAD:
86
+ troubles.append(f"{ahead} days of the log made ahead: `retention run` makes {AHEAD}")
87
+ return "; ".join(troubles) or None
88
+
89
+
90
+ async def _partitions(pool: Pool) -> list[Partition]:
91
+ """Every partition of the log, the default's end unknown."""
92
+ async with pool.connection() as connection:
93
+ rows = await (await connection.execute(PARTITIONS)).fetchall()
94
+ found: list[Partition] = []
95
+ for row in rows:
96
+ upper = UPPER.search(str(row["bound"]))
97
+ found.append(Partition(str(row["name"]), None if upper is None else float(upper.group(1))))
98
+ return sorted(found, key=lambda partition: (partition.upto is None, partition.upto or 0.0))
99
+
100
+
101
+ def _day_named(start: float) -> str:
102
+ """The name of the day that starts at that time."""
103
+ return f"call_log_{datetime.fromtimestamp(start, UTC):%Y%m%d}"
104
+
105
+
106
+ # A day before the old table's bound is its; a day the default holds rows of cannot be made, since
107
+ # making it would move them, and is named instead.
108
+ async def _made_ahead(
109
+ pool: Pool, found: list[Partition], now: float
110
+ ) -> tuple[list[str], list[str]]:
111
+ names = {part.name for part in found}
112
+ before = next((part.upto for part in found if part.name == BEFORE), None) or 0.0
113
+ made: list[str] = []
114
+ refused: list[str] = []
115
+ for start in (_day_of(now) + n * A_DAY_S for n in range(AHEAD)):
116
+ name = _day_named(start)
117
+ if name in names or start < before:
118
+ continue
119
+ bounds = {"start": start, "end": start + A_DAY_S}
120
+ async with pool.connection() as connection, connection.transaction():
121
+ await connection.execute(BRIEF)
122
+ holds = sql.SQL(DEFAULT_HOLDS).format(default=sql.Identifier(DEFAULT))
123
+ row = await (await connection.execute(holds, bounds)).fetchone()
124
+ if row is not None and row["exists"]:
125
+ refused.append(name)
126
+ continue
127
+ statement = sql.SQL(MADE).format(
128
+ day=sql.Identifier(name), start=sql.Literal(start), end=sql.Literal(start + A_DAY_S)
129
+ )
130
+ await connection.execute(statement)
131
+ made.append(name)
132
+ return made, refused
133
+
134
+
135
+ # Only a day wholly past and empty goes: its rows went with the calls erasure took, one by one,
136
+ # as each org's days said; a row of a call kept, or of an agent's own log, keeps it.
137
+ async def _dropped(pool: Pool, found: list[Partition], now: float) -> list[str]:
138
+ dropped: list[str] = []
139
+ for part in found:
140
+ if part.upto is None or part.upto > _day_of(now):
141
+ continue
142
+ async with pool.connection() as connection, connection.transaction():
143
+ await connection.execute(BRIEF)
144
+ holds = sql.SQL(HOLDS).format(day=sql.Identifier(part.name))
145
+ row = await (await connection.execute(holds)).fetchone()
146
+ if row is None or row["holds"]:
147
+ continue
148
+ await connection.execute(sql.SQL(DROPPED).format(day=sql.Identifier(part.name)))
149
+ dropped.append(part.name)
150
+ return dropped
151
+
152
+
153
+ def _day_of(now: float) -> float:
154
+ return float(int(now) - int(now) % A_DAY_S)