pinecall-runtime 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|