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/logs.py ADDED
@@ -0,0 +1,581 @@
1
+ """The logs open in this process: append, the live readers, replay, the feeds."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
6
+ from typing import Self
7
+
8
+ from pinecall.domain.call import CallContext
9
+ from pinecall.domain.errors import DeclarationRefused
10
+ from pinecall.domain.names import ENVS, Env, JsonObject, Medium, parse_env
11
+ from pinecall.log import private
12
+ from pinecall.log._relay import BOX_CHANNEL, CHANNEL, FEED_CHANNEL, FEED_PREFIX, LOG_PREFIX, Relay
13
+ from pinecall.log.private import Privacy
14
+ from pinecall.log.readers import EVERYTHING, Filter
15
+ from pinecall.log.reduce import reduce
16
+ from pinecall.log.store import Claimant, Store, Unnumbered, log_name
17
+ from pinecall.process.signal import LocalSignal, Signal
18
+ from pinecall.wire.events import (
19
+ EPHEMERAL_EVENTS,
20
+ TERMINAL_EVENT,
21
+ CallDialing,
22
+ CallRinging,
23
+ CallStarted,
24
+ )
25
+ from pinecall.wire.frames import Entry
26
+ from pinecall.wire.parts import Route
27
+ from pinecall.wire.rest.calls import BatchedEntry
28
+ from pinecall.wire.state import State
29
+
30
+ # Runs inline on every append, before append returns: for work that is part of the write.
31
+ type Tap = Callable[[Entry], Awaitable[None]]
32
+
33
+
34
+ # How far a reader may fall behind before it is dropped: about a minute of a busy call, and the
35
+ # most a dead socket can pin.
36
+ QUEUE_DEPTH = 256
37
+
38
+
39
+ # What an org's feed and the box's carry: the lifecycle, never the content of a call.
40
+ ORG_EVENTS = frozenset(
41
+ {
42
+ "agent.registered",
43
+ "agent.detached",
44
+ "call.ringing",
45
+ "call.dialing",
46
+ "call.started",
47
+ "call.ended",
48
+ "attention.requested",
49
+ "attention.answered",
50
+ "supervisor.took_over",
51
+ "supervisor.released",
52
+ }
53
+ )
54
+
55
+
56
+ class Subscription:
57
+ """One live reader's queue; iteration ends when the log closes or the reader fell behind."""
58
+
59
+ def __init__(
60
+ self,
61
+ only: Filter,
62
+ leave: Callable[["Subscription"], None],
63
+ after: Callable[[], None] | None = None,
64
+ ) -> None:
65
+ """Keep the filter, the way out of the fanout, and what to do once out (unfollow)."""
66
+ self.only = only
67
+ self.dropped = False
68
+ self._leave = leave
69
+ self._after = after
70
+ self._ended = False
71
+ self._left = False
72
+ # One slot past the depth for the end marker, so closing a full queue never blocks.
73
+ self._queue: asyncio.Queue[Entry | None] = asyncio.Queue(maxsize=QUEUE_DEPTH + 1)
74
+
75
+ def offer(self, entry: Entry) -> bool:
76
+ """Queue the entry if the filter passes it; False once this reader is gone."""
77
+ if self._ended:
78
+ return False
79
+ if not self.only.passes(entry):
80
+ return True
81
+ if self._queue.qsize() >= QUEUE_DEPTH:
82
+ self.dropped = True
83
+ self._end()
84
+ return False
85
+ self._queue.put_nowait(entry)
86
+ return True
87
+
88
+ def close(self) -> None:
89
+ """End the iteration after what is queued, and leave the fanout."""
90
+ self._end()
91
+ self._leave(self)
92
+ if not self._left:
93
+ self._left = True
94
+ if self._after is not None:
95
+ self._after()
96
+
97
+ def _end(self) -> None:
98
+ if not self._ended:
99
+ self._ended = True
100
+ self._queue.put_nowait(None)
101
+
102
+ def __aiter__(self) -> Self:
103
+ return self
104
+
105
+ async def __anext__(self) -> Entry:
106
+ """Return the next entry in log order; the end marker ends the iteration."""
107
+ entry = await self._queue.get()
108
+ if entry is None:
109
+ raise StopAsyncIteration
110
+ return entry
111
+
112
+
113
+ class Fanout:
114
+ """The live readers of one log or feed; publishing never waits for any of them."""
115
+
116
+ def __init__(self) -> None:
117
+ """Start with no reader."""
118
+ self._readers: set[Subscription] = set()
119
+ self._closed = False
120
+
121
+ @property
122
+ def readers(self) -> int:
123
+ """Return how many readers are subscribed."""
124
+ return len(self._readers)
125
+
126
+ def subscribe(
127
+ self, only: Filter = EVERYTHING, after: Callable[[], None] | None = None
128
+ ) -> Subscription:
129
+ """Return a reader of what is published from now on; on a closed fanout, an ended one."""
130
+ subscription = Subscription(only, self._readers.discard, after)
131
+ if self._closed:
132
+ subscription.close()
133
+ else:
134
+ self._readers.add(subscription)
135
+ return subscription
136
+
137
+ def publish(self, entry: Entry) -> None:
138
+ """Offer the entry to every reader, dropping the ones that fell behind."""
139
+ for reader in tuple(self._readers):
140
+ if not reader.offer(entry):
141
+ reader.close()
142
+
143
+ def close(self) -> None:
144
+ """End every reader after what it was given; a later subscriber gets nothing."""
145
+ self._closed = True
146
+ for reader in tuple(self._readers):
147
+ reader.close()
148
+ self._readers.clear()
149
+
150
+ def drop(self) -> None:
151
+ """End every reader as one that fell behind: each resumes from the store."""
152
+ for reader in tuple(self._readers):
153
+ reader.dropped = True
154
+ reader.close()
155
+ self._readers.clear()
156
+
157
+
158
+ # Readers and the writer share the one Log of a name: a reader often arrives before the writer.
159
+ class Log:
160
+ """One call's log, or one agent's: written here, read live here, replayed from the store."""
161
+
162
+ def __init__(
163
+ self, store: Store, call: str | None, agent: str, relay: Relay | None = None
164
+ ) -> None:
165
+ """Name the log: a call's, or an agent's own when call is None; alone with no relay."""
166
+ self.call = call
167
+ self.agent = agent
168
+ self.name = log_name(call, agent)
169
+ self.fanout = Fanout()
170
+ self.sealed = False
171
+ # Set by Logs.writing: a held log outlives its readers until the process forgets it.
172
+ self.kept_open = False
173
+ # Set by the call's writer: what it masks by. None on a log nobody declared anything for.
174
+ self.privacy: Privacy | None = None
175
+ # The private values of the entries appended here, by seq, for the socket that runs them.
176
+ self.private_values: dict[int, JsonObject] = {}
177
+ self._store = store
178
+ # The clock its entries are stamped with.
179
+ self.clock = store.clock
180
+ # None: a log of this process alone, its readers hearing its own appends only.
181
+ self._relay = relay
182
+ self._taps: list[Tap] = []
183
+ self._snapshot: tuple[int, State] | None = None
184
+
185
+ def tapped(self, tap: Tap) -> None:
186
+ """Run the tap on every append from now on, before append returns."""
187
+ self._taps.append(tap)
188
+
189
+ # Stored before published, so no reader sees an entry the store lacks and every cursor resumes.
190
+ # A private value is sealed aside before the entry is published: a tool call whose seal broke
191
+ # never reached the app, so the worker's retry runs the tool once.
192
+ async def append(
193
+ self, kind: str, data: JsonObject | None = None, *, ephemeral: bool | None = None
194
+ ) -> Entry:
195
+ """Write the entry, its private values masked, publish it, run the taps, seal at the end."""
196
+ forgettable = kind in EPHEMERAL_EVENTS if ephemeral is None else ephemeral
197
+ written = self._split(kind, data or {})
198
+ entry = await self._store.append(
199
+ self.call, self.agent, kind, written.kept, ephemeral=forgettable
200
+ )
201
+ await self._sealed_aside(self._kept_open([(entry, written)]))
202
+ await self._published(entry)
203
+ return entry
204
+
205
+ # Two writers deciding the same end (a webhook delivered twice) write it once.
206
+ async def append_first(self, kind: str, data: JsonObject) -> Entry | None:
207
+ """Write and publish a durable entry unless the log holds one of its type; None then."""
208
+ if self.call is None:
209
+ raise DeclarationRefused(f"agent {self.agent}: only a call's log is looked at")
210
+ written = self._split(kind, data)
211
+ entry = await self._store.append_first(self.call, self.agent, kind, written.kept)
212
+ if entry is not None:
213
+ await self._sealed_aside(self._kept_open([(entry, written)]))
214
+ await self._published(entry)
215
+ return entry
216
+
217
+ # Once per call id, whichever gateway writes it: the app's answer where its socket is, or the
218
+ # timeout where the worker asked.
219
+ async def answer(self, data: JsonObject) -> Entry | None:
220
+ """Write and publish a tool.result unless the log holds its answer already; None then."""
221
+ if self.call is None:
222
+ raise DeclarationRefused(f"agent {self.agent}: only a call's log asks tools")
223
+ written = self._split("tool.result", data)
224
+ entry = await self._store.append_answer(self.call, self.agent, written.kept)
225
+ if entry is not None:
226
+ await self._sealed_aside(self._kept_open([(entry, written)]))
227
+ await self._published(entry)
228
+ return entry
229
+
230
+ # An entry stored by a gateway that may have died before telling the others: told again. A
231
+ # reader that heard it already skips it by its seq.
232
+ def told_again(self, entry: Entry) -> None:
233
+ """Publish a stored entry once more to every gateway's readers."""
234
+ if self._relay is None:
235
+ self.fanout.publish(entry)
236
+ else:
237
+ self._relay.local(CHANNEL.format(name=self.name), entry)
238
+
239
+ # A replayed batch was published when it was first taken: publishing it again would repeat it.
240
+ # Its private values are sealed aside after it is published and again on a replay, which keeps
241
+ # the ones kept already: a batch whose seal broke is kept whole by the worker's retry.
242
+ async def append_many(self, entries: Sequence[BatchedEntry], *, after: int) -> list[Entry]:
243
+ """Write a worker's batch once, then publish and tap each entry in order as append does."""
244
+ written = [self._split(item.type, item.data) for item in entries]
245
+ unnumbered = [
246
+ Unnumbered(
247
+ type=item.type,
248
+ data=kept.kept,
249
+ ephemeral=item.type in EPHEMERAL_EVENTS
250
+ if item.ephemeral is None
251
+ else item.ephemeral,
252
+ ts=item.ts,
253
+ )
254
+ for item, kept in zip(entries, written, strict=True)
255
+ ]
256
+ batch = await self._store.append_many(self.call, self.agent, unnumbered, after=after)
257
+ aside = self._kept_open(zip(batch.entries, written, strict=True))
258
+ if not batch.replayed:
259
+ for entry in batch.entries:
260
+ await self._published(entry)
261
+ await self._sealed_aside(aside)
262
+ return batch.entries
263
+
264
+ def opened(self, entry: Entry) -> Entry:
265
+ """The entry as its writer wrote it, for the app's socket: the private values back in."""
266
+ return private.restored(entry, self.private_values.get(entry.seq))
267
+
268
+ async def whole(self) -> list[Entry]:
269
+ """Every entry of the log as its writer wrote it, the private values sealed aside opened."""
270
+ if self.privacy is None:
271
+ return await self._store.whole(self.name)
272
+ return await private.opened_whole(self._store, self.privacy.vault, self.name)
273
+
274
+ async def seal(self) -> None:
275
+ """Seal the call's log in the store and end every live reader."""
276
+ if self.call is None:
277
+ raise DeclarationRefused(f"agent {self.agent}: an agent's log has no end")
278
+ self.sealed = True
279
+ await self._store.seal(self.call)
280
+ self.fanout.close()
281
+
282
+ # Compared against the store's seq, ephemerals counted, or a live call never hits the memo.
283
+ async def snapshot(self) -> State:
284
+ """Return the state the log reduces to, folded again only when the log moved."""
285
+ latest = await self._store.latest_seq(self.name)
286
+ if self._snapshot is None or self._snapshot[0] != latest:
287
+ self._snapshot = latest, reduce(await self._store.whole(self.name))
288
+ return self._snapshot[1]
289
+
290
+ # Followed before the fanout is subscribed, so an entry another gateway commits after the
291
+ # backlog is read was published after the subscription was confirmed, and is heard.
292
+ async def followed(self, only: Filter = EVERYTHING) -> Subscription:
293
+ """A live reader that hears what any gateway writes to this log, from now on."""
294
+ if self._relay is None:
295
+ return self.fanout.subscribe(only)
296
+ await self._relay.follow(CHANNEL.format(name=self.name))
297
+ return self.fanout.subscribe(only, after=self._unfollowed)
298
+
299
+ async def stream(self, *, after: int = 0, only: Filter = EVERYTHING) -> AsyncIterator[Entry]:
300
+ """Yield the backlog above the cursor, log.caught_up, then what is written from then on."""
301
+ cursor = after
302
+ while True:
303
+ # Subscribed before the backlog is read, so nothing written between the two is lost.
304
+ subscription = await self.followed(only)
305
+ try:
306
+ # Asked before the backlog: a log sealed by then is whole in it, and one sealed
307
+ # after ends its readers here, once they drained what they were given.
308
+ over = self.sealed or await self._store.sealed(self.name)
309
+ for entry in await self._store.whole(self.name, after=cursor):
310
+ cursor = entry.seq
311
+ if only.passes(entry):
312
+ yield entry
313
+ yield self._marker(cursor, "log.caught_up", {"seq": cursor})
314
+ if over:
315
+ return
316
+ async for entry in subscription:
317
+ if entry.seq > cursor:
318
+ cursor = entry.seq
319
+ yield entry
320
+ if not subscription.dropped:
321
+ return
322
+ finally:
323
+ subscription.close()
324
+ # Fell behind: a gap says what was missed and, for a call, carries the state instead
325
+ # of the entries. An agent's log reduces to no state, so its gap re-reads the store.
326
+ if self.call is None:
327
+ yield self._marker(
328
+ cursor, "log.gap", {"from_seq": cursor + 1, "to_seq": cursor, "snapshot": None}
329
+ )
330
+ continue
331
+ to_seq = max(await self._store.latest_seq(self.name), cursor)
332
+ snapshot = (await self.snapshot()).written()
333
+ yield self._marker(
334
+ to_seq, "log.gap", {"from_seq": cursor + 1, "to_seq": to_seq, "snapshot": snapshot}
335
+ )
336
+ cursor = to_seq
337
+
338
+ def _split(self, kind: str, data: JsonObject) -> private.Split:
339
+ if self.privacy is None:
340
+ return private.Split(data, {})
341
+ return private.split(kind, data, self.privacy.config)
342
+
343
+ def _kept_open(
344
+ self, written: Iterable[tuple[Entry, private.Split]]
345
+ ) -> list[tuple[int, JsonObject]]:
346
+ aside = [(entry.seq, split.private) for entry, split in written if split.private]
347
+ self.private_values.update(aside)
348
+ return aside
349
+
350
+ async def _sealed_aside(self, aside: list[tuple[int, JsonObject]]) -> None:
351
+ if self.privacy is not None:
352
+ await private.kept_aside(self._store, self.privacy.vault, self.name, aside)
353
+
354
+ def _unfollowed(self) -> None:
355
+ if self._relay is not None:
356
+ self._relay.unfollow(CHANNEL.format(name=self.name))
357
+
358
+ async def _published(self, entry: Entry) -> None:
359
+ if self._relay is None:
360
+ self.fanout.publish(entry)
361
+ else:
362
+ self._relay.local(CHANNEL.format(name=self.name), entry)
363
+ for tap in list(self._taps):
364
+ await tap(entry)
365
+ if entry.type == TERMINAL_EVENT and self.call is not None:
366
+ await self.seal()
367
+
368
+ def _marker(self, seq: int, kind: str, data: JsonObject) -> Entry:
369
+ return Entry(
370
+ seq=seq,
371
+ ts=time.time(),
372
+ call=self.call,
373
+ agent=self.agent,
374
+ type=kind,
375
+ ephemeral=True,
376
+ data=data,
377
+ )
378
+
379
+
380
+ class Logs:
381
+ """The logs of this process by name, the org feeds and the box's, and who owns what."""
382
+
383
+ def __init__(self, store: Store, signal: Signal | None = None) -> None:
384
+ """Start with no log open and no feed listened to; alone with no signal."""
385
+ self.store = store
386
+ self.relay = Relay(store, signal or LocalSignal(), self._delivered, self._dropped)
387
+ self.box = Fanout()
388
+ self._logs: dict[str, Log] = {}
389
+ self._feeds: dict[tuple[str, Env], Fanout] = {}
390
+ # A log's owner never changes; caching it keeps a store read off the append path.
391
+ self._owners: dict[str, Claimant] = {}
392
+
393
+ @property
394
+ def readers(self) -> int:
395
+ """How many live readers every log and feed of this process holds."""
396
+ fanouts = [self.box, *self._feeds.values(), *(log.fanout for log in self._logs.values())]
397
+ return sum(fanout.readers for fanout in fanouts)
398
+
399
+ def writing(self, call: str, agent: str) -> Log:
400
+ """Return the call's log for its writer, held until the process forgets the call."""
401
+ log = self._log(call, agent)
402
+ log.agent = agent
403
+ if not log.kept_open:
404
+ log.kept_open = True
405
+ log.tapped(self._fed)
406
+ return log
407
+
408
+ def reading(self, call: str) -> Log:
409
+ """Return the call's log for a reader, whoever writes it."""
410
+ self._prune(but=call)
411
+ return self._log(call, "")
412
+
413
+ def agent(self, slug: str) -> Log:
414
+ """Return the agent's own log, for writing or reading."""
415
+ self._prune(but=log_name(None, slug))
416
+ log = self._log(None, slug)
417
+ if not log.kept_open:
418
+ log.kept_open = True
419
+ log.tapped(self._fed)
420
+ return log
421
+
422
+ def opened(self, call: str) -> Log | None:
423
+ """Return the call's log if this process writes it."""
424
+ log = self._logs.get(call)
425
+ return log if log is not None and log.kept_open else None
426
+
427
+ def forget(self, call: str) -> None:
428
+ """Drop the call's log, its readers and its cached owner."""
429
+ self._logs.pop(call, None)
430
+ self._owners.pop(call, None)
431
+
432
+ async def close(self) -> None:
433
+ """Stop following every log."""
434
+ await self.relay.close()
435
+
436
+ def feed(self, org: str, env: Env) -> Fanout:
437
+ """Return the org's feed in that world: the lifecycle entries of every log it owns there."""
438
+ self._prune()
439
+ feed = self._feeds.get((org, env))
440
+ if feed is None:
441
+ feed = self._feeds[(org, env)] = Fanout()
442
+ return feed
443
+
444
+ # Followed before it is subscribed, as a log's stream is: what any gateway writes from now on.
445
+ async def feed_reader(self, org: str, env: Env) -> Subscription:
446
+ """A reader of the org's feed in that world, hearing every gateway."""
447
+ channel = FEED_CHANNEL.format(org=org, env=env)
448
+ await self.relay.follow(channel, ordered=False)
449
+ return self.feed(org, env).subscribe(after=lambda: self.relay.unfollow(channel))
450
+
451
+ async def box_reader(self) -> Subscription:
452
+ """A reader of every org's floor, hearing every gateway."""
453
+ await self.relay.follow(BOX_CHANNEL, ordered=False)
454
+ return self.box.subscribe(after=lambda: self.relay.unfollow(BOX_CHANNEL))
455
+
456
+ # Only a found owner is cached: a log nobody claimed yet may be claimed later.
457
+ async def claimant_of(self, entry: Entry) -> Claimant | None:
458
+ """Return whose the entry's log is and in which world, asked of the store once per log."""
459
+ name = log_name(entry.call, entry.agent)
460
+ found = self._owners.get(name)
461
+ if found is None:
462
+ found = await self.store.claimant(entry.call, entry.agent)
463
+ if found is not None:
464
+ self._owners[name] = found
465
+ return found
466
+
467
+ def _log(self, call: str | None, agent: str) -> Log:
468
+ name = log_name(call, agent)
469
+ log = self._logs.get(name)
470
+ if log is None:
471
+ log = self._logs[name] = Log(self.store, call, agent, self.relay)
472
+ return log
473
+
474
+ # What the relay hands on, this process's own appends included: a log's readers hear it, and
475
+ # the terminal entry ends them, sealed here or on another gateway; a feed's hear it as it came.
476
+ def _delivered(self, channel: str, entry: Entry) -> None:
477
+ fanout = self._fanout_of(channel)
478
+ if fanout is None:
479
+ return
480
+ fanout.publish(entry)
481
+ log = self._logs.get(log_name(entry.call, entry.agent))
482
+ if entry.type == TERMINAL_EVENT and log is not None and fanout is log.fanout:
483
+ log.sealed = log.call is not None
484
+ if log.sealed:
485
+ fanout.close()
486
+
487
+ def _dropped(self, channel: str) -> None:
488
+ fanout = self._fanout_of(channel)
489
+ if fanout is not None:
490
+ fanout.drop()
491
+
492
+ def _fanout_of(self, channel: str) -> Fanout | None:
493
+ if channel == BOX_CHANNEL:
494
+ return self.box
495
+ if channel.startswith(FEED_PREFIX):
496
+ org, _, env = channel.removeprefix(FEED_PREFIX).rpartition(":")
497
+ return self._feeds.get((org, parse_env(env)))
498
+ log = self._logs.get(channel.removeprefix(LOG_PREFIX))
499
+ return None if log is None else log.fanout
500
+
501
+ # An agent's own entries serve both worlds, so they reach the org's feed in each.
502
+ async def _fed(self, entry: Entry) -> None:
503
+ if entry.type not in ORG_EVENTS:
504
+ return
505
+ claimant = await self.claimant_of(entry)
506
+ if claimant is None:
507
+ return
508
+ self.relay.local(BOX_CHANNEL, entry)
509
+ worlds = ENVS if claimant.env is None else (claimant.env,)
510
+ for env in worlds:
511
+ self.relay.local(FEED_CHANNEL.format(org=claimant.org, env=env), entry)
512
+
513
+ # On every read, so readers asking for arbitrary names cannot grow the tables without bound.
514
+ def _prune(self, *, but: str = "") -> None:
515
+ idle = [
516
+ name
517
+ for name, log in self._logs.items()
518
+ if name != but and not log.kept_open and not log.fanout.readers
519
+ ]
520
+ for name in idle:
521
+ del self._logs[name]
522
+ for key in [k for k, feed in self._feeds.items() if not feed.readers]:
523
+ del self._feeds[key]
524
+
525
+
526
+ # The caller is always the far end: on an outbound call that is the `to`.
527
+ def arrival_entry(
528
+ context: CallContext, door: str, *, asked_by: str | None = None
529
+ ) -> tuple[str, JsonObject]:
530
+ """Return a call's first entry: call.dialing when placed, call.ringing when offered."""
531
+ from_, to = _two_ends(context, door)
532
+ data: JsonObject = {
533
+ "channel": context.channel,
534
+ "from": from_,
535
+ "to": to,
536
+ "run": context.run,
537
+ "caller": None,
538
+ }
539
+ if context.direction == "outbound":
540
+ # The one record of who placed an outbound call, which is what audits dial spend.
541
+ dialing = CallDialing.read({**data, "asked_by": asked_by}, "call.dialing")
542
+ return "call.dialing", dialing.written()
543
+ route = Route(channel=context.route.channel, number=context.route.number)
544
+ ringing = CallRinging.read({**data, "route": route.written()}, "call.ringing")
545
+ return "call.ringing", ringing.written()
546
+
547
+
548
+ def started_entry(
549
+ context: CallContext, door: str, at: float, worker: str | None = None, *, medium: Medium
550
+ ) -> JsonObject:
551
+ """Return the call.started entry, written once media is up: spoken or written, and by whom."""
552
+ from_, to = _two_ends(context, door)
553
+ ran_by: JsonObject = {} if worker is None else {"worker": worker}
554
+ opened_in: JsonObject = {"state": dict(context.state)} if context.state else {}
555
+ started = CallStarted.read(
556
+ {
557
+ "channel": context.channel,
558
+ "direction": context.direction,
559
+ "from": from_,
560
+ "to": to,
561
+ "run": context.run,
562
+ # The persona and its rules, frozen here for the facts and the persona judge.
563
+ "persona": context.persona,
564
+ "accepts_when": context.accepts_when,
565
+ "declines_when": context.declines_when,
566
+ "caller": None,
567
+ "started_at": at,
568
+ "env": context.env,
569
+ "medium": medium,
570
+ **ran_by,
571
+ **opened_in,
572
+ },
573
+ "call.started",
574
+ )
575
+ return started.written()
576
+
577
+
578
+ def _two_ends(context: CallContext, door: str) -> tuple[str, str]:
579
+ if context.direction == "outbound":
580
+ return door, context.caller
581
+ return context.caller, door
@@ -0,0 +1,71 @@
1
+ """What a call is, kept at its open: its context and its config, for any gateway to serve it."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from hashlib import sha256
6
+
7
+ from psycopg.types.json import Jsonb
8
+ from pydantic import TypeAdapter
9
+
10
+ from pinecall.domain.agent import AgentConfig
11
+ from pinecall.domain.call import CallContext
12
+ from pinecall.postgres.pool import Pool
13
+
14
+ KEPT_CONFIG = """
15
+ INSERT INTO call_configs (org, hash, config) VALUES (%(org)s, %(hash)s, %(config)s)
16
+ ON CONFLICT (org, hash) DO NOTHING
17
+ """
18
+
19
+ # The first opening stands: a reopen of the same call says it again and changes nothing.
20
+ KEPT_OPENING = """
21
+ INSERT INTO call_openings (call, org, context, config_hash)
22
+ VALUES (%(call)s, %(org)s, %(context)s, %(hash)s)
23
+ ON CONFLICT (call) DO NOTHING
24
+ """
25
+
26
+ OPENING = """
27
+ SELECT opening.context, config.config
28
+ FROM call_openings opening
29
+ JOIN call_configs config ON config.org = opening.org AND config.hash = opening.config_hash
30
+ WHERE opening.call = %(call)s
31
+ """
32
+
33
+ _CONTEXT: TypeAdapter[CallContext] = TypeAdapter(CallContext)
34
+ _CONFIG: TypeAdapter[AgentConfig] = TypeAdapter(AgentConfig)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Opening:
39
+ """A call as it opened: what the worker said of it, and the config it runs on."""
40
+
41
+ context: CallContext
42
+ config: AgentConfig
43
+
44
+
45
+ async def kept(pool: Pool, org: str, context: CallContext, config: AgentConfig) -> None:
46
+ """Keep what the call is, in one transaction: its config once per hash, its opening once."""
47
+ written = _CONFIG.dump_python(config, mode="json")
48
+ hashed = sha256(json.dumps(written, sort_keys=True).encode()).hexdigest()
49
+ opening = {
50
+ "call": context.call,
51
+ "org": org,
52
+ "context": Jsonb(_CONTEXT.dump_python(context, mode="json")),
53
+ "hash": hashed,
54
+ }
55
+ async with pool.connection() as connection, connection.transaction():
56
+ await connection.execute(
57
+ KEPT_CONFIG, {"org": org, "hash": hashed, "config": Jsonb(written)}
58
+ )
59
+ await connection.execute(KEPT_OPENING, opening)
60
+
61
+
62
+ async def opening_of(pool: Pool, call: str) -> Opening | None:
63
+ """What the call is, as it was kept when it opened; None for one opened before it was kept."""
64
+ async with pool.connection() as connection:
65
+ row = await (await connection.execute(OPENING, {"call": call})).fetchone()
66
+ if row is None:
67
+ return None
68
+ return Opening(
69
+ context=_CONTEXT.validate_python(row["context"]),
70
+ config=_CONFIG.validate_python(row["config"]),
71
+ )