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,253 @@
|
|
|
1
|
+
"""Starting and signalling the detached worker. Spec PR8 §8.7.
|
|
2
|
+
|
|
3
|
+
The point of the whole slice is here: the process that runs a Climb must
|
|
4
|
+
outlive the process that started it. A host agent invokes ``techtree climb
|
|
5
|
+
start``, gets a run identifier back in well under a second, and exits. The
|
|
6
|
+
work continues.
|
|
7
|
+
|
|
8
|
+
That is what ``start_new_session=True`` buys. The worker becomes a session
|
|
9
|
+
leader in its own process group, so it does not receive the terminal's
|
|
10
|
+
``SIGHUP`` when the shell that launched it goes away, and a ``Ctrl-C`` in that
|
|
11
|
+
shell interrupts the CLI rather than the run. The same fact makes cancellation
|
|
12
|
+
precise: the run's process group contains the worker and nothing else, so
|
|
13
|
+
signalling the group cannot reach anything the run did not start.
|
|
14
|
+
|
|
15
|
+
Three rules apply to every launch.
|
|
16
|
+
|
|
17
|
+
*No shell.* The command is an argument array handed to ``execvp``. Spec §10.1
|
|
18
|
+
allows nothing else, and there is no string anywhere in this module that a run
|
|
19
|
+
identifier could be interpolated into.
|
|
20
|
+
|
|
21
|
+
*No inherited environment.* The child is given a small, named set of
|
|
22
|
+
variables, built by the injected ``environment_builder``. The fake executor
|
|
23
|
+
needs no model credential, and spec §10.7 says it must not be handed one; the
|
|
24
|
+
way to guarantee that is to pass a constructed environment rather than to
|
|
25
|
+
filter the ambient one.
|
|
26
|
+
|
|
27
|
+
*The log is the run's, and private.* ``worker.log`` is opened ``0600`` in
|
|
28
|
+
append mode and given to the child as both stdout and stderr, so anything the
|
|
29
|
+
worker prints — including anything a library prints — lands in one file the
|
|
30
|
+
run owns instead of on the caller's terminal.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import signal
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Final
|
|
43
|
+
|
|
44
|
+
from techtree.errors import PrerequisiteError, RunError
|
|
45
|
+
from techtree.fs import ensure_private_directory
|
|
46
|
+
from techtree.paths import TechtreePaths
|
|
47
|
+
from techtree.runs.store import RunStore
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"WORKER_COMMAND",
|
|
51
|
+
"WORKER_EXECUTABLE_NAME",
|
|
52
|
+
"WORKER_EXECUTABLE_NOT_FOUND",
|
|
53
|
+
"WORKER_LAUNCH_FAILED",
|
|
54
|
+
"WORKER_SIGNAL_FAILED",
|
|
55
|
+
"WorkerLauncher",
|
|
56
|
+
"default_worker_executable",
|
|
57
|
+
"scrubbed_worker_environment",
|
|
58
|
+
"worker_visible_environment",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
#: Stable error codes. Spec PR8 §8.16.
|
|
62
|
+
WORKER_EXECUTABLE_NOT_FOUND: Final = "worker_executable_not_found"
|
|
63
|
+
WORKER_LAUNCH_FAILED: Final = "worker_launch_failed"
|
|
64
|
+
WORKER_SIGNAL_FAILED: Final = "worker_signal_failed"
|
|
65
|
+
|
|
66
|
+
#: The console script the package installs for the detached worker.
|
|
67
|
+
WORKER_EXECUTABLE_NAME: Final = "techtree-worker"
|
|
68
|
+
|
|
69
|
+
#: The subcommand a launch always invokes.
|
|
70
|
+
WORKER_COMMAND: Final = "execute"
|
|
71
|
+
|
|
72
|
+
_LOG_FILE_MODE: Final = 0o600
|
|
73
|
+
|
|
74
|
+
#: Everything the worker is given. ``TECHTREE_HOME`` is how it finds the run
|
|
75
|
+
#: directory; ``PYTHONUNBUFFERED`` is how its log is readable while it runs
|
|
76
|
+
#: rather than only after it exits.
|
|
77
|
+
_INHERITED_VARIABLES: Final[tuple[str, ...]] = (
|
|
78
|
+
"PATH",
|
|
79
|
+
"HOME",
|
|
80
|
+
"TMPDIR",
|
|
81
|
+
"TECHTREE_LOG_LEVEL",
|
|
82
|
+
)
|
|
83
|
+
_TECHTREE_HOME: Final = "TECHTREE_HOME"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def default_worker_executable() -> Path:
|
|
87
|
+
"""Return where the ``techtree-worker`` console script should be.
|
|
88
|
+
|
|
89
|
+
The interpreter's own directory is consulted first. Inside a virtual
|
|
90
|
+
environment that is where the console script lives, and it is the right
|
|
91
|
+
answer even when the environment has not been activated on ``PATH`` —
|
|
92
|
+
which is exactly the situation a launched subprocess is in.
|
|
93
|
+
|
|
94
|
+
Resolution does not assert that the program is there. Only a launch needs
|
|
95
|
+
it to exist, and a command that merely reads a run's status should not
|
|
96
|
+
fail because this machine could not start a new one.
|
|
97
|
+
"""
|
|
98
|
+
beside_interpreter = Path(sys.executable).resolve().parent / WORKER_EXECUTABLE_NAME
|
|
99
|
+
if beside_interpreter.is_file():
|
|
100
|
+
return beside_interpreter
|
|
101
|
+
|
|
102
|
+
on_path = shutil.which(WORKER_EXECUTABLE_NAME)
|
|
103
|
+
return beside_interpreter if on_path is None else Path(on_path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def worker_visible_environment(
|
|
107
|
+
environ: Mapping[str, str] | None = None,
|
|
108
|
+
) -> dict[str, str]:
|
|
109
|
+
"""Return the part of an environment a worker would actually be given.
|
|
110
|
+
|
|
111
|
+
Variables are copied in by name from a fixed list. Nothing that is not on
|
|
112
|
+
that list reaches the worker, so a model-provider credential in the
|
|
113
|
+
operator's shell cannot be inherited by a run that has no business seeing
|
|
114
|
+
one.
|
|
115
|
+
|
|
116
|
+
This is separate from :func:`scrubbed_worker_environment` because a
|
|
117
|
+
readiness check has to answer "what would a run be able to see" without
|
|
118
|
+
inventing a run to ask about. Both go through this one function so that the
|
|
119
|
+
answer a check gives and the environment a launch builds cannot drift
|
|
120
|
+
apart.
|
|
121
|
+
"""
|
|
122
|
+
source = os.environ if environ is None else environ
|
|
123
|
+
return {name: source[name] for name in _INHERITED_VARIABLES if name in source}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def scrubbed_worker_environment(
|
|
127
|
+
paths: TechtreePaths,
|
|
128
|
+
*,
|
|
129
|
+
environ: Mapping[str, str] | None = None,
|
|
130
|
+
) -> Callable[[str], Mapping[str, str]]:
|
|
131
|
+
"""Return the builder that constructs a worker's whole environment.
|
|
132
|
+
|
|
133
|
+
What the worker inherits is :func:`worker_visible_environment`; what it is
|
|
134
|
+
told on top of that is where this installation lives and that its output
|
|
135
|
+
must not be buffered.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def build(run_id: str) -> Mapping[str, str]:
|
|
139
|
+
environment = worker_visible_environment(environ)
|
|
140
|
+
environment[_TECHTREE_HOME] = str(paths.root)
|
|
141
|
+
environment["PYTHONUNBUFFERED"] = "1"
|
|
142
|
+
return environment
|
|
143
|
+
|
|
144
|
+
return build
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class WorkerLauncher:
|
|
148
|
+
"""Launches and signals one detached local worker per run."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
worker_executable: Path,
|
|
154
|
+
run_store: RunStore,
|
|
155
|
+
environment_builder: Callable[[str], Mapping[str, str]],
|
|
156
|
+
) -> None:
|
|
157
|
+
self._executable = worker_executable
|
|
158
|
+
self._runs = run_store
|
|
159
|
+
self._environment = environment_builder
|
|
160
|
+
|
|
161
|
+
def command(self, run_id: str) -> Sequence[str]:
|
|
162
|
+
"""Return the argument array a launch executes."""
|
|
163
|
+
return [str(self._executable), WORKER_COMMAND, "--run-id", run_id]
|
|
164
|
+
|
|
165
|
+
def launch(self, run_id: str) -> int:
|
|
166
|
+
"""Start the detached worker for one run and return its process id."""
|
|
167
|
+
if not self._executable.is_file() or not os.access(self._executable, os.X_OK):
|
|
168
|
+
raise PrerequisiteError(
|
|
169
|
+
f"there is no runnable worker program at {self._executable}, so "
|
|
170
|
+
"no run can be started on this machine",
|
|
171
|
+
code=WORKER_EXECUTABLE_NOT_FOUND,
|
|
172
|
+
details={"run_id": run_id, "executable": str(self._executable)},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
log_path = self._runs.worker_log_path(run_id)
|
|
176
|
+
run_dir = log_path.parent
|
|
177
|
+
ensure_private_directory(run_dir)
|
|
178
|
+
descriptor = os.open(
|
|
179
|
+
log_path,
|
|
180
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
|
181
|
+
_LOG_FILE_MODE,
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
# An argument array, never a command string: there is nothing here
|
|
185
|
+
# for a shell to reinterpret. The working directory is the run's
|
|
186
|
+
# own, so a worker outliving the directory it was started from is
|
|
187
|
+
# not holding one open.
|
|
188
|
+
process = subprocess.Popen(
|
|
189
|
+
self.command(run_id),
|
|
190
|
+
stdin=subprocess.DEVNULL,
|
|
191
|
+
stdout=descriptor,
|
|
192
|
+
stderr=subprocess.STDOUT,
|
|
193
|
+
env=dict(self._environment(run_id)),
|
|
194
|
+
cwd=str(run_dir),
|
|
195
|
+
start_new_session=True,
|
|
196
|
+
close_fds=True,
|
|
197
|
+
shell=False,
|
|
198
|
+
)
|
|
199
|
+
except OSError as error:
|
|
200
|
+
raise RunError(
|
|
201
|
+
f"the worker for run {run_id} could not be started: "
|
|
202
|
+
f"{error.strerror or error}",
|
|
203
|
+
code=WORKER_LAUNCH_FAILED,
|
|
204
|
+
details={"run_id": run_id},
|
|
205
|
+
) from error
|
|
206
|
+
finally:
|
|
207
|
+
os.close(descriptor)
|
|
208
|
+
|
|
209
|
+
return process.pid
|
|
210
|
+
|
|
211
|
+
def is_alive(self, run_id: str) -> bool:
|
|
212
|
+
"""Return whether this run's worker process still exists."""
|
|
213
|
+
pid = self._runs.read_pid(run_id)
|
|
214
|
+
if pid is None:
|
|
215
|
+
return False
|
|
216
|
+
try:
|
|
217
|
+
os.kill(pid, 0)
|
|
218
|
+
except ProcessLookupError:
|
|
219
|
+
return False
|
|
220
|
+
except PermissionError:
|
|
221
|
+
# The process exists; it simply is not ours to signal. That is
|
|
222
|
+
# still an answer to the question asked.
|
|
223
|
+
return True
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def request_termination(self, run_id: str) -> None:
|
|
227
|
+
"""Ask this run's process group to stop, and do not wait."""
|
|
228
|
+
self._signal(run_id, signal.SIGTERM)
|
|
229
|
+
|
|
230
|
+
def force_kill(self, run_id: str) -> None:
|
|
231
|
+
"""Stop this run's process group without asking."""
|
|
232
|
+
self._signal(run_id, signal.SIGKILL)
|
|
233
|
+
|
|
234
|
+
def _signal(self, run_id: str, number: signal.Signals) -> None:
|
|
235
|
+
"""Signal the worker's process group, tolerating a worker that ended."""
|
|
236
|
+
pid = self._runs.read_pid(run_id)
|
|
237
|
+
if pid is None:
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
# The worker was started with ``start_new_session``, so its process
|
|
241
|
+
# group identifier is its own process id and the group holds only
|
|
242
|
+
# the run.
|
|
243
|
+
os.killpg(os.getpgid(pid), number)
|
|
244
|
+
except ProcessLookupError:
|
|
245
|
+
# The worker already exited. Being asked to stop something that
|
|
246
|
+
# has stopped is not a failure.
|
|
247
|
+
return
|
|
248
|
+
except OSError as error:
|
|
249
|
+
raise RunError(
|
|
250
|
+
f"run {run_id} could not be signalled: {error.strerror or error}",
|
|
251
|
+
code=WORKER_SIGNAL_FAILED,
|
|
252
|
+
details={"run_id": run_id, "signal": number.name},
|
|
253
|
+
) from error
|