techtree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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