techtree 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 (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,679 @@
1
+ """Turning the static bundle into a working environment. Spec section 20.3.
2
+
3
+ Installing an engine means: materialize exactly the files the bundle digest
4
+ covers, build an isolated environment from the lock that shipped with them, ask
5
+ that environment what it actually is, and only then record it as installed.
6
+
7
+ Three properties are worth stating plainly.
8
+
9
+ *The lock is never regenerated here.* ``uv sync --frozen`` either agrees with
10
+ ``uv.lock`` or fails. Resolving dependencies on a participant's machine would
11
+ mean each participant ran a slightly different engine, which would make the
12
+ digest a label rather than a guarantee.
13
+
14
+ *The global Python environment is irrelevant.* The engine has its own
15
+ interpreter and its own site-packages; nothing it runs is resolved through the
16
+ caller's ``PATH`` or the interpreter Techtree itself runs on.
17
+
18
+ *An installation is recorded only after the environment answers correctly.*
19
+ The verification query runs inside the engine and reports its Python version,
20
+ the Verifiers version and the exact commit it was built from, and where each
21
+ shipped package resolved on disk. Those are compared with the descriptor before
22
+ ``installed.json`` is written, and ``installed.json`` is what makes the
23
+ directory count as an engine at all.
24
+
25
+ That last point is also how an interrupted install is safe. The environment is
26
+ built at its final content-addressed path — an editable path dependency and a
27
+ virtual environment both record absolute paths, so a directory built somewhere
28
+ else and moved afterwards is a broken environment, not a relocated one.
29
+ ``installed.json``, written last, is the thing that says the directory is
30
+ finished; a directory without one is not an installation, and installing
31
+ replaces it.
32
+
33
+ Decisions document 0004, ratified as 0007 R7, adds the other half of that
34
+ protocol: an install *announces itself* before it starts. A ``.installing-``
35
+ marker is written into the engines directory before the engine directory is
36
+ created and removed only after ``installed.json`` exists, so a machine can
37
+ always tell the difference between the two reasons an engine might be absent.
38
+ An engine that was never installed leaves nothing behind. An install that was
39
+ killed — the power went, the terminal was closed, the machine rebooted mid-sync
40
+ — leaves a marker, and the next setup finds it, says so, and removes the
41
+ unfinished directory before building again rather than retrying on top of it.
42
+
43
+ The marker lives beside the engine directory rather than inside it, for two
44
+ reasons that are both about not disturbing what it protects: the bundle copy
45
+ requires an empty destination, and an installed engine's directory is hashed
46
+ file by file when it is verified. A marker inside would be either impossible to
47
+ write first or something the digest rule would have to be taught to ignore.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import json
53
+ import os
54
+ import platform
55
+ import shutil
56
+ import subprocess
57
+ import sys
58
+ from collections.abc import Iterator
59
+ from contextlib import contextmanager
60
+ from dataclasses import dataclass
61
+ from datetime import UTC, datetime
62
+ from importlib.resources.abc import Traversable
63
+ from pathlib import Path
64
+ from typing import Final
65
+
66
+ from filelock import FileLock, Timeout
67
+
68
+ from techtree.canonical import digest_object
69
+ from techtree.engines.bundle import (
70
+ DEFAULT_ENGINE_NAME,
71
+ copy_engine_bundle,
72
+ embedded_engine_root,
73
+ engine_bundle_digest,
74
+ read_engine_descriptor,
75
+ )
76
+ from techtree.engines.registry import EngineRegistry, digest_from_directory_name
77
+ from techtree.errors import (
78
+ EngineError,
79
+ PrerequisiteError,
80
+ VerificationError,
81
+ )
82
+ from techtree.fs import (
83
+ atomic_write_json,
84
+ ensure_private_directory,
85
+ fsync_directory,
86
+ remove_tree,
87
+ )
88
+ from techtree.models.base import Digest
89
+ from techtree.models.engine import (
90
+ EngineDescriptor,
91
+ EngineInstallation,
92
+ EngineStatus,
93
+ normalize_host_platform,
94
+ )
95
+ from techtree.paths import TechtreePaths
96
+
97
+ __all__ = [
98
+ "INSTALLING_MARKER_PREFIX",
99
+ "INSTALL_LOCK_FILENAME",
100
+ "SYNC_TIMEOUT_SECONDS",
101
+ "VERIFICATION_TIMEOUT_SECONDS",
102
+ "EngineInstaller",
103
+ "EngineVerification",
104
+ "InterruptedInstall",
105
+ "find_uv",
106
+ ]
107
+
108
+ #: A first install downloads several hundred megabytes of wheels, so this is
109
+ #: generous. It exists to end a hung network call, not to bound normal work.
110
+ SYNC_TIMEOUT_SECONDS: Final = 1800.0
111
+ #: Asking a built environment what it is imports Verifiers once.
112
+ VERIFICATION_TIMEOUT_SECONDS: Final = 300.0
113
+ #: One installer at a time per Techtree home.
114
+ INSTALL_LOCK_FILENAME: Final = ".install.lock"
115
+ INSTALL_LOCK_TIMEOUT_SECONDS: Final = 1800.0
116
+
117
+ #: Written before an install starts and removed after it publishes. The engine
118
+ #: directory's own name is appended, so one marker names one engine and a
119
+ #: reader can tell which install did not finish.
120
+ INSTALLING_MARKER_PREFIX: Final = ".installing-"
121
+
122
+ #: What a marker says, for a person reading a directory rather than for code.
123
+ #: Nothing branches on these values: the marker's *existence* is the signal, so
124
+ #: an unreadable marker still means exactly what a readable one means.
125
+ _MARKER_SCHEMA: Final = "techtree.engine-installing.v1"
126
+
127
+ #: Asked of the engine, answered by the engine. Written as one expression-free
128
+ #: script so it can be passed with ``-c`` and cannot import anything of
129
+ #: Techtree's: the whole point is that the engine speaks for itself.
130
+ _VERIFICATION_QUERY: Final = """\
131
+ import importlib, json, platform, sys
132
+ from importlib.metadata import distribution
133
+
134
+ modules = {}
135
+ for name in sys.argv[1:]:
136
+ modules[name] = getattr(importlib.import_module(name), "__file__", None)
137
+
138
+ verifiers = distribution("verifiers")
139
+ recorded = json.loads(verifiers.read_text("direct_url.json") or "{}")
140
+
141
+ json.dump(
142
+ {
143
+ "python_version": platform.python_version(),
144
+ "python_executable": sys.executable,
145
+ "verifiers_version": verifiers.version,
146
+ "verifiers_commit": recorded.get("vcs_info", {}).get("commit_id"),
147
+ "modules": modules,
148
+ },
149
+ sys.stdout,
150
+ )
151
+ """
152
+
153
+ #: How many characters of a failed command's output travel into an error.
154
+ #: Enough to name the cause, short enough not to turn a message into a log.
155
+ _OUTPUT_EXCERPT = 2000
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class EngineVerification:
160
+ """What an installed engine says about itself."""
161
+
162
+ python_version: str
163
+ python_executable: str
164
+ verifiers_version: str
165
+ verifiers_commit: str
166
+ modules: dict[str, str]
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class InterruptedInstall:
171
+ """An install that announced itself and never published a result.
172
+
173
+ ``started_at`` and ``pid`` come from the marker and are diagnostics only.
174
+ A marker that cannot be read still describes an interrupted install; what
175
+ is missing is only the ability to say when it started.
176
+ """
177
+
178
+ digest: Digest
179
+ path: Path
180
+ marker: Path
181
+ started_at: str | None
182
+ pid: int | None
183
+
184
+
185
+ class EngineInstaller:
186
+ """Installs, verifies, and removes managed engines."""
187
+
188
+ def __init__(
189
+ self,
190
+ paths: TechtreePaths,
191
+ registry: EngineRegistry,
192
+ uv_executable: Path,
193
+ ) -> None:
194
+ self._paths = paths
195
+ self._registry = registry
196
+ self._uv = uv_executable
197
+
198
+ def install(self, digest: Digest | None = None) -> EngineStatus:
199
+ """Materialize, frozen-sync, verify, and mark installed."""
200
+ root = embedded_engine_root(DEFAULT_ENGINE_NAME)
201
+ descriptor = read_engine_descriptor(root)
202
+ bundle_digest = engine_bundle_digest(root)
203
+
204
+ if digest is not None and digest != bundle_digest:
205
+ raise EngineError(
206
+ f"this build ships engine {bundle_digest}, not {digest}",
207
+ code="engine_digest_unknown",
208
+ details={"requested": digest, "available": bundle_digest},
209
+ )
210
+
211
+ _require_supported_host(descriptor)
212
+ ensure_private_directory(self._paths.engines_dir)
213
+
214
+ with self._install_lock():
215
+ # Decisions 0004, ratified as 0007 R7: whatever a killed install
216
+ # left behind goes before another one starts, so nothing is ever
217
+ # built on top of a half-built environment.
218
+ self.discard_interrupted()
219
+
220
+ if self._registry.installation(bundle_digest) is not None:
221
+ verified = self._verified_status(bundle_digest, descriptor)
222
+ if verified is not None:
223
+ return verified
224
+ # The directory exists but the environment inside it no longer
225
+ # answers. There is nothing to preserve; build it again.
226
+ remove_tree(self._registry.path(bundle_digest))
227
+
228
+ return self._install_fresh(root, descriptor, bundle_digest)
229
+
230
+ def interrupted_installs(self) -> list[InterruptedInstall]:
231
+ """Report every engine whose install started and never published.
232
+
233
+ A marker beside a complete installation is not one of these: the
234
+ install finished and only the marker's removal was lost, so there is
235
+ nothing unfinished to report and nothing to rebuild.
236
+ """
237
+ if not self._paths.engines_dir.is_dir():
238
+ return []
239
+
240
+ found: list[InterruptedInstall] = []
241
+ for marker in sorted(self._paths.engines_dir.iterdir()):
242
+ digest = _digest_from_marker_name(marker.name)
243
+ if digest is None or not marker.is_file():
244
+ continue
245
+ if self._registry.installation(digest) is not None:
246
+ continue
247
+ started_at, pid = _read_marker(marker)
248
+ found.append(
249
+ InterruptedInstall(
250
+ digest=digest,
251
+ path=self._registry.path(digest),
252
+ marker=marker,
253
+ started_at=started_at,
254
+ pid=pid,
255
+ )
256
+ )
257
+ return found
258
+
259
+ def discard_interrupted(self) -> list[InterruptedInstall]:
260
+ """Remove what unfinished installs left behind, and say what was there.
261
+
262
+ Decisions 0004 step 5: a failed or interrupted install is removed
263
+ rather than resumed. Half of a virtual environment is not a smaller
264
+ engine — it is a directory whose contents nothing can vouch for — and
265
+ the only honest thing to do with one is build it again.
266
+
267
+ A marker left beside a *finished* install is dropped here too, in the
268
+ other order: the installation is complete, so only the marker goes.
269
+ """
270
+ discarded = self.interrupted_installs()
271
+ for install in discarded:
272
+ remove_tree(install.path)
273
+ _remove_marker(install.marker)
274
+ for marker in self._stale_markers_of_complete_installs():
275
+ _remove_marker(marker)
276
+ return discarded
277
+
278
+ def verify(self, digest: Digest) -> EngineStatus:
279
+ """Verify the installed bundle and the live environment."""
280
+ installation = self._registry.installation(digest)
281
+ if installation is None:
282
+ raise EngineError(
283
+ f"engine {digest} is not installed",
284
+ code="engine_not_installed",
285
+ details={"digest": digest, "path": str(self._registry.path(digest))},
286
+ )
287
+
288
+ installed_root = self._registry.path(digest)
289
+ recomputed = engine_bundle_digest(installed_root)
290
+ if recomputed != digest:
291
+ raise VerificationError(
292
+ f"the files of engine {digest} have changed since it was "
293
+ f"installed; they now hash to {recomputed}",
294
+ code="engine_bundle_modified",
295
+ details={"expected": digest, "recomputed": recomputed},
296
+ )
297
+
298
+ descriptor = read_engine_descriptor(installed_root)
299
+ self._check_environment(digest, descriptor)
300
+ return self._registry.status(digest)
301
+
302
+ def uninstall(self, digest: Digest) -> None:
303
+ """Remove an installed engine that is not the active one."""
304
+ if self._registry.active_digest() == digest:
305
+ raise EngineError(
306
+ f"engine {digest} is active; activate another engine before "
307
+ "removing it",
308
+ code="engine_active",
309
+ details={"digest": digest},
310
+ )
311
+ with self._install_lock():
312
+ remove_tree(self._registry.path(digest))
313
+
314
+ # -- steps -------------------------------------------------------------
315
+
316
+ def _install_fresh(
317
+ self,
318
+ root: Traversable,
319
+ descriptor: EngineDescriptor,
320
+ digest: Digest,
321
+ ) -> EngineStatus:
322
+ """Build one engine from scratch at its content-addressed path."""
323
+ destination = self._registry.path(digest)
324
+ # Anything already here is the residue of an install that did not
325
+ # finish, because a finished one would have been reused above.
326
+ remove_tree(destination)
327
+
328
+ # Written before the directory exists, so there is no moment where an
329
+ # engine is being built and nothing on disk says so.
330
+ marker = self._marker_path(digest)
331
+ _write_marker(marker, digest)
332
+ try:
333
+ copy_engine_bundle(root, destination)
334
+ self._sync(destination, descriptor)
335
+ verification = self._check_environment(digest, descriptor)
336
+ except BaseException:
337
+ remove_tree(destination)
338
+ _remove_marker(marker)
339
+ raise
340
+
341
+ self._registry.record(
342
+ EngineInstallation(
343
+ digest=digest,
344
+ installed_at=datetime.now(UTC),
345
+ python_executable=verification.python_executable,
346
+ descriptor_digest=digest_object(descriptor),
347
+ verified=True,
348
+ )
349
+ )
350
+ # Last, and only after the one publication signal exists. A crash in
351
+ # between leaves a marker beside a complete install, which the next
352
+ # setup recognizes as residue rather than as unfinished work.
353
+ _remove_marker(marker)
354
+ return self._registry.status(digest)
355
+
356
+ def _sync(self, destination: Path, descriptor: EngineDescriptor) -> None:
357
+ """Build the engine environment from its own lock, and only from it.
358
+
359
+ ``--frozen`` means the lock is the whole resolution: uv installs what it
360
+ says and never writes a new one. A lock that is missing, unreadable, or
361
+ does not cover the engine project is reported as its own failure,
362
+ because it means the bundle is damaged rather than the network being
363
+ unhappy — retrying would not help, reinstalling might.
364
+ """
365
+ completed = self._run(
366
+ [
367
+ str(self._uv),
368
+ "sync",
369
+ "--frozen",
370
+ "--project",
371
+ str(destination),
372
+ "--python",
373
+ descriptor.python_version,
374
+ ],
375
+ timeout=SYNC_TIMEOUT_SECONDS,
376
+ )
377
+ if completed.returncode == 0:
378
+ return
379
+
380
+ output = f"{completed.stdout}\n{completed.stderr}"
381
+ if "uv.lock" in output or "lockfile" in output:
382
+ raise EngineError(
383
+ "the engine lock cannot be used with the engine project; the "
384
+ f"engine files at {destination} are damaged or incomplete",
385
+ code="engine_lock_mismatch",
386
+ details={"path": str(destination), "detail": _excerpt(output)},
387
+ )
388
+ raise EngineError(
389
+ f"building the engine environment failed: {_excerpt(completed.stderr)}",
390
+ code="engine_sync_failed",
391
+ details={"exit_code": completed.returncode},
392
+ retryable=True,
393
+ )
394
+
395
+ def _check_environment(
396
+ self, digest: Digest, descriptor: EngineDescriptor
397
+ ) -> EngineVerification:
398
+ """Ask the engine what it is, and refuse anything but the right answer."""
399
+ verification = self._query(digest, descriptor)
400
+ engine_root = self._registry.path(digest).resolve()
401
+
402
+ _require(
403
+ verification.python_version.startswith(f"{descriptor.python_version}."),
404
+ f"engine runs Python {verification.python_version}, but the "
405
+ f"descriptor pins {descriptor.python_version}",
406
+ digest,
407
+ )
408
+ _require(
409
+ verification.verifiers_version == descriptor.verifiers_version,
410
+ f"engine holds Verifiers {verification.verifiers_version}, but the "
411
+ f"descriptor pins {descriptor.verifiers_version}",
412
+ digest,
413
+ )
414
+ _require(
415
+ verification.verifiers_commit == descriptor.verifiers_revision,
416
+ f"engine holds Verifiers commit {verification.verifiers_commit}, "
417
+ f"but the descriptor pins {descriptor.verifiers_revision}",
418
+ digest,
419
+ )
420
+
421
+ for package in descriptor.packages:
422
+ location = verification.modules.get(_module_name(package.name))
423
+ if location is None:
424
+ raise EngineError(
425
+ f"package {package.name} did not report where it is installed",
426
+ code="engine_verification_mismatch",
427
+ details={"digest": digest, "package": package.name},
428
+ )
429
+ _require(
430
+ Path(location).resolve().is_relative_to(engine_root),
431
+ f"package {package.name} imports from {location}, which is "
432
+ f"outside the engine at {engine_root}",
433
+ digest,
434
+ )
435
+
436
+ return verification
437
+
438
+ def _query(
439
+ self, digest: Digest, descriptor: EngineDescriptor
440
+ ) -> EngineVerification:
441
+ """Run the verification query inside the engine."""
442
+ python = self._registry.python(digest)
443
+ if not python.is_file():
444
+ raise EngineError(
445
+ f"engine {digest} has no interpreter at {python}",
446
+ code="engine_python_missing",
447
+ details={"digest": digest, "path": str(python)},
448
+ )
449
+
450
+ modules = [_module_name(package.name) for package in descriptor.packages]
451
+ completed = self._run(
452
+ [str(python), "-c", _VERIFICATION_QUERY, *modules],
453
+ timeout=VERIFICATION_TIMEOUT_SECONDS,
454
+ )
455
+ if completed.returncode != 0:
456
+ raise EngineError(
457
+ "the engine environment did not answer the verification query: "
458
+ f"{_excerpt(completed.stderr)}",
459
+ code="engine_verification_failed",
460
+ details={"digest": digest, "exit_code": completed.returncode},
461
+ )
462
+
463
+ try:
464
+ document = json.loads(completed.stdout)
465
+ return EngineVerification(
466
+ python_version=str(document["python_version"]),
467
+ python_executable=str(document["python_executable"]),
468
+ verifiers_version=str(document["verifiers_version"]),
469
+ verifiers_commit=str(document["verifiers_commit"]),
470
+ modules={
471
+ str(name): str(location)
472
+ for name, location in document["modules"].items()
473
+ if location is not None
474
+ },
475
+ )
476
+ except (KeyError, TypeError, ValueError, AttributeError) as error:
477
+ raise EngineError(
478
+ f"the engine verification query returned unusable output: {error}",
479
+ code="engine_verification_unreadable",
480
+ details={"digest": digest},
481
+ ) from error
482
+
483
+ def _verified_status(
484
+ self, digest: Digest, descriptor: EngineDescriptor
485
+ ) -> EngineStatus | None:
486
+ """Return the status of an existing installation that still works."""
487
+ try:
488
+ self._check_environment(digest, descriptor)
489
+ except EngineError:
490
+ return None
491
+ return self._registry.status(digest)
492
+
493
+ def _marker_path(self, digest: Digest) -> Path:
494
+ """Return where one engine's in-progress marker lives."""
495
+ return (
496
+ self._paths.engines_dir
497
+ / f"{INSTALLING_MARKER_PREFIX}{self._registry.path(digest).name}"
498
+ )
499
+
500
+ def _stale_markers_of_complete_installs(self) -> list[Path]:
501
+ """Return markers whose install finished after all."""
502
+ if not self._paths.engines_dir.is_dir():
503
+ return []
504
+ return [
505
+ marker
506
+ for marker in sorted(self._paths.engines_dir.iterdir())
507
+ if marker.is_file()
508
+ and (digest := _digest_from_marker_name(marker.name)) is not None
509
+ and self._registry.installation(digest) is not None
510
+ ]
511
+
512
+ @contextmanager
513
+ def _install_lock(self) -> Iterator[None]:
514
+ """Serialize installs within one Techtree home."""
515
+ lock = FileLock(
516
+ str(self._paths.engines_dir / INSTALL_LOCK_FILENAME),
517
+ timeout=INSTALL_LOCK_TIMEOUT_SECONDS,
518
+ )
519
+ try:
520
+ lock.acquire()
521
+ except Timeout as error:
522
+ raise EngineError(
523
+ "another Techtree process is installing an engine",
524
+ code="engine_install_locked",
525
+ details={"path": str(self._paths.engines_dir)},
526
+ retryable=True,
527
+ ) from error
528
+ try:
529
+ yield
530
+ finally:
531
+ lock.release()
532
+
533
+ def _run(
534
+ self, argv: list[str], *, timeout: float
535
+ ) -> subprocess.CompletedProcess[str]:
536
+ """Run one installation command.
537
+
538
+ Installation is the one place the caller's environment is passed
539
+ through: uv needs the user's proxy, certificate, cache, and index
540
+ configuration to reach a network the user can reach. Evaluation runs do
541
+ not, and :mod:`techtree.engines.runner` gives them a minimal one.
542
+ """
543
+ try:
544
+ return subprocess.run(
545
+ argv,
546
+ capture_output=True,
547
+ text=True,
548
+ check=False,
549
+ timeout=timeout,
550
+ stdin=subprocess.DEVNULL,
551
+ )
552
+ except subprocess.TimeoutExpired as error:
553
+ raise EngineError(
554
+ f"engine installation step timed out after {timeout:.0f}s",
555
+ code="engine_install_timeout",
556
+ details={"timeout_seconds": timeout},
557
+ retryable=True,
558
+ ) from error
559
+ except OSError as error:
560
+ raise EngineError(
561
+ f"engine installation step could not be started: "
562
+ f"{error.strerror or error}",
563
+ code="engine_install_unusable",
564
+ details={"argv0": argv[0]},
565
+ ) from error
566
+
567
+
568
+ def _write_marker(marker: Path, digest: Digest) -> None:
569
+ """Announce an install, durably, before anything is built."""
570
+ atomic_write_json(
571
+ marker,
572
+ {
573
+ "schema_version": _MARKER_SCHEMA,
574
+ "digest": digest,
575
+ "started_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
576
+ "pid": os.getpid(),
577
+ },
578
+ )
579
+
580
+
581
+ def _remove_marker(marker: Path) -> None:
582
+ """Withdraw an announcement, and make the removal durable."""
583
+ marker.unlink(missing_ok=True)
584
+ fsync_directory(marker.parent)
585
+
586
+
587
+ def _read_marker(marker: Path) -> tuple[str | None, int | None]:
588
+ """Return what a marker says about its install, when it can say anything."""
589
+ try:
590
+ document = json.loads(marker.read_bytes())
591
+ except (OSError, ValueError):
592
+ return None, None
593
+ if not isinstance(document, dict):
594
+ return None, None
595
+ started_at = document.get("started_at")
596
+ pid = document.get("pid")
597
+ return (
598
+ started_at if isinstance(started_at, str) else None,
599
+ pid if isinstance(pid, int) else None,
600
+ )
601
+
602
+
603
+ def _digest_from_marker_name(name: str) -> Digest | None:
604
+ """Return the engine digest one marker names, or None if it names none."""
605
+ if not name.startswith(INSTALLING_MARKER_PREFIX):
606
+ return None
607
+ return digest_from_directory_name(name[len(INSTALLING_MARKER_PREFIX) :])
608
+
609
+
610
+ def find_uv() -> Path:
611
+ """Locate uv, or say what is missing."""
612
+ found = shutil.which("uv")
613
+ if found is None:
614
+ raise EngineError(
615
+ "uv was not found on PATH; the managed evaluation engine is "
616
+ "installed with uv (https://docs.astral.sh/uv/getting-started/"
617
+ "installation/)",
618
+ code="uv_not_found",
619
+ retryable=True,
620
+ )
621
+ return Path(found)
622
+
623
+
624
+ def _require_supported_host(descriptor: EngineDescriptor) -> None:
625
+ """Refuse to install an engine that does not support this machine."""
626
+ try:
627
+ host = normalize_host_platform(sys.platform, platform.machine())
628
+ except PrerequisiteError as error:
629
+ raise EngineError(
630
+ str(error),
631
+ code="engine_host_unsupported",
632
+ details=dict(error.details),
633
+ ) from error
634
+
635
+ if host not in descriptor.supported_hosts:
636
+ raise EngineError(
637
+ f"the managed engine does not support {host}; it supports "
638
+ + ", ".join(descriptor.supported_hosts),
639
+ code="engine_host_unsupported",
640
+ details={
641
+ "host_platform": host,
642
+ "supported_hosts": list(descriptor.supported_hosts),
643
+ },
644
+ )
645
+
646
+
647
+ def _module_name(distribution_name: str) -> str:
648
+ """Return the module a distribution imports as.
649
+
650
+ Verifiers resolves a local taskset id to a module by replacing hyphens with
651
+ underscores (``docs/verifiers-pin.md``), and the shipped packages follow
652
+ the same rule.
653
+ """
654
+ return distribution_name.replace("-", "_")
655
+
656
+
657
+ def _require(condition: bool, message: str, digest: Digest) -> None:
658
+ if condition:
659
+ return
660
+ raise EngineError(
661
+ message,
662
+ code="engine_verification_mismatch",
663
+ details={"digest": digest},
664
+ )
665
+
666
+
667
+ def _excerpt(output: str) -> str:
668
+ """Return the tail of a command's output, which is where the reason is.
669
+
670
+ Installation is the one step that runs a subprocess with the caller's own
671
+ environment (see :meth:`_EngineInstaller._run`), so uv's output can quote
672
+ the caller's own configuration back. It is repeated word for word:
673
+ decision 0036 rules that Techtree does not guess at what a borrowed line
674
+ means before writing it down.
675
+ """
676
+ text = output.strip()
677
+ if len(text) <= _OUTPUT_EXCERPT:
678
+ return text
679
+ return f"...{text[-_OUTPUT_EXCERPT:]}"