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/worker.py ADDED
@@ -0,0 +1,657 @@
1
+ """Worker processes (08-app.md §5 steps 2 and 3, KB-021).
2
+
3
+ `python -m app.worker run <tenant> <run>` drives one run while it holds the run's lease;
4
+ `python -m app.worker preflight <tenant> <check>` runs one executor preflight;
5
+ `python -m app.worker credcheck <tenant> <check>` checks one credential (KB-029);
6
+ `python -m app.worker llmsearch <tenant> <search>` looks for model servers (KB-036);
7
+ `python -m app.worker profilecheck <tenant> <check>` checks an LLM profile (KB-037);
8
+ `python -m app.worker literature <tenant> <tenant>` starts the tenant's bundled PureScholar
9
+ (KB-025, KB-031). These are the only processes that decrypt credentials (INV-2)."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import sys
17
+ import threading
18
+ import time
19
+ import uuid
20
+ from collections.abc import Callable
21
+ from typing import Any
22
+
23
+ from pydantic import ValidationError
24
+
25
+ from app import (
26
+ bundled,
27
+ leases,
28
+ literature_sources,
29
+ model_endpoints,
30
+ model_search,
31
+ ssh_chain,
32
+ ssh_hosts,
33
+ tunnels,
34
+ )
35
+ from app.compose import (
36
+ CredentialMissing,
37
+ Credentials,
38
+ ServiceUrls,
39
+ allowed_for_run,
40
+ build_executor,
41
+ make_loop,
42
+ prices,
43
+ run_config,
44
+ )
45
+ from app.db import Database
46
+ from app.executor_config import credentials_named
47
+ from app.repo import CommandRow, ExecutorRow, RunRow, TenantRepo
48
+ from app.settings import Settings
49
+ from app.statuses import DRIVABLE, TERMINAL
50
+ from app.unseal import CredentialUnreadable
51
+ from executor import Executor
52
+ from llm import LLM
53
+ from loop import Loop
54
+ from purescholar import PureScholar
55
+ from puretrace import PureTrace
56
+
57
+ log = logging.getLogger("app.worker")
58
+
59
+ IDLE_S = 600.0 # 10 idle minutes (KB-021)
60
+ POLL_S = 1.0
61
+ INTERRUPTED = "start interrupted; create the run again"
62
+ EXIT_OK, EXIT_ERROR, EXIT_BUSY, EXIT_LEASE_LOST = 0, 1, 2, 3
63
+
64
+ LoopFactory = Callable[[RunRow, Callable[[str], str], Callable[[], None]], Loop]
65
+ ExecutorFactory = Callable[[ExecutorRow, Callable[[str], str]], Executor]
66
+
67
+
68
+ class LeaseLost(Exception):
69
+ pass
70
+
71
+
72
+ class _Lease:
73
+ """Holds a lease and renews it from a thread; a failed renewal ends the process at once.
74
+ `check` runs before every ledger write (`compose.GuardedLedger`): a worker that was
75
+ suspended past its lease never writes, even before its next renewal runs (KB-021)."""
76
+
77
+ def __init__(self, db: Database, key: str, *, clock: Callable[[], float], ttl: float,
78
+ renew_s: float, on_lost: Callable[[int], Any],
79
+ monotonic: Callable[[], float] = time.monotonic) -> None:
80
+ self._db, self.key, self._clock, self._mono = db, key, clock, monotonic
81
+ self._ttl, self._renew_s, self._on_lost = ttl, renew_s, on_lost
82
+ self.holder = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
83
+ self._stop = threading.Event()
84
+ self._thread = threading.Thread(target=self._loop, name="lease", daemon=True)
85
+ self._ok_wall = self._ok_mono = 0.0
86
+
87
+ def acquire(self) -> bool:
88
+ if not leases.acquire(self._db, self.key, self.holder, self._clock(), self._ttl):
89
+ return False
90
+ self._ok_wall, self._ok_mono = self._clock(), self._mono()
91
+ self._thread.start()
92
+ return True
93
+
94
+ def check(self) -> None:
95
+ """Both clocks must show a renewal within half the TTL: wall time catches a suspend
96
+ that a monotonic clock may not count, and the other way round."""
97
+ limit = self._ttl / 2
98
+ if self._clock() - self._ok_wall > limit or self._mono() - self._ok_mono > limit:
99
+ self._lost()
100
+ raise LeaseLost(self.key)
101
+
102
+ def _lost(self) -> None:
103
+ log.error("lease %s lost; stopping", self.key)
104
+ self._on_lost(EXIT_LEASE_LOST)
105
+
106
+ def _loop(self) -> None:
107
+ while not self._stop.wait(self._renew_s):
108
+ try:
109
+ ok = leases.renew(self._db, self.key, self.holder, self._clock(), self._ttl)
110
+ except Exception: # a locked database: retry while the lease still has time
111
+ ok = None
112
+ if ok:
113
+ self._ok_wall, self._ok_mono = self._clock(), self._mono()
114
+ elif ok is False or self._clock() - self._ok_wall >= self._ttl * 0.75:
115
+ self._lost()
116
+ return
117
+
118
+ def release(self) -> None:
119
+ self._stop.set()
120
+ if self._thread.is_alive():
121
+ self._thread.join(timeout=5)
122
+ leases.release(self._db, self.key, self.holder)
123
+
124
+
125
+ class RunWorker:
126
+ def __init__(self, settings: Settings, db: Database, tenant_id: str, run_id: str, *,
127
+ loop_factory: LoopFactory | None = None,
128
+ clock: Callable[[], float] = time.time,
129
+ sleep: Callable[[float], None] = time.sleep,
130
+ exit: Callable[[int], Any] = os._exit,
131
+ idle_s: float = IDLE_S, poll_s: float = POLL_S,
132
+ lease_ttl: float = leases.LEASE_TTL_S, renew_s: float = leases.RENEW_S) -> None:
133
+ self._settings, self._db, self._run_id = settings, db, run_id
134
+ self._repo = TenantRepo(db, tenant_id)
135
+ self._urls = ServiceUrls.from_settings(settings) # replaced by the run's own in _work
136
+ self._factory = loop_factory or (
137
+ lambda run, lookup, guard: make_loop(settings, run, lookup, guard, self._urls))
138
+ self._clock, self._sleep = clock, sleep
139
+ self._idle_s, self._poll_s = idle_s, poll_s
140
+ self._lease = _Lease(db, f"run:{run_id}", clock=clock, ttl=lease_ttl, renew_s=renew_s,
141
+ on_lost=exit)
142
+ self._creds: Credentials | None = None
143
+
144
+ def scrub(self, text: str) -> str:
145
+ return self._creds.scrub(text) if self._creds is not None else text
146
+
147
+ def run(self) -> int:
148
+ if not self._lease.acquire():
149
+ return EXIT_BUSY
150
+ try:
151
+ return self._work()
152
+ except Exception as exc:
153
+ log.error("worker for run %s failed: %s", self._run_id,
154
+ self.scrub(f"{type(exc).__name__}: {exc}"))
155
+ return EXIT_ERROR
156
+ finally:
157
+ self._lease.release()
158
+
159
+ # ---- the run -------------------------------------------------------------------------
160
+
161
+ def _work(self) -> int:
162
+ run = self._repo.get_run(self._run_id)
163
+ if run is None:
164
+ return EXIT_BUSY
165
+ try:
166
+ config = run_config(run)
167
+ except ValidationError:
168
+ # A stored config this version cannot read: retrying cannot help (review finding 6).
169
+ self._repo.set_worker_error(self._run_id, "the run's stored config is not valid")
170
+ return EXIT_OK
171
+ self._urls = ServiceUrls.for_run(run, self._repo, self._settings)
172
+ self._creds = Credentials(self._repo, self._settings.master_key,
173
+ allowed_for_run(run, config, self._settings, self._urls))
174
+ if run.state == "starting" and run.ledger_run_id is None:
175
+ self._fail_start(INTERRUPTED)
176
+ return EXIT_OK
177
+ if run.state == "start_failed":
178
+ self._refuse_all("the run did not start")
179
+ return EXIT_OK
180
+ try:
181
+ loop = self._factory(run, self._creds, self._lease.check)
182
+ if run.ledger_run_id is None:
183
+ lid = self._start(loop, config)
184
+ if lid is None:
185
+ return EXIT_OK
186
+ else:
187
+ lid = run.ledger_run_id
188
+ self._drive(run, loop, lid)
189
+ except (CredentialMissing, CredentialUnreadable) as exc:
190
+ # No ledger write is possible without the run's secrets (04 INV-7): record it for
191
+ # the user in the run index; a new command tries again.
192
+ self._repo.set_worker_error(self._run_id, self.scrub(_credential_reason(exc)))
193
+ return EXIT_OK
194
+
195
+ def _start(self, loop: Loop, config: Any) -> str | None:
196
+ self._repo.set_run_state(self._run_id, "starting")
197
+ try:
198
+ lid = loop.start(config)
199
+ except (CredentialMissing, CredentialUnreadable) as exc:
200
+ self._fail_start(self.scrub(_credential_reason(exc)))
201
+ return None
202
+ except ValueError as exc:
203
+ self._fail_start(self.scrub(str(exc)))
204
+ return None
205
+ self._repo.set_run_state(self._run_id, "started", ledger_run_id=lid)
206
+ for cmd in self._repo.pending_commands(self._run_id):
207
+ if cmd.kind == "start":
208
+ self._repo.finish_command(cmd.id, "done")
209
+ return lid
210
+
211
+ def _fail_start(self, reason: str) -> None:
212
+ self._repo.set_run_state(self._run_id, "start_failed", start_error=reason)
213
+ self._refuse_all(f"the run did not start: {reason}")
214
+
215
+ def _refuse_all(self, reason: str) -> None:
216
+ for cmd in self._repo.pending_commands(self._run_id):
217
+ self._repo.finish_command(cmd.id, "refused", reason)
218
+
219
+ def _drive(self, run: RunRow, loop: Loop, lid: str) -> None:
220
+ stop = threading.Event()
221
+ errors: list[BaseException] = []
222
+ thread = threading.Thread(target=self._commands, args=(run, lid, stop, errors),
223
+ name="commands", daemon=True)
224
+ thread.start()
225
+ try:
226
+ idle_since = self._clock()
227
+ while not errors:
228
+ status = loop.status(lid).status
229
+ if status in TERMINAL:
230
+ break
231
+ if status in DRIVABLE:
232
+ loop.drive(lid)
233
+ idle_since = self._clock()
234
+ continue
235
+ if self._repo.pending_commands(self._run_id):
236
+ idle_since = self._clock()
237
+ elif self._clock() - idle_since >= self._idle_s:
238
+ break
239
+ self._sleep(self._poll_s)
240
+ finally:
241
+ stop.set()
242
+ thread.join(timeout=30)
243
+ if errors:
244
+ raise errors[0]
245
+ if thread.is_alive():
246
+ # Still inside a command: applying it again here could record it twice. The
247
+ # pending rows stay for the next worker (review finding 4).
248
+ log.warning("command thread of run %s did not stop; leaving commands", self._run_id)
249
+ return
250
+ self._apply_pending(loop, lid) # commands that arrived as the run ended
251
+
252
+ def _commands(self, run: RunRow, lid: str, stop: threading.Event,
253
+ errors: list[BaseException]) -> None:
254
+ """Applies user actions while `drive` runs, through a second `Loop` on its own ledger
255
+ connection; `loop` re-reads the ledger every step, so a pause lands mid-drive."""
256
+ try:
257
+ if self._creds is None:
258
+ raise RuntimeError("credentials are set up before driving")
259
+ loop = self._factory(run, self._creds, self._lease.check)
260
+ while not stop.is_set():
261
+ self._apply_pending(loop, lid)
262
+ stop.wait(self._poll_s)
263
+ except BaseException as exc:
264
+ errors.append(exc)
265
+
266
+ def _apply_pending(self, loop: Loop, lid: str) -> None:
267
+ for cmd in self._repo.pending_commands(self._run_id):
268
+ self._apply(loop, lid, cmd)
269
+
270
+ def _apply(self, loop: Loop, lid: str, cmd: CommandRow) -> None:
271
+ p = cmd.payload
272
+ try:
273
+ if cmd.kind == "pause":
274
+ loop.pause(lid, str(p.get("reason") or "paused by the user"))
275
+ elif cmd.kind == "resume":
276
+ loop.resume(lid)
277
+ elif cmd.kind == "stop":
278
+ loop.stop(lid)
279
+ elif cmd.kind == "checkpoint":
280
+ loop.decide_checkpoint(lid, str(p["checkpoint_id"]), p["decision"])
281
+ elif cmd.kind == "start":
282
+ raise ValueError("the run has already started")
283
+ else:
284
+ raise ValueError(f"unknown command {cmd.kind}")
285
+ except ValueError as exc:
286
+ self._repo.finish_command(cmd.id, "refused", self.scrub(str(exc)))
287
+ return
288
+ self._repo.finish_command(cmd.id, "done")
289
+
290
+
291
+ def _credential_reason(exc: BaseException) -> str:
292
+ if isinstance(exc, CredentialUnreadable):
293
+ return "a stored credential cannot be decrypted; store it again"
294
+ return f"credential {exc.args[0] if exc.args else ''} is not stored"
295
+
296
+
297
+ def _docker_unused_ssh(kind: str, config: dict[str, Any]) -> bool:
298
+ return kind == "ssh" and not config.get("use_docker") and bool(config.get("host_key"))
299
+
300
+
301
+ class PreflightWorker:
302
+ """Runs `executor.preflight()` with only the executor's own credential (KB-021)."""
303
+
304
+ def __init__(self, settings: Settings, db: Database, tenant_id: str, check_id: str, *,
305
+ executor_factory: ExecutorFactory | None = None,
306
+ clock: Callable[[], float] = time.time,
307
+ exit: Callable[[int], Any] = os._exit,
308
+ lease_ttl: float = leases.LEASE_TTL_S, renew_s: float = leases.RENEW_S) -> None:
309
+ self._settings, self._check_id = settings, check_id
310
+ self._repo = TenantRepo(db, tenant_id)
311
+ self._factory = executor_factory or (
312
+ lambda row, lookup: build_executor(row, lookup, settings))
313
+ self._lease = _Lease(db, f"preflight:{check_id}", clock=clock, ttl=lease_ttl,
314
+ renew_s=renew_s, on_lost=exit)
315
+
316
+ def run(self) -> int:
317
+ if not self._lease.acquire():
318
+ return EXIT_BUSY
319
+ try:
320
+ check = self._repo.check(self._check_id)
321
+ if check is None or check.state != "pending":
322
+ return EXIT_BUSY
323
+ row = self._repo.get_executor(check.executor_id)
324
+ if row is None:
325
+ self._repo.finish_check(check.id, {"ok": False, "problems": ["no executor"]})
326
+ return EXIT_OK
327
+ creds = Credentials(self._repo, self._settings.master_key,
328
+ set(credentials_named(row.kind, row.config)))
329
+ try:
330
+ report = self._factory(row, creds).preflight()
331
+ result = {"ok": report.ok,
332
+ "problems": [creds.scrub(str(p)) for p in report.problems]}
333
+ if report.ok and _docker_unused_ssh(row.kind, row.config):
334
+ # 08 §3 Onboarding step 3 (KB-035): offer containers when Docker answers.
335
+ result["docker"] = ssh_hosts.docker_answers_via(
336
+ ssh_chain.hops_of(row.config), creds)
337
+ except (CredentialMissing, CredentialUnreadable) as exc:
338
+ result = {"ok": False, "problems": [_credential_reason(exc)]}
339
+ except Exception as exc:
340
+ result = {"ok": False,
341
+ "problems": [creds.scrub(f"{type(exc).__name__}: {exc}")]}
342
+ self._repo.finish_check(check.id, result)
343
+ return EXIT_OK
344
+ finally:
345
+ self._lease.release()
346
+
347
+
348
+ CheckRunner = Callable[..., list[str]]
349
+
350
+
351
+ def run_check(kind: str, request: dict[str, Any], value: str,
352
+ lookup: Callable[[str], str] | None = None) -> list[str]:
353
+ """One credential check (08 §3, KB-029). Returns the problems; empty means passed. A check
354
+ of a model on an SSH machine opens its tunnel with `lookup`'s SSH keys (KB-036)."""
355
+ if kind == "llm":
356
+ target: dict[str, Any] = {"provider": request["provider"], "model": request["model"],
357
+ "credential": "key"}
358
+ if request.get("base_url"):
359
+ target["base_url"] = request["base_url"]
360
+ if request.get("public_only"):
361
+ target["public_only"] = True
362
+ if request.get("ssh"):
363
+ target["ssh"] = request["ssh"]
364
+ table = prices({f"{request['provider']}/{request['model']}": request["price"]}
365
+ if request.get("price") else {})
366
+ profile = {r: dict(target) for r in ("planner", "coder", "analyst")}
367
+ opened: list[Any] = []
368
+ if "ssh" in target:
369
+ if lookup is None:
370
+ return ["this check needs the SSH machine's keys"]
371
+ profile, opened = tunnels.open_tunnels(profile, lookup)
372
+ try:
373
+ report = LLM(profile, table, {"key": value}).preflight()
374
+ finally:
375
+ for t in opened:
376
+ t.close()
377
+ return list(report.problems)
378
+ if kind == "purescholar":
379
+ async def ps() -> None:
380
+ client = PureScholar(request["url"], value)
381
+ try:
382
+ await client.preflight()
383
+ finally:
384
+ await client.aclose()
385
+ asyncio.run(ps())
386
+ return []
387
+ if kind == "puretrace":
388
+ async def pt() -> None:
389
+ client = PureTrace(request["url"], value, "preflight", [])
390
+ try:
391
+ await client.preflight()
392
+ finally:
393
+ await client.aclose()
394
+ asyncio.run(pt())
395
+ return []
396
+ if kind == "source": # a literature-source key: one search request (KB-039)
397
+ return literature_sources.check(str(request["group"]), value)
398
+ raise ValueError(f"unknown check kind {kind!r}")
399
+
400
+
401
+ class CredentialCheckWorker:
402
+ """Checks one credential with only that credential decrypted (INV-2, KB-029)."""
403
+
404
+ def __init__(self, settings: Settings, db: Database, tenant_id: str, check_id: str, *,
405
+ runner: CheckRunner = run_check, clock: Callable[[], float] = time.time,
406
+ exit: Callable[[int], Any] = os._exit,
407
+ lease_ttl: float = leases.LEASE_TTL_S, renew_s: float = leases.RENEW_S) -> None:
408
+ self._settings, self._check_id, self._runner = settings, check_id, runner
409
+ self._repo = TenantRepo(db, tenant_id)
410
+ self._lease = _Lease(db, f"credcheck:{check_id}", clock=clock, ttl=lease_ttl,
411
+ renew_s=renew_s, on_lost=exit)
412
+
413
+ def run(self) -> int:
414
+ if not self._lease.acquire():
415
+ return EXIT_BUSY
416
+ try:
417
+ check = self._repo.credential_check(self._check_id)
418
+ if check is None or check.state != "pending":
419
+ return EXIT_BUSY
420
+ cred = self._repo.credential(check.credential_id)
421
+ if cred is None:
422
+ self._repo.finish_credential_check(check.id, {"ok": False,
423
+ "problems": ["no credential"]})
424
+ return EXIT_OK
425
+ ssh = check.request.get("ssh") or {}
426
+ names = {cred.name, *(str(h["key_credential"]) for h in ssh.get("hops", []))}
427
+ creds = Credentials(self._repo, self._settings.master_key, names)
428
+ try:
429
+ extra = [creds] if ssh else [] # the tunnel's SSH keys (KB-036)
430
+ problems = [creds.scrub(str(p)) for p in self._runner(
431
+ check.kind, check.request, creds(cred.name), *extra)]
432
+ except (CredentialMissing, CredentialUnreadable) as exc:
433
+ problems = [_credential_reason(exc)]
434
+ except Exception as exc:
435
+ problems = [creds.scrub(f"{type(exc).__name__}: {exc}")]
436
+ self._repo.finish_credential_check(check.id, {"ok": not problems,
437
+ "problems": problems})
438
+ return EXIT_OK
439
+ finally:
440
+ self._lease.release()
441
+
442
+
443
+ SearchRunner = Callable[[dict[str, Any], TenantRepo, Callable[[str], str], Settings],
444
+ dict[str, Any]]
445
+
446
+
447
+ def run_search(target: dict[str, Any], repo: TenantRepo, lookup: Callable[[str], str],
448
+ settings: Settings) -> dict[str, Any]:
449
+ """One model search (08 §3, KB-036). `target` was validated by the API."""
450
+ ports = [int(p) for p in target.get("ports", [])]
451
+ if target["kind"] == "this-computer":
452
+ return model_search.search_this_computer(ports).to_dict()
453
+ if target["kind"] == "machine":
454
+ mid = str(target["machine"])
455
+ conn = repo.resolve_machine(mid)
456
+ hops = [*conn.get("jumps", []),
457
+ {k: conn[k] for k in ("host", "user", "port", "key_credential", "host_key")}]
458
+ return model_search.search_machine(mid, hops, ports, lookup, settings.mode).to_dict()
459
+ key = lookup(str(target["credential"])) if target.get("credential") else ""
460
+ return model_search.search_url(str(target["url"]), key, settings.mode).to_dict()
461
+
462
+
463
+ def search_credentials(repo: TenantRepo, target: dict[str, Any]) -> set[str]:
464
+ """Only what this search needs: the machine's SSH keys, or the URL's API key (INV-2)."""
465
+ if target.get("kind") == "machine":
466
+ conn = repo.resolve_machine(str(target["machine"]))
467
+ return {str(conn["key_credential"]),
468
+ *(str(h["key_credential"]) for h in conn.get("jumps", []))}
469
+ return {str(target["credential"])} if target.get("credential") else set()
470
+
471
+
472
+ class SearchWorker:
473
+ """Looks for model servers and their models with only the keys the search needs."""
474
+
475
+ def __init__(self, settings: Settings, db: Database, tenant_id: str, search_id: str, *,
476
+ runner: SearchRunner = run_search, clock: Callable[[], float] = time.time,
477
+ exit: Callable[[int], Any] = os._exit,
478
+ lease_ttl: float = leases.LEASE_TTL_S, renew_s: float = leases.RENEW_S) -> None:
479
+ self._settings, self._search_id, self._runner = settings, search_id, runner
480
+ self._repo = TenantRepo(db, tenant_id)
481
+ self._lease = _Lease(db, f"llmsearch:{search_id}", clock=clock, ttl=lease_ttl,
482
+ renew_s=renew_s, on_lost=exit)
483
+
484
+ def run(self) -> int:
485
+ if not self._lease.acquire():
486
+ return EXIT_BUSY
487
+ try:
488
+ search = self._repo.get_search(self._search_id)
489
+ if search is None or search["state"] != "pending":
490
+ return EXIT_BUSY
491
+ target = search["target"]
492
+ try:
493
+ names = search_credentials(self._repo, target)
494
+ except (KeyError, ValueError) as exc:
495
+ self._repo.finish_search(search["id"], {"servers": [], "problems": [
496
+ f"the SSH machine cannot be used: {exc}"]})
497
+ return EXIT_OK
498
+ creds = Credentials(self._repo, self._settings.master_key, names)
499
+ try:
500
+ report = self._runner(target, self._repo, creds, self._settings)
501
+ report["problems"] = [creds.scrub(str(p)) for p in report.get("problems", [])]
502
+ except (CredentialMissing, CredentialUnreadable) as exc:
503
+ report = {"servers": [], "problems": [_credential_reason(exc)]}
504
+ except Exception as exc:
505
+ report = {"servers": [], "problems": [creds.scrub(f"{type(exc).__name__}: {exc}")]}
506
+ self._repo.finish_search(search["id"], report)
507
+ return EXIT_OK
508
+ finally:
509
+ self._lease.release()
510
+
511
+
512
+ ProfileRunner = Callable[[dict[str, dict[str, Any]], dict[str, dict[str, Any]],
513
+ Callable[[str], str]], list[str]]
514
+
515
+
516
+ def run_profile_check(roles: dict[str, dict[str, Any]], prices_: dict[str, dict[str, Any]],
517
+ lookup: Callable[[str], str]) -> list[str]:
518
+ """`llm.preflight()` for every role of an expanded profile, through its tunnels (KB-037).
519
+ Returns the problems; empty means passed."""
520
+ names = {str(s["credential"]) for spec in roles.values()
521
+ for s in (spec, spec.get("fallback") or {}) if isinstance(s, dict)
522
+ and s.get("credential")}
523
+ profile, opened = (tunnels.open_tunnels(roles, lookup)
524
+ if model_endpoints.ssh_credentials(roles) else (roles, []))
525
+ try:
526
+ report = LLM(profile, prices(prices_), {n: lookup(n) for n in names}).preflight()
527
+ return list(report.problems)
528
+ finally:
529
+ for t in opened:
530
+ t.close()
531
+
532
+
533
+ class ProfileCheckWorker:
534
+ """Checks one LLM profile with only its credentials and SSH keys decrypted (INV-2,
535
+ KB-037)."""
536
+
537
+ def __init__(self, settings: Settings, db: Database, tenant_id: str, check_id: str, *,
538
+ runner: ProfileRunner = run_profile_check,
539
+ clock: Callable[[], float] = time.time, exit: Callable[[int], Any] = os._exit,
540
+ lease_ttl: float = leases.LEASE_TTL_S, renew_s: float = leases.RENEW_S) -> None:
541
+ self._settings, self._check_id, self._runner = settings, check_id, runner
542
+ self._repo = TenantRepo(db, tenant_id)
543
+ self._lease = _Lease(db, f"profilecheck:{check_id}", clock=clock, ttl=lease_ttl,
544
+ renew_s=renew_s, on_lost=exit)
545
+
546
+ def run(self) -> int:
547
+ if not self._lease.acquire():
548
+ return EXIT_BUSY
549
+ try:
550
+ check = self._repo.profile_check(self._check_id)
551
+ if check is None or check["state"] != "pending":
552
+ return EXIT_BUSY
553
+ profile = self._repo.get_profile(check["profile_id"])
554
+ if profile is None:
555
+ self._repo.finish_profile_check(check["id"], {"ok": False,
556
+ "problems": ["no profile"]})
557
+ return EXIT_OK
558
+ # A proposed edit is checked as proposed; it is applied only if this passes
559
+ # (08 §3 Profile edits, KB-041).
560
+ proposal = check.get("proposal") or {}
561
+ roles_in = proposal.get("roles", profile.roles)
562
+ prices_in = proposal.get("prices", profile.prices)
563
+ try:
564
+ roles = {r: model_endpoints.expand(s, self._settings.mode,
565
+ self._repo.resolve_machine)
566
+ for r, s in roles_in.items()}
567
+ except (KeyError, ValueError) as exc:
568
+ self._repo.finish_profile_check(check["id"], {"ok": False, "problems": [
569
+ f"the profile's SSH machine cannot be used: {exc}"]})
570
+ return EXIT_OK
571
+ names = {str(s["credential"]) for spec in roles.values()
572
+ for s in (spec, spec.get("fallback") or {}) if isinstance(s, dict)
573
+ and s.get("credential")} | set(model_endpoints.ssh_credentials(roles))
574
+ creds = Credentials(self._repo, self._settings.master_key, names)
575
+ try:
576
+ problems = [creds.scrub(str(p))
577
+ for p in self._runner(roles, prices_in, creds)]
578
+ except (CredentialMissing, CredentialUnreadable) as exc:
579
+ problems = [_credential_reason(exc)]
580
+ except Exception as exc:
581
+ problems = [creds.scrub(f"{type(exc).__name__}: {exc}")]
582
+ self._repo.finish_profile_check(check["id"], {"ok": not problems,
583
+ "problems": problems})
584
+ return EXIT_OK
585
+ finally:
586
+ self._lease.release()
587
+
588
+
589
+ class LiteratureWorker:
590
+ """The literature-service task (08 §5 steps 3 and 12): decrypts only the LLM credentials
591
+ the bundled PureScholar uses, then starts it."""
592
+
593
+ def __init__(self, settings: Settings, db: Database, tenant_id: str, *,
594
+ docker: bundled.Docker | None = None,
595
+ clock: Callable[[], float] = time.time,
596
+ exit: Callable[[int], Any] = os._exit, timeout_s: float = bundled.READY_TIMEOUT_S,
597
+ lease_ttl: float = leases.LEASE_TTL_S, renew_s: float = leases.RENEW_S,
598
+ sleep: Callable[[float], None] = time.sleep) -> None:
599
+ self._settings = settings
600
+ self._repo = TenantRepo(db, tenant_id, settings.master_key) # seals the new key
601
+ self._starter = bundled.Starter(settings, self._repo, docker=docker, timeout_s=timeout_s)
602
+ self._sleep = sleep
603
+ self._lease = _Lease(db, f"literature:{tenant_id}", clock=clock, ttl=lease_ttl,
604
+ renew_s=renew_s, on_lost=exit)
605
+
606
+ def run(self) -> int:
607
+ if not self._lease.acquire():
608
+ return EXIT_BUSY
609
+ try:
610
+ row = self._repo.bundled()
611
+ if row is None or row["state"] not in ("pending", "starting"):
612
+ return EXIT_BUSY
613
+ creds = Credentials(self._repo, self._settings.master_key,
614
+ self._starter.credentials_needed())
615
+ try:
616
+ self._starter.run(creds, creds.scrub)
617
+ except (CredentialMissing, CredentialUnreadable) as exc:
618
+ self._repo.set_bundled("failed", _credential_reason(exc), expect=("starting",))
619
+ except Exception as exc:
620
+ self._repo.set_bundled("failed", creds.scrub(f"{type(exc).__name__}: {exc}"),
621
+ expect=("starting",))
622
+ # A model on this computer or an SSH machine: the bridge stays open while the
623
+ # backend uses it (08 §5 step 12, KB-036). Holding the lease meanwhile is safe:
624
+ # a restart request ends the wait first.
625
+ self._starter.keep_open(self._sleep)
626
+ return EXIT_OK
627
+ finally:
628
+ self._lease.release()
629
+
630
+
631
+ def main(argv: list[str]) -> int:
632
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
633
+ if len(argv) != 3 or argv[0] not in ("run", "preflight", "credcheck", "llmsearch",
634
+ "profilecheck", "literature"):
635
+ print("usage: python -m app.worker run|preflight|credcheck|llmsearch|profilecheck|"
636
+ "literature "
637
+ "<tenant> <id>",
638
+ file=sys.stderr)
639
+ return EXIT_ERROR
640
+ settings = Settings.from_env()
641
+ db = Database(settings.app_db)
642
+ kind, tenant_id, item = argv
643
+ if kind == "run":
644
+ return RunWorker(settings, db, tenant_id, item).run()
645
+ if kind == "credcheck":
646
+ return CredentialCheckWorker(settings, db, tenant_id, item).run()
647
+ if kind == "llmsearch":
648
+ return SearchWorker(settings, db, tenant_id, item).run()
649
+ if kind == "profilecheck":
650
+ return ProfileCheckWorker(settings, db, tenant_id, item).run()
651
+ if kind == "literature":
652
+ return LiteratureWorker(settings, db, tenant_id).run()
653
+ return PreflightWorker(settings, db, tenant_id, item).run()
654
+
655
+
656
+ if __name__ == "__main__":
657
+ sys.exit(main(sys.argv[1:]))