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,475 @@
1
+ """What the box hosts for an org: an app with the token its process runs on, and its releases."""
2
+
3
+ import hashlib
4
+ import io
5
+ import re
6
+ import tarfile
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+
10
+ from cryptography.fernet import MultiFernet
11
+ from psycopg.rows import DictRow
12
+
13
+ from pinecall.domain.errors import DeclarationRefused, NotFound
14
+ from pinecall.domain.names import Env, parse_slug
15
+ from pinecall.domain.person import SERVER_SCOPES
16
+ from pinecall.postgres.pool import Pool
17
+ from pinecall.tenancy import keys
18
+ from pinecall.tenancy.people import fingerprint
19
+ from pinecall.tenancy.vault import opened, sealed
20
+
21
+ # The ceilings of one upload: what travels, what it unpacks to, and how many files.
22
+ LARGEST_SOURCE = 10 * 1024 * 1024
23
+
24
+
25
+ LARGEST_UNPACKED = 100 * 1024 * 1024
26
+
27
+
28
+ MOST_FILES = 5000
29
+
30
+
31
+ # A host name is 63 characters at most, and an app's name is part of it whole.
32
+ LONGEST_NAME = 40
33
+
34
+
35
+ STAMP = 8
36
+
37
+
38
+ # How long after a person asks for an app's logs the runner is told to send them.
39
+ LOGS_FOR_S = 60
40
+
41
+
42
+ TOO_BIG = "a release's sources are {limit} MB at most packed"
43
+
44
+
45
+ TOO_LONG_A_NAME = "an app's name is {limit} characters at most, and {name} is {length}"
46
+
47
+
48
+ NOT_A_TARBALL = "a release is a gzipped tarball of the project's sources, and this is not one"
49
+
50
+
51
+ UNPACKS_TOO_BIG = "a release unpacks to {limit} MB at most, and this one passes it"
52
+
53
+
54
+ TOO_MANY_FILES = "a release holds {limit} files at most: leave node_modules and build output out"
55
+
56
+
57
+ NOT_A_FILE = "{path} is a link or a device: a release holds files and folders only"
58
+
59
+
60
+ LEAVES_THE_PROJECT = "{path} leaves the project's folder: every path of a release stays inside it"
61
+
62
+
63
+ NOT_HOSTED = "the box hosts no app called {name} for this org in {env}"
64
+
65
+
66
+ NO_RELEASE = "{name} has no release {release}"
67
+
68
+
69
+ LABEL = "hosted app {name}"
70
+
71
+
72
+ # One row per app with a release: the newest, and a stamp of the org's secrets in that world,
73
+ # which is what its host name is made of. An org's list leaves its other orgs out.
74
+ STATUS = """
75
+ SELECT app.org, app.env, app.name, app.created_by, app.created_at, app.live_release,
76
+ app.live_host, app.failed_host, app.failed_why, newest.release, newest.sha256,
77
+ app.stopped_at,
78
+ app.logs_asked_at > now() - make_interval(secs => %(logs_for_s)s) AS logs_wanted,
79
+ (SELECT coalesce(string_agg(name || '@' || extract(epoch FROM set_at), ','
80
+ ORDER BY name), '')
81
+ FROM org_secrets WHERE org = app.org AND env = app.env) AS secrets
82
+ FROM hosted_apps app
83
+ LEFT JOIN LATERAL (
84
+ SELECT release, sha256 FROM hosted_releases
85
+ WHERE org = app.org AND env = app.env AND name = app.name ORDER BY release DESC LIMIT 1
86
+ ) newest ON true
87
+ WHERE app.env = %(env)s AND (%(org)s::text IS NULL OR app.org = %(org)s)
88
+ ORDER BY app.org, app.name
89
+ """
90
+
91
+
92
+ IS_HOSTED = "SELECT 1 FROM hosted_apps WHERE org = %(org)s AND env = %(env)s AND name = %(name)s"
93
+
94
+
95
+ # Two uploads of a new app at once: the second insert writes nothing and its token is revoked.
96
+ OPEN_APP = """
97
+ INSERT INTO hosted_apps (org, env, name, key_fingerprint, sealed_key, created_by)
98
+ VALUES (%(org)s, %(env)s, %(name)s, %(key_fingerprint)s, %(sealed_key)s, %(created_by)s)
99
+ ON CONFLICT (org, env, name) DO NOTHING RETURNING name
100
+ """
101
+
102
+
103
+ # The app's row is locked first, so two uploads at once are two releases, never one number twice.
104
+ LOCK_APP = """
105
+ SELECT 1 FROM hosted_apps WHERE org = %(org)s AND env = %(env)s AND name = %(name)s FOR UPDATE
106
+ """
107
+
108
+
109
+ KEEP_RELEASE = """
110
+ INSERT INTO hosted_releases (org, env, name, release, source, sha256, bytes, author, note)
111
+ SELECT %(org)s, %(env)s, %(name)s, coalesce(max(release), 0) + 1, %(source)s, %(sha256)s,
112
+ %(bytes)s, %(author)s, %(note)s
113
+ FROM hosted_releases WHERE org = %(org)s AND env = %(env)s AND name = %(name)s
114
+ RETURNING release, sha256, bytes, author, note, created_at
115
+ """
116
+
117
+
118
+ # Each release is its whole tarball: an app keeps its newest ten, and the one its runner serves
119
+ # while a newer one starts, never more.
120
+ KEPT_RELEASES = 10
121
+
122
+
123
+ PRUNED = """
124
+ DELETE FROM hosted_releases
125
+ WHERE org = %(org)s AND env = %(env)s AND name = %(name)s AND release <= %(newest)s - %(kept)s
126
+ AND release IS DISTINCT FROM (
127
+ SELECT live_release FROM hosted_apps WHERE org = %(org)s AND env = %(env)s AND name = %(name)s
128
+ )
129
+ """
130
+
131
+
132
+ RELEASES = """
133
+ SELECT release, sha256, bytes, author, note, created_at FROM hosted_releases
134
+ WHERE org = %(org)s AND env = %(env)s AND name = %(name)s ORDER BY release DESC
135
+ """
136
+
137
+
138
+ SOURCE = """
139
+ SELECT source, sha256 FROM hosted_releases
140
+ WHERE org = %(org)s AND env = %(env)s AND name = %(name)s AND release = %(release)s
141
+ """
142
+
143
+
144
+ SEALED_KEY = """
145
+ SELECT sealed_key FROM hosted_apps WHERE org = %(org)s AND env = %(env)s AND name = %(name)s
146
+ """
147
+
148
+
149
+ WENT_LIVE = """
150
+ UPDATE hosted_apps SET live_release = %(release)s, live_host = %(host)s,
151
+ reported_by = %(runner)s, reported_at = now()
152
+ WHERE org = %(org)s AND env = %(env)s AND name = %(name)s
153
+ """
154
+
155
+
156
+ FAILED = """
157
+ UPDATE hosted_apps SET failed_host = %(host)s, failed_why = %(why)s,
158
+ reported_by = %(runner)s, reported_at = now()
159
+ WHERE org = %(org)s AND env = %(env)s AND name = %(name)s
160
+ """
161
+
162
+
163
+ DROP_APP = """
164
+ DELETE FROM hosted_apps WHERE org = %(org)s AND env = %(env)s AND name = %(name)s
165
+ RETURNING key_fingerprint
166
+ """
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class HostedApp:
171
+ """Which app: the org it is hosted for, the world it runs in, and its name."""
172
+
173
+ org: str
174
+ env: Env
175
+ name: str
176
+
177
+ @property
178
+ def columns(self) -> dict[str, str]:
179
+ """The three columns every row of the app carries."""
180
+ return {"org": self.org, "env": self.env, "name": self.name}
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class AppStatus:
185
+ """A hosted app as the runner left it: its newest release, what serves it, and what failed."""
186
+
187
+ org: str
188
+ name: str
189
+ created_by: str
190
+ created_at: datetime
191
+ # None before the first release; the rest of the newest release is None with it.
192
+ release: int | None
193
+ sha256: str | None
194
+ # The name the release's process runs under: one release under one set of secrets.
195
+ host: str | None
196
+ live_release: int | None
197
+ # Why the wanted host failed to build or start; None when it did not, or was never tried.
198
+ failed_why: str | None
199
+ # A person stopped it: the runner is not told to run it, and its releases stay.
200
+ stopped: bool = False
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class Runnable:
205
+ """An app a runner is to run: its newest release, the host it runs under, and what serves."""
206
+
207
+ org: str
208
+ name: str
209
+ release: int
210
+ sha256: str
211
+ host: str
212
+ # The host was reported failed: the runner leaves it alone.
213
+ failed: bool
214
+ logs_wanted: bool
215
+ live_host: str | None
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class Source:
220
+ """One upload, checked: the tarball as it came, and its sha256."""
221
+
222
+ data: bytes
223
+ sha256: str
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class Release:
228
+ """One upload of an app's sources as it is kept: its number, and who sent it and why."""
229
+
230
+ release: int
231
+ sha256: str
232
+ bytes: int
233
+ author: str
234
+ note: str
235
+ created_at: datetime
236
+
237
+
238
+ # <name>-r<release>-<stamp>: the release is read back off it, so a report names nothing else.
239
+ A_HOST = re.compile(rf"^(?P<name>[a-z0-9-]+)-r(?P<release>[1-9][0-9]*)-[0-9a-f]{{{STAMP}}}$")
240
+
241
+
242
+ def checked_name(name: str) -> str:
243
+ """The name as an app's: a slug short enough to be part of a host name."""
244
+ if len(parse_slug(name)) > LONGEST_NAME:
245
+ refusal = TOO_LONG_A_NAME.format(limit=LONGEST_NAME, name=name, length=len(name))
246
+ raise DeclarationRefused(refusal)
247
+ return name
248
+
249
+
250
+ # What the process calls its machine, which is what the gateway's list of processes shows, and the
251
+ # name of its container on a machine every org's apps share: the stamp is of the org, the world and
252
+ # the app too, so two orgs' apps of one name and one source never meet, and of the org's secrets,
253
+ # so a changed secret is a new host.
254
+ def host_of(app: HostedApp, release: int, sha256: str, secrets: str) -> str:
255
+ """The host name one release of the app runs under, with the org's secrets as they are."""
256
+ stamped = f"{app.org}:{app.env}:{app.name}:{release}:{sha256}:{secrets}"
257
+ return f"{app.name}-r{release}-{hashlib.sha256(stamped.encode()).hexdigest()[:STAMP]}"
258
+
259
+
260
+ def is_a_host_of(name: str, host: str) -> bool:
261
+ """Whether the host is one a release of the app runs under, whichever release."""
262
+ named = A_HOST.match(host)
263
+ return named is not None and named["name"] == name
264
+
265
+
266
+ def release_of(host: str) -> int | None:
267
+ """The release a host runs, read off its name; None for a name that is no host's."""
268
+ named = A_HOST.match(host)
269
+ return None if named is None else int(named["release"])
270
+
271
+
272
+ # Read here, before it is kept, so whoever unpacks a release later unpacks nothing but files
273
+ # and folders under the project's own folder.
274
+ def checked_source(data: bytes) -> Source:
275
+ """The upload as a release's sources; DeclarationRefused for anything but a safe tarball."""
276
+ if len(data) > LARGEST_SOURCE:
277
+ raise DeclarationRefused(TOO_BIG.format(limit=LARGEST_SOURCE >> 20))
278
+ try:
279
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tarball:
280
+ _check_members(tarball)
281
+ except (tarfile.TarError, OSError, EOFError) as unreadable:
282
+ raise DeclarationRefused(NOT_A_TARBALL) from unreadable
283
+ return Source(data=data, sha256=hashlib.sha256(data).hexdigest())
284
+
285
+
286
+ async def apps_of(pool: Pool, org: str, env: Env) -> list[AppStatus]:
287
+ """The apps the box hosts for the org in the world, by name, stopped ones too."""
288
+ return [_status(row) for row in await _rows(pool, org, env)]
289
+
290
+
291
+ # Every org's: what the world's runner is told to have running. A stopped app is left out, and a
292
+ # runner stops what it is not told to run; one with no release yet has nothing to run.
293
+ async def hosted_in(pool: Pool, env: Env) -> list[Runnable]:
294
+ """Every app the box runs in the world, by org and name."""
295
+ return [
296
+ _runnable(row)
297
+ for row in await _rows(pool, None, env)
298
+ if row["stopped_at"] is None and row["release"] is not None
299
+ ]
300
+
301
+
302
+ async def is_hosted(pool: Pool, app: HostedApp) -> bool:
303
+ """Whether the box already hosts the app."""
304
+ async with pool.connection() as connection:
305
+ return await (await connection.execute(IS_HOSTED, app.columns)).fetchone() is not None
306
+
307
+
308
+ # The token is the org's and the world's, a server's: what `pinecall start` would be given by hand.
309
+ async def open_app(pool: Pool, vault: MultiFernet, app: HostedApp, *, created_by: str) -> None:
310
+ """Start hosting the app: a server's token minted for its process, kept sealed."""
311
+ issued = keys.Issued(
312
+ org=app.org,
313
+ env=app.env,
314
+ scopes=SERVER_SCOPES,
315
+ label=LABEL.format(name=app.name),
316
+ created_by=created_by,
317
+ )
318
+ _, secret = await keys.issue(pool, issued)
319
+ values = {
320
+ **app.columns,
321
+ "key_fingerprint": fingerprint(secret),
322
+ "sealed_key": sealed(vault, secret),
323
+ "created_by": created_by,
324
+ }
325
+ async with pool.connection() as connection:
326
+ opened = await (await connection.execute(OPEN_APP, values)).fetchone()
327
+ if opened is None:
328
+ await keys.revoke(pool, fingerprint(secret))
329
+
330
+
331
+ async def keep_release(
332
+ pool: Pool, app: HostedApp, source: Source, *, author: str, note: str
333
+ ) -> Release:
334
+ """Keep the upload as the next release, the oldest past ten let go; NotFound if not hosted."""
335
+ values = {
336
+ **app.columns,
337
+ "source": source.data,
338
+ "sha256": source.sha256,
339
+ "bytes": len(source.data),
340
+ "author": author,
341
+ "note": note,
342
+ }
343
+ async with pool.connection() as connection, connection.transaction():
344
+ locked = await (await connection.execute(LOCK_APP, app.columns)).fetchone()
345
+ kept = None if locked is None else await connection.execute(KEEP_RELEASE, values)
346
+ row = None if kept is None else await kept.fetchone()
347
+ if row is not None:
348
+ newest = {**app.columns, "newest": row["release"], "kept": KEPT_RELEASES}
349
+ await connection.execute(PRUNED, newest)
350
+ if row is None:
351
+ raise NotFound(NOT_HOSTED.format(name=app.name, env=app.env))
352
+ return _release(row)
353
+
354
+
355
+ async def releases_of(pool: Pool, app: HostedApp) -> list[Release]:
356
+ """The app's releases, newest first; NotFound for an app the box does not host."""
357
+ # independent: the app looked for, then its releases read
358
+ async with pool.connection() as connection:
359
+ if await (await connection.execute(IS_HOSTED, app.columns)).fetchone() is None:
360
+ raise NotFound(NOT_HOSTED.format(name=app.name, env=app.env))
361
+ rows = await (await connection.execute(RELEASES, app.columns)).fetchall()
362
+ return [_release(row) for row in rows]
363
+
364
+
365
+ # Checked when it was kept: what comes back is a release's sources as they are.
366
+ async def source_of(pool: Pool, app: HostedApp, release: int) -> Source:
367
+ """The tarball one release was uploaded as; NotFound for a release nobody sent."""
368
+ async with pool.connection() as connection:
369
+ found = await connection.execute(SOURCE, {**app.columns, "release": release})
370
+ row = await found.fetchone()
371
+ if row is None:
372
+ raise NotFound(NO_RELEASE.format(name=app.name, release=release))
373
+ return Source(data=bytes(row["source"]), sha256=row["sha256"])
374
+
375
+
376
+ async def key_of(pool: Pool, vault: MultiFernet, app: HostedApp) -> str:
377
+ """The server's token the app's process runs on, opened; NotFound for an app not hosted."""
378
+ async with pool.connection() as connection:
379
+ row = await (await connection.execute(SEALED_KEY, app.columns)).fetchone()
380
+ secret = None if row is None else opened(vault, row["sealed_key"])
381
+ if not isinstance(secret, str):
382
+ raise NotFound(NOT_HOSTED.format(name=app.name, env=app.env))
383
+ return secret
384
+
385
+
386
+ # A report names its host, and a host names its release: one about a release since replaced is
387
+ # kept as what it is, and says nothing of the release wanted now.
388
+ async def went_live(pool: Pool, app: HostedApp, host: str, *, runner: str) -> None:
389
+ """Record that the host serves the app now, and with it the release it runs."""
390
+ values = {**app.columns, "release": release_of(host), "host": host, "runner": runner}
391
+ async with pool.connection() as connection:
392
+ await connection.execute(WENT_LIVE, values)
393
+
394
+
395
+ async def failed(pool: Pool, app: HostedApp, host: str, *, why: str, runner: str) -> None:
396
+ """Record that the host did not build, start or stay up, and why."""
397
+ values = {**app.columns, "host": host, "why": why, "runner": runner}
398
+ async with pool.connection() as connection:
399
+ await connection.execute(FAILED, values)
400
+
401
+
402
+ async def drop_app(pool: Pool, app: HostedApp) -> None:
403
+ """Stop hosting the app: its releases go, and its token opens nothing from the next request."""
404
+ async with pool.connection() as connection:
405
+ row = await (await connection.execute(DROP_APP, app.columns)).fetchone()
406
+ if row is None:
407
+ raise NotFound(NOT_HOSTED.format(name=app.name, env=app.env))
408
+ await keys.revoke(pool, row["key_fingerprint"])
409
+
410
+
411
+ async def _rows(pool: Pool, org: str | None, env: Env) -> list[DictRow]:
412
+ values = {"org": org, "env": env, "logs_for_s": LOGS_FOR_S}
413
+ async with pool.connection() as connection:
414
+ return await (await connection.execute(STATUS, values)).fetchall()
415
+
416
+
417
+ def _check_members(tarball: tarfile.TarFile) -> None:
418
+ unpacked = 0
419
+ for files, member in enumerate(tarball, start=1):
420
+ path = member.name
421
+ if not (member.isfile() or member.isdir()):
422
+ raise DeclarationRefused(NOT_A_FILE.format(path=path))
423
+ if path.startswith("/") or ".." in path.split("/"):
424
+ raise DeclarationRefused(LEAVES_THE_PROJECT.format(path=path))
425
+ unpacked += member.size
426
+ if files > MOST_FILES:
427
+ raise DeclarationRefused(TOO_MANY_FILES.format(limit=MOST_FILES))
428
+ if unpacked > LARGEST_UNPACKED:
429
+ raise DeclarationRefused(UNPACKS_TOO_BIG.format(limit=LARGEST_UNPACKED >> 20))
430
+
431
+
432
+ def _status(row: DictRow) -> AppStatus:
433
+ host = None if row["release"] is None else _host(row)
434
+ return AppStatus(
435
+ org=row["org"],
436
+ name=row["name"],
437
+ created_by=row["created_by"],
438
+ created_at=row["created_at"],
439
+ release=row["release"],
440
+ sha256=row["sha256"],
441
+ host=host,
442
+ live_release=row["live_release"],
443
+ failed_why=row["failed_why"] if host is not None and row["failed_host"] == host else None,
444
+ stopped=row["stopped_at"] is not None,
445
+ )
446
+
447
+
448
+ def _runnable(row: DictRow) -> Runnable:
449
+ host = _host(row)
450
+ return Runnable(
451
+ org=row["org"],
452
+ name=row["name"],
453
+ release=row["release"],
454
+ sha256=row["sha256"],
455
+ host=host,
456
+ failed=row["failed_host"] == host,
457
+ logs_wanted=bool(row["logs_wanted"]),
458
+ live_host=row["live_host"],
459
+ )
460
+
461
+
462
+ def _host(row: DictRow) -> str:
463
+ app = HostedApp(org=row["org"], env=row["env"], name=row["name"])
464
+ return host_of(app, row["release"], row["sha256"], row["secrets"])
465
+
466
+
467
+ def _release(row: DictRow) -> Release:
468
+ return Release(
469
+ release=row["release"],
470
+ sha256=row["sha256"],
471
+ bytes=row["bytes"],
472
+ author=row["author"],
473
+ note=row["note"],
474
+ created_at=row["created_at"],
475
+ )
@@ -0,0 +1,126 @@
1
+ """The judges an org writes: the org's, asked of every agent's calls, and one agent's own."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from psycopg.rows import DictRow
7
+
8
+ from pinecall.domain.agent import PANEL_JUDGES, AgentJudge
9
+ from pinecall.domain.errors import Conflict, DeclarationRefused, NotFound
10
+ from pinecall.domain.scope import THE_ORGS_OWN
11
+ from pinecall.postgres.pool import Pool
12
+
13
+ NOBODY = "{whose} has no judge called {name}"
14
+
15
+
16
+ TAKEN = "{name} is {whose} judge already: one name asks one question of a call"
17
+
18
+
19
+ A_PANELS = "{name} is a judge of the panel: a judge of your own takes another name"
20
+
21
+
22
+ NO_QUESTION = "a judge asks a question of a call: write one"
23
+
24
+
25
+ THE_ORG = "the org"
26
+
27
+
28
+ JUDGES = """
29
+ SELECT name, question, runs_on, author, set_at
30
+ FROM agent_judges WHERE org = %(org)s AND agent = %(agent)s
31
+ ORDER BY name
32
+ """
33
+
34
+
35
+ # The org's rows first: their agent is '', which sorts before any slug.
36
+ FOR_CALL = """
37
+ SELECT name, question, runs_on, author, set_at
38
+ FROM agent_judges WHERE org = %(org)s AND agent IN ('', %(agent)s)
39
+ ORDER BY agent, name
40
+ """
41
+
42
+
43
+ # A name is one question per call: the org's and an agent's may not share it, since a call of
44
+ # that agent would carry two verdicts under one name. Two agents may.
45
+ CLASH = """
46
+ SELECT agent FROM agent_judges
47
+ WHERE org = %(org)s AND name = %(name)s AND agent <> %(agent)s AND '' IN (agent, %(agent)s)
48
+ LIMIT 1
49
+ """
50
+
51
+
52
+ PUT_JUDGE = """
53
+ INSERT INTO agent_judges (org, agent, name, question, runs_on, author)
54
+ VALUES (%(org)s, %(agent)s, %(name)s, %(question)s, %(runs_on)s, %(author)s)
55
+ ON CONFLICT (org, agent, name) DO UPDATE SET
56
+ question = excluded.question, runs_on = excluded.runs_on, author = excluded.author,
57
+ set_at = now()
58
+ """
59
+
60
+
61
+ DROP_JUDGE = """
62
+ DELETE FROM agent_judges WHERE org = %(org)s AND agent = %(agent)s AND name = %(name)s
63
+ RETURNING name
64
+ """
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class StoredJudge:
69
+ """A judge as the org keeps it: who wrote it last, and when."""
70
+
71
+ judge: AgentJudge
72
+ author: str
73
+ set_at: datetime
74
+
75
+
76
+ async def put_judge(pool: Pool, org: str, agent: str, written: AgentJudge, *, author: str) -> None:
77
+ """Write a judge whole, the org's (agent '') or one agent's; the same name again replaces it."""
78
+ if not written.question.strip():
79
+ raise DeclarationRefused(NO_QUESTION)
80
+ if written.name in PANEL_JUDGES:
81
+ raise DeclarationRefused(A_PANELS.format(name=written.name))
82
+ values = {
83
+ "org": org,
84
+ "agent": agent,
85
+ "name": written.name,
86
+ "question": written.question,
87
+ "runs_on": written.runs_on,
88
+ "author": author,
89
+ }
90
+ async with pool.connection() as connection, connection.transaction():
91
+ clash = await (await connection.execute(CLASH, values)).fetchone()
92
+ if clash is not None:
93
+ raise Conflict(TAKEN.format(name=written.name, whose=_owner(str(clash["agent"]))))
94
+ await connection.execute(PUT_JUDGE, values)
95
+
96
+
97
+ async def drop_judge(pool: Pool, org: str, agent: str, name: str) -> None:
98
+ """Forget a judge of the org's or of one agent's; NotFound for a name nobody wrote there."""
99
+ async with pool.connection() as connection:
100
+ dropped = await connection.execute(DROP_JUDGE, {"org": org, "agent": agent, "name": name})
101
+ if await dropped.fetchone() is None:
102
+ raise NotFound(NOBODY.format(whose=_owner(agent), name=name))
103
+
104
+
105
+ async def judges_of(pool: Pool, org: str, agent: str) -> list[StoredJudge]:
106
+ """The judges written at one level, the org's (agent '') or an agent's own, by name."""
107
+ async with pool.connection() as connection:
108
+ rows = await (await connection.execute(JUDGES, {"org": org, "agent": agent})).fetchall()
109
+ return [_judge(row) for row in rows]
110
+
111
+
112
+ async def for_call(pool: Pool, org: str, agent: str) -> list[StoredJudge]:
113
+ """Every judge a call of the agent is held to beside the panel: the org's, then its own."""
114
+ async with pool.connection() as connection:
115
+ rows = await (await connection.execute(FOR_CALL, {"org": org, "agent": agent})).fetchall()
116
+ return [_judge(row) for row in rows]
117
+
118
+
119
+ def _judge(row: DictRow) -> StoredJudge:
120
+ judge = AgentJudge(name=row["name"], question=row["question"], runs_on=row["runs_on"])
121
+ return StoredJudge(judge=judge, author=row["author"], set_at=row["set_at"])
122
+
123
+
124
+ def _owner(agent: str) -> str:
125
+ """The owner of a judge as a sentence names it: the org, or the agent's slug."""
126
+ return THE_ORG if agent == THE_ORGS_OWN else agent