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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- 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:]}"
|