puremethod 0.1.0__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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
app/repo.py
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""Tenant-scoped data access (08-app.md §5 step 6, INV-1). A `TenantRepo` is bound to one
|
|
2
|
+
tenant and every statement filters on it; there is no unscoped helper. The one cross-tenant
|
|
3
|
+
query, finding work for workers, lives in `app.scan`, which `app.api` may not import."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from app import ssh_machines
|
|
14
|
+
from app.db import Conn, Database
|
|
15
|
+
from app.keys import seal
|
|
16
|
+
|
|
17
|
+
CREDENTIAL_TYPES = ("llm-key", "ssh-key", "data-token", "purescholar-key", "puretrace-key",
|
|
18
|
+
"literature-key")
|
|
19
|
+
# What `POST /credentials/{id}/check` can check, by credential type (08-app.md §3, KB-029,
|
|
20
|
+
# KB-039).
|
|
21
|
+
CHECK_KINDS = {"llm-key": "llm", "purescholar-key": "purescholar", "puretrace-key": "puretrace",
|
|
22
|
+
"literature-key": "source"}
|
|
23
|
+
SERVICES = ("literature", "puretrace")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def now_iso() -> str:
|
|
27
|
+
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def new_id(prefix: str) -> str:
|
|
31
|
+
return f"{prefix}_{secrets.token_hex(8)}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StaleEdit(Exception):
|
|
35
|
+
"""The profile changed after this edit was made from it (KB-041)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class CredentialInfo:
|
|
40
|
+
"""A stored credential's public facts. There is deliberately no value field (INV-2)."""
|
|
41
|
+
id: str
|
|
42
|
+
name: str
|
|
43
|
+
type: str
|
|
44
|
+
created_at: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Sealed:
|
|
49
|
+
id: str
|
|
50
|
+
nonce: bytes
|
|
51
|
+
ciphertext: bytes
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ExecutorRow:
|
|
56
|
+
id: str
|
|
57
|
+
name: str
|
|
58
|
+
kind: str
|
|
59
|
+
config: dict[str, Any]
|
|
60
|
+
created_at: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class MachineRow:
|
|
65
|
+
id: str
|
|
66
|
+
name: str
|
|
67
|
+
config: dict[str, Any]
|
|
68
|
+
created_at: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ProfileRow:
|
|
73
|
+
id: str
|
|
74
|
+
name: str
|
|
75
|
+
roles: dict[str, dict[str, Any]]
|
|
76
|
+
prices: dict[str, dict[str, float]]
|
|
77
|
+
created_at: str
|
|
78
|
+
# Raised by each change to roles or prices; a check counts for the version it checked
|
|
79
|
+
# (08 §3 Profile edits, KB-041). A rename keeps the version.
|
|
80
|
+
version: int = 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class RunRow:
|
|
85
|
+
id: str
|
|
86
|
+
title: str
|
|
87
|
+
ledger_run_id: str | None
|
|
88
|
+
state: str # queued | starting | started | start_failed
|
|
89
|
+
start_error: str | None
|
|
90
|
+
worker_error: str | None
|
|
91
|
+
config: dict[str, Any]
|
|
92
|
+
prices: dict[str, dict[str, float]]
|
|
93
|
+
executor: dict[str, Any] # snapshot at creation: id, name, kind, config
|
|
94
|
+
created_at: str
|
|
95
|
+
# Snapshot at creation of where the run reaches PureScholar and PureTrace: a URL, "local"
|
|
96
|
+
# (the bundled PureScholar) or None. None for runs created before KB-029.
|
|
97
|
+
services: dict[str, str | None] | None = None
|
|
98
|
+
# The LLM profile the run was started from; None for runs created before KB-041.
|
|
99
|
+
llm_profile_id: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class CommandRow:
|
|
104
|
+
id: str
|
|
105
|
+
run_id: str
|
|
106
|
+
seq: int
|
|
107
|
+
kind: str
|
|
108
|
+
payload: dict[str, Any]
|
|
109
|
+
state: str # pending | done | refused
|
|
110
|
+
reason: str | None
|
|
111
|
+
created_at: str
|
|
112
|
+
done_at: str | None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class CheckRow:
|
|
117
|
+
id: str
|
|
118
|
+
executor_id: str
|
|
119
|
+
state: str # pending | done
|
|
120
|
+
report: dict[str, Any] | None
|
|
121
|
+
created_at: str
|
|
122
|
+
done_at: str | None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class CredentialCheckRow:
|
|
127
|
+
id: str
|
|
128
|
+
credential_id: str
|
|
129
|
+
kind: str # llm | purescholar | puretrace
|
|
130
|
+
request: dict[str, Any] # what to check against; never a credential value
|
|
131
|
+
state: str # pending | done
|
|
132
|
+
report: dict[str, Any] | None # {"ok": bool, "problems": [...]}
|
|
133
|
+
created_at: str
|
|
134
|
+
done_at: str | None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ccheck(r: tuple[Any, ...]) -> CredentialCheckRow:
|
|
138
|
+
return CredentialCheckRow(r[0], r[1], r[2], json.loads(r[3]), r[4],
|
|
139
|
+
json.loads(r[5]) if r[5] else None, r[6], r[7])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_CC_COLS = "id, credential_id, kind, request, state, report, created_at, done_at"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run(r: tuple[Any, ...]) -> RunRow:
|
|
146
|
+
return RunRow(r[0], r[1], r[2], r[3], r[4], r[5], json.loads(r[6]), json.loads(r[7]),
|
|
147
|
+
json.loads(r[8]), r[9], json.loads(r[10]) if r[10] else None, r[11])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cmd(r: tuple[Any, ...]) -> CommandRow:
|
|
151
|
+
return CommandRow(r[0], r[1], int(r[2]), r[3], json.loads(r[4]), r[5], r[6], r[7], r[8])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_RUN_COLS = ("id, title, ledger_run_id, state, start_error, worker_error, config, prices, "
|
|
155
|
+
"executor, created_at, services, llm_profile_id")
|
|
156
|
+
_CMD_COLS = "id, run_id, seq, kind, payload, state, reason, created_at, done_at"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TenantRepo:
|
|
160
|
+
def __init__(self, db: Database, tenant_id: str, master_key: bytes | None = None) -> None:
|
|
161
|
+
if not tenant_id:
|
|
162
|
+
raise ValueError("a tenant id is required")
|
|
163
|
+
self._db = db
|
|
164
|
+
self.tenant_id = tenant_id
|
|
165
|
+
self._key = master_key # sealing only; nothing here decrypts
|
|
166
|
+
|
|
167
|
+
# ---- credentials ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def add_credential(self, name: str, type_: str, value: str) -> CredentialInfo:
|
|
170
|
+
if self._key is None:
|
|
171
|
+
raise RuntimeError("this repository cannot seal credentials")
|
|
172
|
+
if type_ not in CREDENTIAL_TYPES:
|
|
173
|
+
raise ValueError(f"unknown credential type {type_!r}")
|
|
174
|
+
cid, at = new_id("k"), now_iso()
|
|
175
|
+
nonce, ct = seal(self._key, self.tenant_id, cid, value)
|
|
176
|
+
with self._db.tx(write=True) as c:
|
|
177
|
+
if c.execute("SELECT 1 FROM credentials WHERE tenant_id = ? AND name = ?",
|
|
178
|
+
(self.tenant_id, name)):
|
|
179
|
+
raise ValueError(f"a credential named {name!r} exists")
|
|
180
|
+
c.execute("INSERT INTO credentials VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
181
|
+
(cid, self.tenant_id, name, type_, nonce, ct, at))
|
|
182
|
+
return CredentialInfo(cid, name, type_, at)
|
|
183
|
+
|
|
184
|
+
def list_credentials(self) -> list[CredentialInfo]:
|
|
185
|
+
with self._db.tx() as c:
|
|
186
|
+
rows = c.execute("SELECT id, name, type, created_at FROM credentials "
|
|
187
|
+
"WHERE tenant_id = ? ORDER BY name", (self.tenant_id,))
|
|
188
|
+
return [CredentialInfo(*r) for r in rows]
|
|
189
|
+
|
|
190
|
+
def credential_types(self) -> dict[str, str]:
|
|
191
|
+
return {c.name: c.type for c in self.list_credentials()}
|
|
192
|
+
|
|
193
|
+
def delete_credential(self, credential_id: str) -> bool:
|
|
194
|
+
with self._db.tx(write=True) as c:
|
|
195
|
+
c.execute("DELETE FROM credential_checks WHERE tenant_id = ? AND credential_id = ?",
|
|
196
|
+
(self.tenant_id, credential_id))
|
|
197
|
+
return c.rowcount("DELETE FROM credentials WHERE tenant_id = ? AND id = ?",
|
|
198
|
+
(self.tenant_id, credential_id)) == 1
|
|
199
|
+
|
|
200
|
+
def credential(self, credential_id: str) -> CredentialInfo | None:
|
|
201
|
+
return next((c for c in self.list_credentials() if c.id == credential_id), None)
|
|
202
|
+
|
|
203
|
+
def sealed(self, name: str) -> Sealed | None:
|
|
204
|
+
with self._db.tx() as c:
|
|
205
|
+
rows = c.execute("SELECT id, nonce, ciphertext FROM credentials "
|
|
206
|
+
"WHERE tenant_id = ? AND name = ?", (self.tenant_id, name))
|
|
207
|
+
return Sealed(rows[0][0], bytes(rows[0][1]), bytes(rows[0][2])) if rows else None
|
|
208
|
+
|
|
209
|
+
# ---- rate limits (08 INV-11, KB-036) -------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def take(self, kind: str, limit: int, window_s: float, now: float) -> bool:
|
|
212
|
+
"""Counts one use of `kind`; False (nothing counted) when `limit` uses already fall
|
|
213
|
+
in the last `window_s` seconds."""
|
|
214
|
+
with self._db.tx(write=True) as c:
|
|
215
|
+
c.execute("DELETE FROM rate_events WHERE tenant_id = ? AND at < ?",
|
|
216
|
+
(self.tenant_id, now - window_s))
|
|
217
|
+
used = c.execute("SELECT COUNT(*) FROM rate_events WHERE tenant_id = ? AND kind = ?",
|
|
218
|
+
(self.tenant_id, kind))[0][0]
|
|
219
|
+
if used >= limit:
|
|
220
|
+
return False
|
|
221
|
+
c.execute("INSERT INTO rate_events VALUES (?, ?, ?)", (self.tenant_id, kind, now))
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
# ---- model searches (08 §3 `/llm-servers/search`, KB-036) -----------------------------
|
|
225
|
+
|
|
226
|
+
def add_search(self, target: dict[str, Any]) -> dict[str, Any]:
|
|
227
|
+
sid, at = new_id("ls"), now_iso()
|
|
228
|
+
with self._db.tx(write=True) as c:
|
|
229
|
+
c.execute("INSERT INTO llm_searches VALUES (?, ?, ?, 'pending', NULL, ?, NULL)",
|
|
230
|
+
(sid, self.tenant_id, json.dumps(target), at))
|
|
231
|
+
return {"id": sid, "target": target, "state": "pending", "report": None,
|
|
232
|
+
"created_at": at, "done_at": None}
|
|
233
|
+
|
|
234
|
+
def get_search(self, search_id: str) -> dict[str, Any] | None:
|
|
235
|
+
with self._db.tx() as c:
|
|
236
|
+
rows = c.execute("SELECT id, target, state, report, created_at, done_at "
|
|
237
|
+
"FROM llm_searches WHERE tenant_id = ? AND id = ?",
|
|
238
|
+
(self.tenant_id, search_id))
|
|
239
|
+
if not rows:
|
|
240
|
+
return None
|
|
241
|
+
r = rows[0]
|
|
242
|
+
return {"id": r[0], "target": json.loads(r[1]), "state": r[2],
|
|
243
|
+
"report": json.loads(r[3]) if r[3] else None, "created_at": r[4],
|
|
244
|
+
"done_at": r[5]}
|
|
245
|
+
|
|
246
|
+
def finish_search(self, search_id: str, report: dict[str, Any]) -> None:
|
|
247
|
+
with self._db.tx(write=True) as c:
|
|
248
|
+
c.execute("UPDATE llm_searches SET state = 'done', report = ?, done_at = ? "
|
|
249
|
+
"WHERE tenant_id = ? AND id = ?",
|
|
250
|
+
(json.dumps(report), now_iso(), self.tenant_id, search_id))
|
|
251
|
+
|
|
252
|
+
# ---- SSH machines (KB-036) ----------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def list_machines(self) -> list[MachineRow]:
|
|
255
|
+
with self._db.tx() as c:
|
|
256
|
+
rows = c.execute("SELECT id, name, config, created_at FROM ssh_machines "
|
|
257
|
+
"WHERE tenant_id = ? ORDER BY name", (self.tenant_id,))
|
|
258
|
+
return [MachineRow(r[0], r[1], json.loads(r[2]), r[3]) for r in rows]
|
|
259
|
+
|
|
260
|
+
def get_machine(self, machine_id: str) -> MachineRow | None:
|
|
261
|
+
return next((m for m in self.list_machines() if m.id == machine_id), None)
|
|
262
|
+
|
|
263
|
+
def save_machine(self, machine_id: str | None, name: str,
|
|
264
|
+
config: dict[str, Any]) -> MachineRow:
|
|
265
|
+
"""Adds (`machine_id` None) or replaces a machine, then re-resolves every `ssh`
|
|
266
|
+
executor from the machines, all in one transaction: an executor's copy never
|
|
267
|
+
disagrees with its machine. Raises ValueError (name taken, unknown or looping jump
|
|
268
|
+
hosts, a hop without a confirmed host key) with nothing written; KeyError if the
|
|
269
|
+
machine is not this tenant's."""
|
|
270
|
+
mid, at = machine_id or new_id("m"), now_iso()
|
|
271
|
+
with self._db.tx(write=True) as c:
|
|
272
|
+
if c.execute("SELECT 1 FROM ssh_machines WHERE tenant_id = ? AND name = ? "
|
|
273
|
+
"AND id <> ?", (self.tenant_id, name, mid)):
|
|
274
|
+
raise ValueError(f"an SSH machine named {name!r} exists")
|
|
275
|
+
if machine_id is None:
|
|
276
|
+
c.execute("INSERT INTO ssh_machines VALUES (?, ?, ?, ?, ?)",
|
|
277
|
+
(mid, self.tenant_id, name, json.dumps(config), at))
|
|
278
|
+
elif not c.rowcount("UPDATE ssh_machines SET name = ?, config = ? "
|
|
279
|
+
"WHERE tenant_id = ? AND id = ?",
|
|
280
|
+
(name, json.dumps(config), self.tenant_id, mid)):
|
|
281
|
+
raise KeyError(machine_id)
|
|
282
|
+
else:
|
|
283
|
+
at = c.execute("SELECT created_at FROM ssh_machines WHERE id = ?", (mid,))[0][0]
|
|
284
|
+
self._resolve_executors(c)
|
|
285
|
+
return MachineRow(mid, name, config, at)
|
|
286
|
+
|
|
287
|
+
def _resolve_executors(self, c: Any) -> None:
|
|
288
|
+
machines = {r[0]: json.loads(r[1]) for r in c.execute(
|
|
289
|
+
"SELECT id, config FROM ssh_machines WHERE tenant_id = ?", (self.tenant_id,))}
|
|
290
|
+
for mid in machines:
|
|
291
|
+
ssh_machines.chain(machines, mid) # every machine's chain must hold
|
|
292
|
+
for xid, raw in c.execute("SELECT id, config FROM executors WHERE tenant_id = ? "
|
|
293
|
+
"AND kind = 'ssh'", (self.tenant_id,)):
|
|
294
|
+
cfg = json.loads(raw)
|
|
295
|
+
if cfg.get("machine") in machines:
|
|
296
|
+
cfg.update(ssh_machines.resolve(machines, cfg["machine"]))
|
|
297
|
+
c.execute("UPDATE executors SET config = ? WHERE tenant_id = ? AND id = ?",
|
|
298
|
+
(json.dumps(cfg), self.tenant_id, xid))
|
|
299
|
+
|
|
300
|
+
def resolve_machine(self, machine_id: str) -> dict[str, Any]:
|
|
301
|
+
"""The connection fields an `ssh` executor stores for this machine. KeyError if it is
|
|
302
|
+
not this tenant's."""
|
|
303
|
+
machines = {m.id: m.config for m in self.list_machines()}
|
|
304
|
+
if machine_id not in machines:
|
|
305
|
+
raise KeyError(machine_id)
|
|
306
|
+
return ssh_machines.resolve(machines, machine_id)
|
|
307
|
+
|
|
308
|
+
def delete_machine(self, machine_id: str) -> list[str] | None:
|
|
309
|
+
"""Deletes an unused machine: returns [] when deleted, None when there is no such
|
|
310
|
+
machine, or what uses it (nothing deleted)."""
|
|
311
|
+
with self._db.tx(write=True) as c:
|
|
312
|
+
rows = c.execute("SELECT id, name, config FROM ssh_machines WHERE tenant_id = ?",
|
|
313
|
+
(self.tenant_id,))
|
|
314
|
+
machines = {r[0]: (r[1], json.loads(r[2])) for r in rows}
|
|
315
|
+
if machine_id not in machines:
|
|
316
|
+
return None
|
|
317
|
+
executors = [(r[0], json.loads(r[1])) for r in c.execute(
|
|
318
|
+
"SELECT name, config FROM executors WHERE tenant_id = ? AND kind = 'ssh'",
|
|
319
|
+
(self.tenant_id,))]
|
|
320
|
+
used = ssh_machines.users(machines, executors, machine_id)
|
|
321
|
+
for pname, raw in c.execute("SELECT name, roles FROM llm_profiles WHERE "
|
|
322
|
+
"tenant_id = ? AND deleted_at IS NULL",
|
|
323
|
+
(self.tenant_id,)):
|
|
324
|
+
specs = json.loads(raw).values()
|
|
325
|
+
if any(machine_id in (s.get("machine"), (s.get("fallback") or {}).get("machine"))
|
|
326
|
+
for s in specs):
|
|
327
|
+
used.append(f"LLM profile {pname}")
|
|
328
|
+
if used:
|
|
329
|
+
return used
|
|
330
|
+
c.execute("DELETE FROM ssh_machines WHERE tenant_id = ? AND id = ?",
|
|
331
|
+
(self.tenant_id, machine_id))
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
# ---- executors --------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def add_executor(self, name: str, kind: str, config: dict[str, Any]) -> ExecutorRow:
|
|
337
|
+
xid, at = new_id("x"), now_iso()
|
|
338
|
+
with self._db.tx(write=True) as c:
|
|
339
|
+
if c.execute("SELECT 1 FROM executors WHERE tenant_id = ? AND name = ?",
|
|
340
|
+
(self.tenant_id, name)):
|
|
341
|
+
raise ValueError(f"an executor named {name!r} exists")
|
|
342
|
+
c.execute("INSERT INTO executors VALUES (?, ?, ?, ?, ?, ?)",
|
|
343
|
+
(xid, self.tenant_id, name, kind, json.dumps(config), at))
|
|
344
|
+
return ExecutorRow(xid, name, kind, config, at)
|
|
345
|
+
|
|
346
|
+
def list_executors(self) -> list[ExecutorRow]:
|
|
347
|
+
with self._db.tx() as c:
|
|
348
|
+
rows = c.execute("SELECT id, name, kind, config, created_at FROM executors "
|
|
349
|
+
"WHERE tenant_id = ? ORDER BY name", (self.tenant_id,))
|
|
350
|
+
return [ExecutorRow(r[0], r[1], r[2], json.loads(r[3]), r[4]) for r in rows]
|
|
351
|
+
|
|
352
|
+
def get_executor(self, executor_id: str) -> ExecutorRow | None:
|
|
353
|
+
with self._db.tx() as c:
|
|
354
|
+
rows = c.execute("SELECT id, name, kind, config, created_at FROM executors "
|
|
355
|
+
"WHERE tenant_id = ? AND id = ?", (self.tenant_id, executor_id))
|
|
356
|
+
return ExecutorRow(rows[0][0], rows[0][1], rows[0][2], json.loads(rows[0][3]),
|
|
357
|
+
rows[0][4]) if rows else None
|
|
358
|
+
|
|
359
|
+
def update_executor(self, executor_id: str, name: str, config: dict[str, Any]) -> bool:
|
|
360
|
+
with self._db.tx(write=True) as c:
|
|
361
|
+
if c.execute("SELECT 1 FROM executors WHERE tenant_id = ? AND name = ? AND id <> ?",
|
|
362
|
+
(self.tenant_id, name, executor_id)):
|
|
363
|
+
raise ValueError(f"an executor named {name!r} exists")
|
|
364
|
+
return c.rowcount("UPDATE executors SET name = ?, config = ? "
|
|
365
|
+
"WHERE tenant_id = ? AND id = ?",
|
|
366
|
+
(name, json.dumps(config), self.tenant_id, executor_id)) == 1
|
|
367
|
+
|
|
368
|
+
def delete_executor(self, executor_id: str) -> bool:
|
|
369
|
+
with self._db.tx(write=True) as c:
|
|
370
|
+
c.execute("DELETE FROM preflight_checks WHERE tenant_id = ? AND executor_id = ?",
|
|
371
|
+
(self.tenant_id, executor_id))
|
|
372
|
+
return c.rowcount("DELETE FROM executors WHERE tenant_id = ? AND id = ?",
|
|
373
|
+
(self.tenant_id, executor_id)) == 1
|
|
374
|
+
|
|
375
|
+
# ---- LLM profiles -----------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def add_profile(self, name: str, roles: dict[str, dict[str, Any]],
|
|
378
|
+
prices: dict[str, dict[str, float]]) -> ProfileRow:
|
|
379
|
+
pid, at = new_id("p"), now_iso()
|
|
380
|
+
with self._db.tx(write=True) as c:
|
|
381
|
+
self._name_free(c, name)
|
|
382
|
+
c.execute("INSERT INTO llm_profiles (id, tenant_id, name, roles, prices, created_at) "
|
|
383
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
384
|
+
(pid, self.tenant_id, name, json.dumps(roles), json.dumps(prices), at))
|
|
385
|
+
return ProfileRow(pid, name, roles, prices, at)
|
|
386
|
+
|
|
387
|
+
def _name_free(self, c: Conn, name: str, but: str | None = None) -> None:
|
|
388
|
+
if any(r[0] != but for r in c.execute(
|
|
389
|
+
"SELECT id FROM llm_profiles WHERE tenant_id = ? AND name = ?",
|
|
390
|
+
(self.tenant_id, name))):
|
|
391
|
+
raise ValueError(f"a profile named {name!r} exists")
|
|
392
|
+
|
|
393
|
+
def list_profiles(self) -> list[ProfileRow]:
|
|
394
|
+
with self._db.tx() as c:
|
|
395
|
+
rows = c.execute("SELECT id, name, roles, prices, created_at, version "
|
|
396
|
+
"FROM llm_profiles WHERE tenant_id = ? AND deleted_at IS NULL "
|
|
397
|
+
"ORDER BY name", (self.tenant_id,))
|
|
398
|
+
return [ProfileRow(r[0], r[1], json.loads(r[2]), json.loads(r[3]), r[4], int(r[5]))
|
|
399
|
+
for r in rows]
|
|
400
|
+
|
|
401
|
+
def get_profile(self, profile_id: str) -> ProfileRow | None:
|
|
402
|
+
return next((p for p in self.list_profiles() if p.id == profile_id), None)
|
|
403
|
+
|
|
404
|
+
def rename_profile(self, profile_id: str, name: str) -> None:
|
|
405
|
+
"""A rename keeps the version, so the profile's checks still count (KB-041)."""
|
|
406
|
+
with self._db.tx(write=True) as c:
|
|
407
|
+
self._name_free(c, name, but=profile_id)
|
|
408
|
+
if not c.rowcount("UPDATE llm_profiles SET name = ? WHERE tenant_id = ? AND id = ? "
|
|
409
|
+
"AND deleted_at IS NULL", (name, self.tenant_id, profile_id)):
|
|
410
|
+
raise KeyError(profile_id)
|
|
411
|
+
|
|
412
|
+
def edit_profile_unchecked(self, profile_id: str, roles: dict[str, dict[str, Any]],
|
|
413
|
+
prices: dict[str, dict[str, float]], version: int) -> int:
|
|
414
|
+
"""Saved without checking (KB-041): applied at once, and the new version has no
|
|
415
|
+
check yet. `version` is the one the user edited; a newer one refuses the edit."""
|
|
416
|
+
with self._db.tx(write=True) as c:
|
|
417
|
+
return self._bump(c, profile_id, roles, prices, version)
|
|
418
|
+
|
|
419
|
+
def _bump(self, c: Conn, profile_id: str, roles: dict[str, dict[str, Any]],
|
|
420
|
+
prices: dict[str, dict[str, float]], version: int) -> int:
|
|
421
|
+
rows = c.execute("SELECT version FROM llm_profiles WHERE tenant_id = ? AND id = ? AND "
|
|
422
|
+
"deleted_at IS NULL", (self.tenant_id, profile_id))
|
|
423
|
+
if not rows:
|
|
424
|
+
raise KeyError(profile_id)
|
|
425
|
+
if int(rows[0][0]) != version:
|
|
426
|
+
raise StaleEdit(profile_id)
|
|
427
|
+
c.execute("UPDATE llm_profiles SET roles = ?, prices = ?, version = ? "
|
|
428
|
+
"WHERE tenant_id = ? AND id = ?",
|
|
429
|
+
(json.dumps(roles), json.dumps(prices), version + 1, self.tenant_id,
|
|
430
|
+
profile_id))
|
|
431
|
+
return version + 1
|
|
432
|
+
|
|
433
|
+
def delete_profile(self, profile_id: str) -> bool:
|
|
434
|
+
"""Hidden, not removed, so its past checks stay readable (KB-041). The stored name
|
|
435
|
+
gets the id appended, which frees the name for a new profile."""
|
|
436
|
+
with self._db.tx(write=True) as c:
|
|
437
|
+
return c.rowcount("UPDATE llm_profiles SET deleted_at = ?, "
|
|
438
|
+
"name = name || ' (deleted ' || id || ')' "
|
|
439
|
+
"WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL",
|
|
440
|
+
(now_iso(), self.tenant_id, profile_id)) == 1
|
|
441
|
+
|
|
442
|
+
def profile_checked(self, profile: ProfileRow) -> bool:
|
|
443
|
+
"""Its latest check passed and checked its current version (08 §3 Profile edits)."""
|
|
444
|
+
check = self.latest_profile_check(profile.id)
|
|
445
|
+
return bool(check and check["state"] == "done" and (check["report"] or {}).get("ok")
|
|
446
|
+
and check["version"] == profile.version)
|
|
447
|
+
|
|
448
|
+
# ---- profile checks (08 §3, §5 step 14, KB-037; edits, KB-041) ------------------------
|
|
449
|
+
|
|
450
|
+
def add_profile_check(self, profile_id: str, proposal: dict[str, Any] | None = None,
|
|
451
|
+
version: int | None = None) -> dict[str, Any]:
|
|
452
|
+
"""A check of the profile as it is, or of a proposed edit of it (`proposal`: roles
|
|
453
|
+
and prices) made from `version`."""
|
|
454
|
+
cid, at = new_id("pc"), now_iso()
|
|
455
|
+
with self._db.tx(write=True) as c:
|
|
456
|
+
rows = c.execute("SELECT version FROM llm_profiles WHERE tenant_id = ? AND id = ? "
|
|
457
|
+
"AND deleted_at IS NULL", (self.tenant_id, profile_id))
|
|
458
|
+
if not rows:
|
|
459
|
+
raise KeyError(profile_id)
|
|
460
|
+
current = int(rows[0][0])
|
|
461
|
+
if version is not None and version != current:
|
|
462
|
+
raise StaleEdit(profile_id)
|
|
463
|
+
c.execute("INSERT INTO profile_checks (id, tenant_id, profile_id, state, report, "
|
|
464
|
+
"created_at, done_at, version, proposal, applied) "
|
|
465
|
+
"VALUES (?, ?, ?, 'pending', NULL, ?, NULL, ?, ?, 0)",
|
|
466
|
+
(cid, self.tenant_id, profile_id, at, current,
|
|
467
|
+
json.dumps(proposal) if proposal is not None else None))
|
|
468
|
+
return {"id": cid, "profile_id": profile_id, "state": "pending", "report": None,
|
|
469
|
+
"created_at": at, "done_at": None, "version": current,
|
|
470
|
+
"proposal": proposal, "applied": False}
|
|
471
|
+
|
|
472
|
+
def _profile_checks(self, where: str, args: tuple[Any, ...]) -> list[dict[str, Any]]:
|
|
473
|
+
with self._db.tx() as c:
|
|
474
|
+
rows = c.execute("SELECT id, profile_id, state, report, created_at, done_at, " # noqa: S608
|
|
475
|
+
"version, proposal, applied "
|
|
476
|
+
f"FROM profile_checks WHERE tenant_id = ? AND {where} "
|
|
477
|
+
"ORDER BY created_at DESC, rowid DESC", (self.tenant_id, *args))
|
|
478
|
+
return [{"id": r[0], "profile_id": r[1], "state": r[2],
|
|
479
|
+
"report": json.loads(r[3]) if r[3] else None, "created_at": r[4],
|
|
480
|
+
"done_at": r[5], "version": int(r[6]),
|
|
481
|
+
"proposal": json.loads(r[7]) if r[7] else None, "applied": bool(r[8])}
|
|
482
|
+
for r in rows]
|
|
483
|
+
|
|
484
|
+
def profile_check(self, check_id: str) -> dict[str, Any] | None:
|
|
485
|
+
rows = self._profile_checks("id = ?", (check_id,))
|
|
486
|
+
return rows[0] if rows else None
|
|
487
|
+
|
|
488
|
+
def get_profile_check(self, profile_id: str, check_id: str) -> dict[str, Any] | None:
|
|
489
|
+
rows = self._profile_checks("profile_id = ? AND id = ?", (profile_id, check_id))
|
|
490
|
+
return rows[0] if rows else None
|
|
491
|
+
|
|
492
|
+
def latest_profile_check(self, profile_id: str) -> dict[str, Any] | None:
|
|
493
|
+
"""The latest check of the profile itself: a proposed edit's check counts only once
|
|
494
|
+
it was applied (KB-041)."""
|
|
495
|
+
rows = self._profile_checks("profile_id = ? AND (proposal IS NULL OR applied = 1)",
|
|
496
|
+
(profile_id,))
|
|
497
|
+
return rows[0] if rows else None
|
|
498
|
+
|
|
499
|
+
def finish_profile_check(self, check_id: str, report: dict[str, Any]) -> dict[str, Any]:
|
|
500
|
+
"""Records the report. A passed check of a proposed edit applies it, unless the
|
|
501
|
+
profile changed after the edit was proposed (KB-041). Returns the report stored."""
|
|
502
|
+
with self._db.tx(write=True) as c:
|
|
503
|
+
rows = c.execute("SELECT profile_id, version, proposal FROM profile_checks "
|
|
504
|
+
"WHERE tenant_id = ? AND id = ?", (self.tenant_id, check_id))
|
|
505
|
+
applied, version = 0, None
|
|
506
|
+
if rows and rows[0][2] and report.get("ok"):
|
|
507
|
+
proposal = json.loads(rows[0][2])
|
|
508
|
+
try:
|
|
509
|
+
version = self._bump(c, rows[0][0], proposal["roles"], proposal["prices"],
|
|
510
|
+
int(rows[0][1]))
|
|
511
|
+
applied = 1
|
|
512
|
+
except (KeyError, StaleEdit):
|
|
513
|
+
report = {"ok": False, "problems": [
|
|
514
|
+
"the profile changed after this edit was proposed, so nothing was "
|
|
515
|
+
"applied. Open the profile again and redo the edit"]}
|
|
516
|
+
c.execute("UPDATE profile_checks SET state = 'done', report = ?, done_at = ?, "
|
|
517
|
+
"applied = ?, version = COALESCE(?, version) "
|
|
518
|
+
"WHERE tenant_id = ? AND id = ?",
|
|
519
|
+
(json.dumps(report), now_iso(), applied, version, self.tenant_id,
|
|
520
|
+
check_id))
|
|
521
|
+
return report
|
|
522
|
+
|
|
523
|
+
# ---- runs and commands --------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def create_run(self, title: str, config: dict[str, Any], prices: dict[str, dict[str, float]],
|
|
526
|
+
executor: ExecutorRow, services: dict[str, str | None] | None = None,
|
|
527
|
+
llm_profile_id: str | None = None) -> tuple[RunRow, CommandRow]:
|
|
528
|
+
"""A queued run and its `start` command, in one transaction (KB-021). The executor is
|
|
529
|
+
stored as it is now, so later edits never reach this run."""
|
|
530
|
+
rid, cid, at = new_id("r"), new_id("c"), now_iso()
|
|
531
|
+
snap = {"id": executor.id, "name": executor.name, "kind": executor.kind,
|
|
532
|
+
"config": executor.config}
|
|
533
|
+
with self._db.tx(write=True) as c:
|
|
534
|
+
c.execute("INSERT INTO runs (id, tenant_id, title, ledger_run_id, state, "
|
|
535
|
+
"start_error, worker_error, config, prices, executor, created_at, "
|
|
536
|
+
"services, llm_profile_id) "
|
|
537
|
+
"VALUES (?, ?, ?, NULL, 'queued', NULL, NULL, ?, ?, ?, ?, ?, ?)",
|
|
538
|
+
(rid, self.tenant_id, title, json.dumps(config), json.dumps(prices),
|
|
539
|
+
json.dumps(snap), at,
|
|
540
|
+
json.dumps(services) if services is not None else None,
|
|
541
|
+
llm_profile_id))
|
|
542
|
+
c.execute("INSERT INTO commands VALUES (?, ?, ?, 1, 'start', '{}', 'pending', NULL, "
|
|
543
|
+
"?, NULL)", (cid, self.tenant_id, rid, at))
|
|
544
|
+
return (RunRow(rid, title, None, "queued", None, None, config, prices, snap, at,
|
|
545
|
+
services, llm_profile_id),
|
|
546
|
+
CommandRow(cid, rid, 1, "start", {}, "pending", None, at, None))
|
|
547
|
+
|
|
548
|
+
def list_runs(self) -> list[RunRow]:
|
|
549
|
+
with self._db.tx() as c:
|
|
550
|
+
rows = c.execute(f"SELECT {_RUN_COLS} FROM runs WHERE tenant_id = ? " # noqa: S608
|
|
551
|
+
"ORDER BY created_at DESC, id", (self.tenant_id,))
|
|
552
|
+
return [_run(r) for r in rows]
|
|
553
|
+
|
|
554
|
+
def get_run(self, run_id: str) -> RunRow | None:
|
|
555
|
+
with self._db.tx() as c:
|
|
556
|
+
rows = c.execute(f"SELECT {_RUN_COLS} FROM runs " # noqa: S608
|
|
557
|
+
"WHERE tenant_id = ? AND id = ?", (self.tenant_id, run_id))
|
|
558
|
+
return _run(rows[0]) if rows else None
|
|
559
|
+
|
|
560
|
+
def set_run_state(self, run_id: str, state: str, *, ledger_run_id: str | None = None,
|
|
561
|
+
start_error: str | None = None) -> None:
|
|
562
|
+
with self._db.tx(write=True) as c:
|
|
563
|
+
c.execute("UPDATE runs SET state = ?, ledger_run_id = COALESCE(?, ledger_run_id), "
|
|
564
|
+
"start_error = ? WHERE tenant_id = ? AND id = ?",
|
|
565
|
+
(state, ledger_run_id, start_error, self.tenant_id, run_id))
|
|
566
|
+
|
|
567
|
+
def set_worker_error(self, run_id: str, error: str | None) -> None:
|
|
568
|
+
with self._db.tx(write=True) as c:
|
|
569
|
+
c.execute("UPDATE runs SET worker_error = ? WHERE tenant_id = ? AND id = ?",
|
|
570
|
+
(error, self.tenant_id, run_id))
|
|
571
|
+
|
|
572
|
+
def add_command(self, run_id: str, kind: str, payload: dict[str, Any]) -> CommandRow:
|
|
573
|
+
"""Queues a user action. A new command also clears a worker error, so a worker is
|
|
574
|
+
tried again (KB-021: a later command starts a new worker)."""
|
|
575
|
+
cid, at = new_id("c"), now_iso()
|
|
576
|
+
with self._db.tx(write=True) as c:
|
|
577
|
+
if not c.execute("SELECT 1 FROM runs WHERE tenant_id = ? AND id = ?",
|
|
578
|
+
(self.tenant_id, run_id)):
|
|
579
|
+
raise KeyError(run_id)
|
|
580
|
+
seq = int(c.execute("SELECT COALESCE(MAX(seq), 0) FROM commands WHERE run_id = ?",
|
|
581
|
+
(run_id,))[0][0]) + 1
|
|
582
|
+
c.execute("INSERT INTO commands VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, NULL)",
|
|
583
|
+
(cid, self.tenant_id, run_id, seq, kind, json.dumps(payload), at))
|
|
584
|
+
c.execute("UPDATE runs SET worker_error = NULL WHERE tenant_id = ? AND id = ?",
|
|
585
|
+
(self.tenant_id, run_id))
|
|
586
|
+
return CommandRow(cid, run_id, seq, kind, payload, "pending", None, at, None)
|
|
587
|
+
|
|
588
|
+
def get_command(self, run_id: str, command_id: str) -> CommandRow | None:
|
|
589
|
+
with self._db.tx() as c:
|
|
590
|
+
rows = c.execute(f"SELECT {_CMD_COLS} FROM commands " # noqa: S608
|
|
591
|
+
"WHERE tenant_id = ? AND run_id = ? AND id = ?",
|
|
592
|
+
(self.tenant_id, run_id, command_id))
|
|
593
|
+
return _cmd(rows[0]) if rows else None
|
|
594
|
+
|
|
595
|
+
def pending_commands(self, run_id: str) -> list[CommandRow]:
|
|
596
|
+
with self._db.tx() as c:
|
|
597
|
+
rows = c.execute(f"SELECT {_CMD_COLS} FROM commands " # noqa: S608
|
|
598
|
+
"WHERE tenant_id = ? AND run_id = ? AND state = 'pending' "
|
|
599
|
+
"ORDER BY seq", (self.tenant_id, run_id))
|
|
600
|
+
return [_cmd(r) for r in rows]
|
|
601
|
+
|
|
602
|
+
def finish_command(self, command_id: str, state: str, reason: str | None = None) -> None:
|
|
603
|
+
if state not in ("done", "refused"):
|
|
604
|
+
raise ValueError(state)
|
|
605
|
+
with self._db.tx(write=True) as c:
|
|
606
|
+
c.execute("UPDATE commands SET state = ?, reason = ?, done_at = ? "
|
|
607
|
+
"WHERE tenant_id = ? AND id = ? AND state = 'pending'",
|
|
608
|
+
(state, reason, now_iso(), self.tenant_id, command_id))
|
|
609
|
+
|
|
610
|
+
# ---- executor preflight -------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
def add_check(self, executor_id: str) -> CheckRow:
|
|
613
|
+
pid, at = new_id("pf"), now_iso()
|
|
614
|
+
with self._db.tx(write=True) as c:
|
|
615
|
+
if not c.execute("SELECT 1 FROM executors WHERE tenant_id = ? AND id = ?",
|
|
616
|
+
(self.tenant_id, executor_id)):
|
|
617
|
+
raise KeyError(executor_id)
|
|
618
|
+
c.execute("INSERT INTO preflight_checks VALUES (?, ?, ?, 'pending', NULL, ?, NULL)",
|
|
619
|
+
(pid, self.tenant_id, executor_id, at))
|
|
620
|
+
return CheckRow(pid, executor_id, "pending", None, at, None)
|
|
621
|
+
|
|
622
|
+
def get_check(self, executor_id: str, check_id: str) -> CheckRow | None:
|
|
623
|
+
with self._db.tx() as c:
|
|
624
|
+
rows = c.execute("SELECT id, executor_id, state, report, created_at, done_at "
|
|
625
|
+
"FROM preflight_checks WHERE tenant_id = ? AND executor_id = ? "
|
|
626
|
+
"AND id = ?", (self.tenant_id, executor_id, check_id))
|
|
627
|
+
if not rows:
|
|
628
|
+
return None
|
|
629
|
+
r = rows[0]
|
|
630
|
+
return CheckRow(r[0], r[1], r[2], json.loads(r[3]) if r[3] else None, r[4], r[5])
|
|
631
|
+
|
|
632
|
+
def check(self, check_id: str) -> CheckRow | None:
|
|
633
|
+
with self._db.tx() as c:
|
|
634
|
+
rows = c.execute("SELECT executor_id FROM preflight_checks "
|
|
635
|
+
"WHERE tenant_id = ? AND id = ?", (self.tenant_id, check_id))
|
|
636
|
+
return self.get_check(str(rows[0][0]), check_id) if rows else None
|
|
637
|
+
|
|
638
|
+
def finish_check(self, check_id: str, report: dict[str, Any]) -> None:
|
|
639
|
+
with self._db.tx(write=True) as c:
|
|
640
|
+
c.execute("UPDATE preflight_checks SET state = 'done', report = ?, done_at = ? "
|
|
641
|
+
"WHERE tenant_id = ? AND id = ?",
|
|
642
|
+
(json.dumps(report), now_iso(), self.tenant_id, check_id))
|
|
643
|
+
|
|
644
|
+
# ---- credential checks (08-app.md §3, KB-029) ------------------------------------------
|
|
645
|
+
|
|
646
|
+
def add_credential_check(self, credential_id: str, request: dict[str, Any]
|
|
647
|
+
) -> CredentialCheckRow:
|
|
648
|
+
cid, at = new_id("cc"), now_iso()
|
|
649
|
+
with self._db.tx(write=True) as c:
|
|
650
|
+
rows = c.execute("SELECT type FROM credentials WHERE tenant_id = ? AND id = ?",
|
|
651
|
+
(self.tenant_id, credential_id))
|
|
652
|
+
if not rows:
|
|
653
|
+
raise KeyError(credential_id)
|
|
654
|
+
kind = CHECK_KINDS.get(str(rows[0][0]))
|
|
655
|
+
if kind is None:
|
|
656
|
+
raise ValueError(f"a {rows[0][0]} credential cannot be checked")
|
|
657
|
+
c.execute("INSERT INTO credential_checks VALUES (?, ?, ?, ?, ?, 'pending', NULL, ?, "
|
|
658
|
+
"NULL)", (cid, self.tenant_id, credential_id, kind, json.dumps(request), at))
|
|
659
|
+
return CredentialCheckRow(cid, credential_id, kind, request, "pending", None, at, None)
|
|
660
|
+
|
|
661
|
+
def get_credential_check(self, credential_id: str, check_id: str
|
|
662
|
+
) -> CredentialCheckRow | None:
|
|
663
|
+
with self._db.tx() as c:
|
|
664
|
+
rows = c.execute(f"SELECT {_CC_COLS} FROM credential_checks " # noqa: S608
|
|
665
|
+
"WHERE tenant_id = ? AND credential_id = ? AND id = ?",
|
|
666
|
+
(self.tenant_id, credential_id, check_id))
|
|
667
|
+
return _ccheck(rows[0]) if rows else None
|
|
668
|
+
|
|
669
|
+
def credential_check(self, check_id: str) -> CredentialCheckRow | None:
|
|
670
|
+
with self._db.tx() as c:
|
|
671
|
+
rows = c.execute(f"SELECT {_CC_COLS} FROM credential_checks " # noqa: S608
|
|
672
|
+
"WHERE tenant_id = ? AND id = ?", (self.tenant_id, check_id))
|
|
673
|
+
return _ccheck(rows[0]) if rows else None
|
|
674
|
+
|
|
675
|
+
def latest_credential_check(self, credential_id: str) -> CredentialCheckRow | None:
|
|
676
|
+
with self._db.tx() as c:
|
|
677
|
+
rows = c.execute(f"SELECT {_CC_COLS} FROM credential_checks " # noqa: S608
|
|
678
|
+
"WHERE tenant_id = ? AND credential_id = ? "
|
|
679
|
+
"ORDER BY created_at DESC, rowid DESC LIMIT 1",
|
|
680
|
+
(self.tenant_id, credential_id))
|
|
681
|
+
return _ccheck(rows[0]) if rows else None
|
|
682
|
+
|
|
683
|
+
def finish_credential_check(self, check_id: str, report: dict[str, Any]) -> None:
|
|
684
|
+
with self._db.tx(write=True) as c:
|
|
685
|
+
c.execute("UPDATE credential_checks SET state = 'done', report = ?, done_at = ? "
|
|
686
|
+
"WHERE tenant_id = ? AND id = ?",
|
|
687
|
+
(json.dumps(report), now_iso(), self.tenant_id, check_id))
|
|
688
|
+
|
|
689
|
+
def latest_preflight(self, executor_id: str) -> CheckRow | None:
|
|
690
|
+
with self._db.tx() as c:
|
|
691
|
+
rows = c.execute("SELECT id FROM preflight_checks WHERE tenant_id = ? AND "
|
|
692
|
+
"executor_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
|
|
693
|
+
(self.tenant_id, executor_id))
|
|
694
|
+
return self.get_check(executor_id, str(rows[0][0])) if rows else None
|
|
695
|
+
|
|
696
|
+
# ---- the bundled PureScholar (08-app.md §5 step 12, KB-025, KB-031) ----------------------
|
|
697
|
+
|
|
698
|
+
def bundled(self) -> dict[str, Any] | None:
|
|
699
|
+
"""state (pending | starting | ready | failed | stopped), reason, port, and the hash
|
|
700
|
+
and credential id of the PureScholar key it was started with."""
|
|
701
|
+
with self._db.tx() as c:
|
|
702
|
+
rows = c.execute("SELECT state, reason, port, key_sha256, key_credential_id "
|
|
703
|
+
"FROM bundled_services WHERE tenant_id = ?", (self.tenant_id,))
|
|
704
|
+
if not rows:
|
|
705
|
+
return None
|
|
706
|
+
state, reason, port, digest, cred = rows[0]
|
|
707
|
+
return {"state": state, "reason": reason or "", "port": port, "key_sha256": digest,
|
|
708
|
+
"key_credential_id": cred}
|
|
709
|
+
|
|
710
|
+
def set_bundled(self, state: str, reason: str = "", *, port: int | None = None,
|
|
711
|
+
key: tuple[str, str] | None = None,
|
|
712
|
+
expect: tuple[str, ...] | None = None) -> bool:
|
|
713
|
+
"""Sets the state, only if the current one is in `expect` (when given): a start never
|
|
714
|
+
overwrites a newer request or a stop made meanwhile. `port` is kept only while ready;
|
|
715
|
+
`key` (sha256, credential id) replaces the recorded key. Returns whether it was set."""
|
|
716
|
+
with self._db.tx(write=True) as c:
|
|
717
|
+
old = c.execute("SELECT state, key_sha256, key_credential_id FROM bundled_services "
|
|
718
|
+
"WHERE tenant_id = ?", (self.tenant_id,))
|
|
719
|
+
if expect is not None and (not old or old[0][0] not in expect):
|
|
720
|
+
return False
|
|
721
|
+
digest, cred = key if key else ((old[0][1], old[0][2]) if old else (None, None))
|
|
722
|
+
c.execute("DELETE FROM bundled_services WHERE tenant_id = ?", (self.tenant_id,))
|
|
723
|
+
c.execute("INSERT INTO bundled_services VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
724
|
+
(self.tenant_id, state, reason, port if state == "ready" else None,
|
|
725
|
+
digest, cred, now_iso()))
|
|
726
|
+
return True
|
|
727
|
+
|
|
728
|
+
# ---- service settings (08-app.md §4, KB-025, KB-029) -------------------------------------
|
|
729
|
+
|
|
730
|
+
def service_setting(self, service: str) -> dict[str, Any] | None:
|
|
731
|
+
if service not in SERVICES:
|
|
732
|
+
raise ValueError(service)
|
|
733
|
+
with self._db.tx() as c:
|
|
734
|
+
rows = c.execute("SELECT config FROM service_settings WHERE tenant_id = ? AND "
|
|
735
|
+
"service = ?", (self.tenant_id, service))
|
|
736
|
+
return dict(json.loads(rows[0][0])) if rows else None
|
|
737
|
+
|
|
738
|
+
def set_service_setting(self, service: str, config: dict[str, Any] | None) -> None:
|
|
739
|
+
if service not in SERVICES:
|
|
740
|
+
raise ValueError(service)
|
|
741
|
+
with self._db.tx(write=True) as c:
|
|
742
|
+
c.execute("DELETE FROM service_settings WHERE tenant_id = ? AND service = ?",
|
|
743
|
+
(self.tenant_id, service))
|
|
744
|
+
if config is not None:
|
|
745
|
+
c.execute("INSERT INTO service_settings VALUES (?, ?, ?, ?)",
|
|
746
|
+
(self.tenant_id, service, json.dumps(config), now_iso()))
|