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,235 @@
|
|
|
1
|
+
"""Which engines exist, where they live, and which one is active.
|
|
2
|
+
|
|
3
|
+
Spec section 20.4.
|
|
4
|
+
|
|
5
|
+
An engine is identified by the digest of its static bundle, and that digest is
|
|
6
|
+
also where it lives: ``<techtree home>/engines/sha256-<hex>/``. Two consequences
|
|
7
|
+
follow, and both are the point.
|
|
8
|
+
|
|
9
|
+
Nothing here decides anything about *content*. The registry answers questions
|
|
10
|
+
about the filesystem and about settings; whether the environment inside an
|
|
11
|
+
engine actually works is the installer's question, asked by running the engine.
|
|
12
|
+
|
|
13
|
+
An engine counts as installed only when its ``installed.json`` is present,
|
|
14
|
+
parses, and names the directory it sits in. That file is written last, after
|
|
15
|
+
verification, so a directory left behind by an interrupted install is reported
|
|
16
|
+
as absent rather than as broken — and the next install replaces it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from techtree.canonical import validate_digest
|
|
25
|
+
from techtree.engines.bundle import (
|
|
26
|
+
ENGINE_TOOLS,
|
|
27
|
+
INSTALLATION_FILENAME,
|
|
28
|
+
default_engine_digest,
|
|
29
|
+
)
|
|
30
|
+
from techtree.errors import EngineError, NotFoundError, ValidationError
|
|
31
|
+
from techtree.fs import atomic_write_json
|
|
32
|
+
from techtree.models.base import Digest
|
|
33
|
+
from techtree.models.engine import EngineInstallation, EngineStatus
|
|
34
|
+
from techtree.paths import TechtreePaths
|
|
35
|
+
from techtree.settings import Settings, load_settings, save_settings
|
|
36
|
+
|
|
37
|
+
__all__ = ["VENV_DIRECTORY", "EngineRegistry", "digest_from_directory_name"]
|
|
38
|
+
|
|
39
|
+
#: uv's project environment, relative to the engine root.
|
|
40
|
+
VENV_DIRECTORY: Final = ".venv"
|
|
41
|
+
_BIN_DIRECTORY: Final = "bin"
|
|
42
|
+
_TOOLS_DIRECTORY: Final = "tools"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EngineRegistry:
|
|
46
|
+
"""Locates engines and records which one is active."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, paths: TechtreePaths, settings: Settings) -> None:
|
|
49
|
+
self._paths = paths
|
|
50
|
+
self._settings = settings
|
|
51
|
+
|
|
52
|
+
# -- what exists ------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def known(self) -> list[Digest]:
|
|
55
|
+
"""Return the packaged digest and every installed digest."""
|
|
56
|
+
digests = {default_engine_digest(), *self.installed()}
|
|
57
|
+
return sorted(digests)
|
|
58
|
+
|
|
59
|
+
def installed(self) -> list[Digest]:
|
|
60
|
+
"""Return the digests of complete installations."""
|
|
61
|
+
if not self._paths.engines_dir.is_dir():
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
found: list[Digest] = []
|
|
65
|
+
for directory in sorted(self._paths.engines_dir.iterdir()):
|
|
66
|
+
if not directory.is_dir():
|
|
67
|
+
continue
|
|
68
|
+
digest = digest_from_directory_name(directory.name)
|
|
69
|
+
if digest is None:
|
|
70
|
+
continue
|
|
71
|
+
if self.installation(digest) is not None:
|
|
72
|
+
found.append(digest)
|
|
73
|
+
return found
|
|
74
|
+
|
|
75
|
+
def installation(self, digest: Digest) -> EngineInstallation | None:
|
|
76
|
+
"""Return the recorded installation, or None when there is not one.
|
|
77
|
+
|
|
78
|
+
A record that does not name this directory is not this engine's record,
|
|
79
|
+
so it is treated as absent rather than trusted.
|
|
80
|
+
"""
|
|
81
|
+
marker = self.path(digest) / INSTALLATION_FILENAME
|
|
82
|
+
try:
|
|
83
|
+
raw = marker.read_bytes()
|
|
84
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
installation = EngineInstallation.model_validate_json(raw)
|
|
89
|
+
except ValueError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
if installation.digest != digest:
|
|
93
|
+
return None
|
|
94
|
+
return installation
|
|
95
|
+
|
|
96
|
+
def record(self, installation: EngineInstallation) -> None:
|
|
97
|
+
"""Write the installation marker that completes an install."""
|
|
98
|
+
atomic_write_json(
|
|
99
|
+
self.path(installation.digest) / INSTALLATION_FILENAME, installation
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# -- where things are -------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def path(self, digest: Digest) -> Path:
|
|
105
|
+
"""Return the installation directory for one engine."""
|
|
106
|
+
return self._paths.engine_dir(validate_digest(digest))
|
|
107
|
+
|
|
108
|
+
def python(self, digest: Digest) -> Path:
|
|
109
|
+
"""Return the engine's managed interpreter."""
|
|
110
|
+
return self.path(digest) / VENV_DIRECTORY / _BIN_DIRECTORY / "python"
|
|
111
|
+
|
|
112
|
+
def executable(self, digest: Digest, name: str) -> Path:
|
|
113
|
+
"""Return one console script inside the engine environment.
|
|
114
|
+
|
|
115
|
+
The pinned Verifiers build installs its scripts under generic names —
|
|
116
|
+
``validate``, ``eval`` — so they are always addressed by absolute path
|
|
117
|
+
inside the engine and never resolved through ``PATH``
|
|
118
|
+
(``docs/verifiers-pin.md``, finding C3).
|
|
119
|
+
"""
|
|
120
|
+
return self.path(digest) / VENV_DIRECTORY / _BIN_DIRECTORY / _plain(name)
|
|
121
|
+
|
|
122
|
+
def tool_path(self, digest: Digest, tool: str) -> Path:
|
|
123
|
+
"""Return one engine helper shipped inside the bundle.
|
|
124
|
+
|
|
125
|
+
Decisions document 0003 A3. Only the helpers the bundle declares can be
|
|
126
|
+
addressed, so a caller cannot turn this into "run any file you like
|
|
127
|
+
with the engine interpreter".
|
|
128
|
+
"""
|
|
129
|
+
if tool not in ENGINE_TOOLS:
|
|
130
|
+
raise NotFoundError(
|
|
131
|
+
f"the engine bundle ships no tool named {tool!r}",
|
|
132
|
+
code="engine_tool_unknown",
|
|
133
|
+
details={"tool": tool, "available": list(ENGINE_TOOLS)},
|
|
134
|
+
)
|
|
135
|
+
return self.path(digest) / _TOOLS_DIRECTORY / tool
|
|
136
|
+
|
|
137
|
+
# -- status and activation --------------------------------------------
|
|
138
|
+
|
|
139
|
+
def status(self, digest: Digest) -> EngineStatus:
|
|
140
|
+
"""Return what is currently true about one engine."""
|
|
141
|
+
installation = self.installation(digest)
|
|
142
|
+
active = self.active_digest() == digest
|
|
143
|
+
|
|
144
|
+
if installation is None:
|
|
145
|
+
return EngineStatus(
|
|
146
|
+
digest=digest,
|
|
147
|
+
installed=False,
|
|
148
|
+
active=False,
|
|
149
|
+
verified=False,
|
|
150
|
+
path=str(self.path(digest)),
|
|
151
|
+
python_executable=None,
|
|
152
|
+
detail=_absent_detail(digest, active=active),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return EngineStatus(
|
|
156
|
+
digest=digest,
|
|
157
|
+
installed=True,
|
|
158
|
+
active=active,
|
|
159
|
+
verified=installation.verified,
|
|
160
|
+
path=str(self.path(digest)),
|
|
161
|
+
python_executable=installation.python_executable,
|
|
162
|
+
detail=_present_detail(installation.verified, active=active),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def active_digest(self) -> Digest | None:
|
|
166
|
+
"""Return the digest of the active engine, if one is recorded."""
|
|
167
|
+
return self._settings.active_engine_digest
|
|
168
|
+
|
|
169
|
+
def set_active(self, digest: Digest) -> Settings:
|
|
170
|
+
"""Record one verified engine as active.
|
|
171
|
+
|
|
172
|
+
Activation is a claim the CLI makes to every later command, so it is
|
|
173
|
+
refused unless the engine is installed and its installation was
|
|
174
|
+
verified. Verification itself belongs to the installer, which runs the
|
|
175
|
+
engine; this reads the result it recorded.
|
|
176
|
+
"""
|
|
177
|
+
installation = self.installation(validate_digest(digest))
|
|
178
|
+
if installation is None:
|
|
179
|
+
raise EngineError(
|
|
180
|
+
f"engine {digest} is not installed, so it cannot be made active",
|
|
181
|
+
code="engine_not_installed",
|
|
182
|
+
details={"digest": digest, "path": str(self.path(digest))},
|
|
183
|
+
)
|
|
184
|
+
if not installation.verified:
|
|
185
|
+
raise EngineError(
|
|
186
|
+
f"engine {digest} was never verified, so it cannot be made active",
|
|
187
|
+
code="engine_not_verified",
|
|
188
|
+
details={"digest": digest},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Settings are re-read rather than written from this object's copy, so
|
|
192
|
+
# concurrently changed preferences are not reverted by an activation.
|
|
193
|
+
settings = load_settings(self._paths).model_copy(
|
|
194
|
+
update={"active_engine_digest": digest}
|
|
195
|
+
)
|
|
196
|
+
save_settings(self._paths, settings)
|
|
197
|
+
self._settings = settings
|
|
198
|
+
return settings
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _plain(name: str) -> str:
|
|
202
|
+
"""Reject anything that is not a bare file name."""
|
|
203
|
+
if not name or "/" in name or name in {".", ".."}:
|
|
204
|
+
raise EngineError(
|
|
205
|
+
f"{name!r} is not an engine executable name",
|
|
206
|
+
code="engine_executable_invalid",
|
|
207
|
+
details={"name": name},
|
|
208
|
+
)
|
|
209
|
+
return name
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def digest_from_directory_name(name: str) -> Digest | None:
|
|
213
|
+
"""Return the digest a directory name encodes, or None if it encodes none."""
|
|
214
|
+
algorithm, separator, hexadecimal = name.partition("-")
|
|
215
|
+
if separator != "-" or algorithm != "sha256":
|
|
216
|
+
return None
|
|
217
|
+
candidate = f"{algorithm}:{hexadecimal}"
|
|
218
|
+
try:
|
|
219
|
+
return validate_digest(candidate)
|
|
220
|
+
except ValidationError:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _absent_detail(digest: Digest, *, active: bool) -> str:
|
|
225
|
+
if active:
|
|
226
|
+
return (
|
|
227
|
+
f"engine {digest} is recorded as active but is not installed; "
|
|
228
|
+
"install it again"
|
|
229
|
+
)
|
|
230
|
+
return "not installed"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _present_detail(verified: bool, *, active: bool) -> str:
|
|
234
|
+
state = "installed and verified" if verified else "installed but not verified"
|
|
235
|
+
return f"{state}, {'active' if active else 'not the active engine'}"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Running one command inside the managed engine. Spec section 20.5.
|
|
2
|
+
|
|
3
|
+
Two rules shape every invocation.
|
|
4
|
+
|
|
5
|
+
*Absolute paths, never ``PATH``.* The pinned Verifiers build installs console
|
|
6
|
+
scripts under names as generic as ``validate`` and ``eval``
|
|
7
|
+
(``docs/verifiers-pin.md``, finding C3). Resolving those through the caller's
|
|
8
|
+
``PATH`` would let anything on the machine answer for the engine, so the engine
|
|
9
|
+
addresses its own executables by absolute path and the environment it runs them
|
|
10
|
+
in carries a ``PATH`` for the operating system's benefit, not for lookup.
|
|
11
|
+
|
|
12
|
+
*A minimal environment.* Model-free validation needs no provider credential,
|
|
13
|
+
and a subprocess that never receives one cannot leak one. The child gets
|
|
14
|
+
``PATH``, ``HOME``, and ``TMPDIR``, plus whatever the caller passes explicitly.
|
|
15
|
+
Everything else in the parent environment — API keys, tokens, cloud
|
|
16
|
+
credentials, the caller's Python configuration — is dropped.
|
|
17
|
+
|
|
18
|
+
The result of a run is data, not a decision. ``validate`` exits ``0`` even when
|
|
19
|
+
every task fails (``docs/verifiers-pin.md``, finding C2), so nothing here maps
|
|
20
|
+
an exit code onto a verdict; callers read the summary the engine wrote.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Final
|
|
32
|
+
|
|
33
|
+
from techtree.engines.registry import EngineRegistry
|
|
34
|
+
from techtree.errors import EngineError
|
|
35
|
+
from techtree.models.base import Digest
|
|
36
|
+
|
|
37
|
+
__all__ = ["INHERITED_ENVIRONMENT", "EngineProcessResult", "EngineRunner"]
|
|
38
|
+
|
|
39
|
+
#: The only variables an engine process inherits from its parent. ``PATH`` so
|
|
40
|
+
#: the child can find ordinary system tools, ``HOME`` because caches and
|
|
41
|
+
#: configuration hang off it, ``TMPDIR`` so scratch files land where the host
|
|
42
|
+
#: expects them.
|
|
43
|
+
INHERITED_ENVIRONMENT: Final[tuple[str, ...]] = ("PATH", "HOME", "TMPDIR")
|
|
44
|
+
|
|
45
|
+
#: Prefix of the variables a caller may add. Anything else has to be passed
|
|
46
|
+
#: deliberately by name, which keeps "just forward the environment" from being
|
|
47
|
+
#: the easy option.
|
|
48
|
+
_TECHTREE_PREFIX: Final = "TECHTREE_"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class EngineProcessResult:
|
|
53
|
+
"""What one engine command did."""
|
|
54
|
+
|
|
55
|
+
argv: tuple[str, ...]
|
|
56
|
+
exit_code: int
|
|
57
|
+
stdout: str
|
|
58
|
+
stderr: str
|
|
59
|
+
duration_seconds: float
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EngineRunner:
|
|
63
|
+
"""Runs commands inside one installed engine."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, registry: EngineRegistry, digest: Digest) -> None:
|
|
66
|
+
self._registry = registry
|
|
67
|
+
self._digest = digest
|
|
68
|
+
|
|
69
|
+
def run(
|
|
70
|
+
self,
|
|
71
|
+
executable: str,
|
|
72
|
+
args: Sequence[str],
|
|
73
|
+
*,
|
|
74
|
+
timeout: float,
|
|
75
|
+
env: Mapping[str, str] | None = None,
|
|
76
|
+
cwd: Path | None = None,
|
|
77
|
+
) -> EngineProcessResult:
|
|
78
|
+
"""Run one console script from the engine environment."""
|
|
79
|
+
return self._execute(
|
|
80
|
+
[str(self._registry.executable(self._digest, executable)), *args],
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
env=env,
|
|
83
|
+
cwd=cwd,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def run_python_script(
|
|
87
|
+
self,
|
|
88
|
+
script: Path,
|
|
89
|
+
args: Sequence[str],
|
|
90
|
+
*,
|
|
91
|
+
timeout: float,
|
|
92
|
+
env: Mapping[str, str] | None = None,
|
|
93
|
+
) -> EngineProcessResult:
|
|
94
|
+
"""Run one Python file with the engine's own interpreter."""
|
|
95
|
+
return self._execute(
|
|
96
|
+
[str(self._registry.python(self._digest)), str(script), *args],
|
|
97
|
+
timeout=timeout,
|
|
98
|
+
env=env,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _execute(
|
|
102
|
+
self,
|
|
103
|
+
argv: Sequence[str],
|
|
104
|
+
*,
|
|
105
|
+
timeout: float,
|
|
106
|
+
env: Mapping[str, str] | None,
|
|
107
|
+
cwd: Path | None = None,
|
|
108
|
+
) -> EngineProcessResult:
|
|
109
|
+
program = Path(argv[0])
|
|
110
|
+
if not program.is_file():
|
|
111
|
+
raise EngineError(
|
|
112
|
+
f"engine {self._digest} has no executable at {program}; "
|
|
113
|
+
"install the engine again",
|
|
114
|
+
code="engine_executable_missing",
|
|
115
|
+
details={"digest": self._digest, "path": str(program)},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
started = time.monotonic()
|
|
119
|
+
try:
|
|
120
|
+
completed = subprocess.run(
|
|
121
|
+
list(argv),
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
check=False,
|
|
125
|
+
timeout=timeout,
|
|
126
|
+
stdin=subprocess.DEVNULL,
|
|
127
|
+
env=engine_environment(env),
|
|
128
|
+
cwd=None if cwd is None else str(cwd),
|
|
129
|
+
)
|
|
130
|
+
except subprocess.TimeoutExpired as error:
|
|
131
|
+
raise EngineError(
|
|
132
|
+
f"engine command did not finish within {timeout:.0f}s: {program.name}",
|
|
133
|
+
code="engine_command_timeout",
|
|
134
|
+
details={"digest": self._digest, "timeout_seconds": timeout},
|
|
135
|
+
retryable=True,
|
|
136
|
+
) from error
|
|
137
|
+
except OSError as error:
|
|
138
|
+
raise EngineError(
|
|
139
|
+
f"engine command could not be started: {error.strerror or error}",
|
|
140
|
+
code="engine_command_unusable",
|
|
141
|
+
details={"digest": self._digest, "path": str(program)},
|
|
142
|
+
) from error
|
|
143
|
+
|
|
144
|
+
return EngineProcessResult(
|
|
145
|
+
argv=tuple(argv),
|
|
146
|
+
exit_code=completed.returncode,
|
|
147
|
+
stdout=completed.stdout,
|
|
148
|
+
stderr=completed.stderr,
|
|
149
|
+
duration_seconds=time.monotonic() - started,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def engine_environment(extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
154
|
+
"""Build the minimal environment an engine process runs in."""
|
|
155
|
+
environment = {
|
|
156
|
+
name: os.environ[name] for name in INHERITED_ENVIRONMENT if name in os.environ
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for name, value in (extra or {}).items():
|
|
160
|
+
if not name.startswith(_TECHTREE_PREFIX) and name not in INHERITED_ENVIRONMENT:
|
|
161
|
+
raise EngineError(
|
|
162
|
+
f"{name} cannot be passed to an engine process; only "
|
|
163
|
+
f"{_TECHTREE_PREFIX}* variables and "
|
|
164
|
+
f"{', '.join(INHERITED_ENVIRONMENT)} are allowed",
|
|
165
|
+
code="engine_environment_rejected",
|
|
166
|
+
details={"variable": name},
|
|
167
|
+
)
|
|
168
|
+
environment[name] = value
|
|
169
|
+
|
|
170
|
+
return environment
|
techtree/errors.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Typed Techtree errors and the exit codes they map to. Spec section 10.5.
|
|
2
|
+
|
|
3
|
+
Every failure a user can provoke is one of the classes below. Each class fixes
|
|
4
|
+
a machine-stable ``code``, a documented process exit code, and whether retrying
|
|
5
|
+
the same command could plausibly succeed. Call sites override those defaults
|
|
6
|
+
only when they have something more specific to say.
|
|
7
|
+
|
|
8
|
+
Two rules shape this module:
|
|
9
|
+
|
|
10
|
+
* ``details`` is machine-facing and travels into JSON output, so it carries
|
|
11
|
+
identifiers, counts, and paths. Nothing filters it: decision 0036 removed
|
|
12
|
+
every secret-shaped-string detector from this project, so what a call site
|
|
13
|
+
puts in ``details`` is what a caller reads, including when the call site is
|
|
14
|
+
forwarding a subprocess's own words.
|
|
15
|
+
* Human-facing text is actionable. Tracebacks are debugging aids for stderr,
|
|
16
|
+
not part of the contract, which is why
|
|
17
|
+
:func:`stable_exception_message` exists for the unexpected-exception path.
|
|
18
|
+
|
|
19
|
+
An error can also carry the ``NextAction`` list the CLI should offer, so that
|
|
20
|
+
the code which knows *why* something failed is the code that says what to do
|
|
21
|
+
about it. The shapes those actions and the projected ``CliError`` are spelled
|
|
22
|
+
in belong to :mod:`techtree.models.cli`. That module is imported only for
|
|
23
|
+
typing, and inside :func:`error_to_cli_error`, because the model package
|
|
24
|
+
imports this one: errors sit underneath models, and speaking a model's
|
|
25
|
+
vocabulary must not invert that.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from collections.abc import Mapping, Sequence
|
|
32
|
+
from typing import TYPE_CHECKING, ClassVar, Final
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from techtree.models.base import JsonValue
|
|
36
|
+
from techtree.models.cli import CliError, NextAction
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AuthenticationError",
|
|
40
|
+
"CancellationError",
|
|
41
|
+
"ConflictError",
|
|
42
|
+
"EngineError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"PolicyError",
|
|
45
|
+
"PrerequisiteError",
|
|
46
|
+
"RunError",
|
|
47
|
+
"TechtreeError",
|
|
48
|
+
"UsageError",
|
|
49
|
+
"ValidationError",
|
|
50
|
+
"VerificationError",
|
|
51
|
+
"error_to_cli_error",
|
|
52
|
+
"exit_code_for",
|
|
53
|
+
"stable_exception_message",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Exit codes
|
|
59
|
+
#
|
|
60
|
+
# These are part of the CLI contract: a host agent branches on them without
|
|
61
|
+
# parsing output. Values are append-only; never reuse a retired number.
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
EXIT_OK: Final = 0
|
|
65
|
+
EXIT_ERROR: Final = 1
|
|
66
|
+
EXIT_USAGE: Final = 2
|
|
67
|
+
EXIT_VALIDATION: Final = 3
|
|
68
|
+
EXIT_PREREQUISITE: Final = 4
|
|
69
|
+
EXIT_NOT_FOUND: Final = 5
|
|
70
|
+
EXIT_CONFLICT: Final = 6
|
|
71
|
+
EXIT_AUTHENTICATION: Final = 7
|
|
72
|
+
EXIT_POLICY: Final = 8
|
|
73
|
+
EXIT_ENGINE: Final = 9
|
|
74
|
+
EXIT_RUN: Final = 10
|
|
75
|
+
EXIT_VERIFICATION: Final = 11
|
|
76
|
+
#: The shell convention for "terminated by SIGINT", which is what a cancelled
|
|
77
|
+
#: run is from the caller's point of view.
|
|
78
|
+
EXIT_CANCELLED: Final = 130
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Hierarchy
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TechtreeError(Exception):
|
|
87
|
+
"""Base class for every failure Techtree reports on purpose."""
|
|
88
|
+
|
|
89
|
+
default_code: ClassVar[str] = "techtree_error"
|
|
90
|
+
default_exit_code: ClassVar[int] = EXIT_ERROR
|
|
91
|
+
default_retryable: ClassVar[bool] = False
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
message: str,
|
|
96
|
+
*,
|
|
97
|
+
code: str | None = None,
|
|
98
|
+
exit_code: int | None = None,
|
|
99
|
+
retryable: bool | None = None,
|
|
100
|
+
details: Mapping[str, JsonValue] | None = None,
|
|
101
|
+
next_actions: Sequence[NextAction] | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
super().__init__(message)
|
|
104
|
+
self.message = message
|
|
105
|
+
self.code = self.default_code if code is None else code
|
|
106
|
+
self.exit_code = self.default_exit_code if exit_code is None else exit_code
|
|
107
|
+
self.retryable = self.default_retryable if retryable is None else retryable
|
|
108
|
+
self.details: dict[str, JsonValue] = dict(details or {})
|
|
109
|
+
#: What the caller could do about this, in the CLI's own vocabulary.
|
|
110
|
+
#: Raising code fills this in when it knows; the CLI never invents it.
|
|
111
|
+
self.next_actions: list[NextAction] = list(next_actions or ())
|
|
112
|
+
|
|
113
|
+
def __str__(self) -> str:
|
|
114
|
+
return self.message
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class UsageError(TechtreeError):
|
|
118
|
+
"""The command, its options, or their combination is not valid."""
|
|
119
|
+
|
|
120
|
+
default_code: ClassVar[str] = "usage_error"
|
|
121
|
+
default_exit_code: ClassVar[int] = EXIT_USAGE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ValidationError(TechtreeError):
|
|
125
|
+
"""Input data or a stored document failed protocol validation."""
|
|
126
|
+
|
|
127
|
+
default_code: ClassVar[str] = "validation_error"
|
|
128
|
+
default_exit_code: ClassVar[int] = EXIT_VALIDATION
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PrerequisiteError(TechtreeError):
|
|
132
|
+
"""Something that had to be done first has not been done."""
|
|
133
|
+
|
|
134
|
+
default_code: ClassVar[str] = "prerequisite_error"
|
|
135
|
+
default_exit_code: ClassVar[int] = EXIT_PREREQUISITE
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class NotFoundError(TechtreeError):
|
|
139
|
+
"""A named object does not exist."""
|
|
140
|
+
|
|
141
|
+
default_code: ClassVar[str] = "not_found"
|
|
142
|
+
default_exit_code: ClassVar[int] = EXIT_NOT_FOUND
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ConflictError(TechtreeError):
|
|
146
|
+
"""The requested change collides with existing immutable state."""
|
|
147
|
+
|
|
148
|
+
default_code: ClassVar[str] = "conflict"
|
|
149
|
+
default_exit_code: ClassVar[int] = EXIT_CONFLICT
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AuthenticationError(TechtreeError):
|
|
153
|
+
"""A credential is missing, rejected, or expired."""
|
|
154
|
+
|
|
155
|
+
default_code: ClassVar[str] = "authentication_error"
|
|
156
|
+
default_exit_code: ClassVar[int] = EXIT_AUTHENTICATION
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class EngineError(TechtreeError):
|
|
160
|
+
"""The managed engine could not be installed, resolved, or invoked."""
|
|
161
|
+
|
|
162
|
+
default_code: ClassVar[str] = "engine_error"
|
|
163
|
+
default_exit_code: ClassVar[int] = EXIT_ENGINE
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class RunError(TechtreeError):
|
|
167
|
+
"""A run failed, or a run operation is illegal in the current phase."""
|
|
168
|
+
|
|
169
|
+
default_code: ClassVar[str] = "run_error"
|
|
170
|
+
default_exit_code: ClassVar[int] = EXIT_RUN
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class VerificationError(TechtreeError):
|
|
174
|
+
"""A digest, signature, or membership commitment did not verify."""
|
|
175
|
+
|
|
176
|
+
default_code: ClassVar[str] = "verification_error"
|
|
177
|
+
default_exit_code: ClassVar[int] = EXIT_VERIFICATION
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CancellationError(TechtreeError):
|
|
181
|
+
"""Work stopped because it was cancelled."""
|
|
182
|
+
|
|
183
|
+
default_code: ClassVar[str] = "cancelled"
|
|
184
|
+
default_exit_code: ClassVar[int] = EXIT_CANCELLED
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PolicyError(TechtreeError):
|
|
188
|
+
"""A data-rights or publication policy forbids the request.
|
|
189
|
+
|
|
190
|
+
Data-policy contradictions raise this and nothing else, so a caller can
|
|
191
|
+
distinguish "your inputs disagree about rights" from "your inputs are
|
|
192
|
+
malformed".
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
default_code: ClassVar[str] = "policy_error"
|
|
196
|
+
default_exit_code: ClassVar[int] = EXIT_POLICY
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Message stability
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
#: A memory address as CPython writes one into a repr. It changes on every
|
|
204
|
+
#: process, so an error quoting one would not compare equal to itself between
|
|
205
|
+
#: runs. Normalising it is about stability, not about secrecy: decision 0036
|
|
206
|
+
#: is explicit that this project detects no secret-shaped strings anywhere.
|
|
207
|
+
_MEMORY_ADDRESS = re.compile(r"\b0x[0-9a-fA-F]{6,}\b")
|
|
208
|
+
_WHITESPACE_RUN = re.compile(r"\s+")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def stable_exception_message(error: Exception) -> str:
|
|
212
|
+
"""Return one exception's message as a single stable line.
|
|
213
|
+
|
|
214
|
+
Line structure is flattened so a message reads as one line wherever it is
|
|
215
|
+
shown, and memory addresses are normalised so the same failure produces
|
|
216
|
+
the same words twice. Nothing else is altered: whatever the exception
|
|
217
|
+
said, including anything a subprocess said through it, is what comes out.
|
|
218
|
+
"""
|
|
219
|
+
text = _WHITESPACE_RUN.sub(" ", str(error)).strip()
|
|
220
|
+
if not text:
|
|
221
|
+
return type(error).__name__
|
|
222
|
+
return _MEMORY_ADDRESS.sub("0x<address>", text)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# CLI projection
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def error_to_cli_error(error: TechtreeError) -> CliError:
|
|
231
|
+
"""Convert an internal typed error to CLI error data.
|
|
232
|
+
|
|
233
|
+
The message is flattened by :func:`stable_exception_message`; ``details``
|
|
234
|
+
travels exactly as the raising call site built it.
|
|
235
|
+
|
|
236
|
+
``next_actions`` is not part of ``CliError``; it belongs to the envelope,
|
|
237
|
+
and the CLI reads it from the error directly.
|
|
238
|
+
"""
|
|
239
|
+
from techtree.models.cli import CliError
|
|
240
|
+
|
|
241
|
+
return CliError(
|
|
242
|
+
code=error.code,
|
|
243
|
+
message=stable_exception_message(error),
|
|
244
|
+
retryable=error.retryable,
|
|
245
|
+
details=dict(error.details),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Exit codes
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def exit_code_for(error: Exception) -> int:
|
|
255
|
+
"""Return documented CLI exit code.
|
|
256
|
+
|
|
257
|
+
Anything that is not a typed Techtree error is an internal defect, and
|
|
258
|
+
every internal defect exits ``1``.
|
|
259
|
+
"""
|
|
260
|
+
if isinstance(error, TechtreeError):
|
|
261
|
+
return error.exit_code
|
|
262
|
+
return EXIT_ERROR
|