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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. 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()))