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,435 @@
1
+ """What has to be true before a real evaluation starts. Spec section 6.18.
2
+
3
+ :mod:`techtree.doctor.checks` answers "is this machine usable at all". This
4
+ module answers a narrower and more expensive question: could the *next* real
5
+ Campaign run actually execute here. The two are separate because the answers
6
+ have different consequences. Somebody browsing Climbs should not be told that
7
+ Docker is broken as though the tool were unusable; somebody about to spend money
8
+ on model calls should be stopped before a container is provisioned.
9
+
10
+ Three distinctions run through the whole module.
11
+
12
+ *The subject platform is not the host platform.* An evaluated subject runs in a
13
+ container on whatever the daemon serves, which on macOS is a Linux VM. Both are
14
+ reported in one ``<os>/<arch>`` vocabulary so a Campaign's declared
15
+ ``supported_platforms`` can be compared against the thing that will actually run
16
+ it, rather than against the laptop.
17
+
18
+ *Evaluation authentication is not host authentication.* The credential checked
19
+ here buys tokens for the evaluated subject. It has nothing to do with whatever
20
+ the operator's own Hermes is signed in with, and it fails late and expensively
21
+ if it is missing: the pinned client resolves an absent key to the literal string
22
+ ``"EMPTY"`` and only discovers the problem at the first model call, after Docker
23
+ is up (``docs/verifiers-eval.md``). It is also diagnosed for the environment a
24
+ detached run would be given rather than for the terminal these checks were
25
+ typed into, because those differ in the one way that matters and a readiness
26
+ answer that cannot survive the difference is worse than no answer at all.
27
+
28
+ *Pulling an image is a setup operation, not a check.* A check that silently
29
+ downloaded gigabytes would be a check that lied about being read-only.
30
+ :func:`check_subject_image` inspects what is present locally and says plainly
31
+ what to run when it is not.
32
+
33
+ Nothing here reads or returns a credential value; see
34
+ :mod:`techtree.verifiers.credentials` for why that is a hard boundary rather
35
+ than a habit.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import os
41
+ import shutil
42
+ import subprocess
43
+ from typing import Final
44
+
45
+ from techtree.doctor.checks import (
46
+ DAEMON_TIMEOUT_SECONDS,
47
+ VERSION_TIMEOUT_SECONDS,
48
+ detect_host_platform,
49
+ )
50
+ from techtree.engines.bundle import read_engine_descriptor
51
+ from techtree.engines.registry import EngineRegistry
52
+ from techtree.errors import ValidationError
53
+ from techtree.models.base import Digest, JsonValue
54
+ from techtree.models.campaign import (
55
+ SUBJECT_AGENT,
56
+ CampaignSpec,
57
+ ModelSpec,
58
+ RuntimeSpec,
59
+ )
60
+ from techtree.models.cli import CheckStatus, DoctorCheck
61
+ from techtree.runs.launcher import worker_visible_environment
62
+ from techtree.verifiers.child import EVAL_EXECUTABLE
63
+ from techtree.verifiers.credentials import credential_status
64
+ from techtree.verifiers.image import resolve_subject_image
65
+ from techtree.verifiers.models import VariantName
66
+
67
+ __all__ = [
68
+ "DEVELOPMENT_PLACEHOLDER_MARKERS",
69
+ "IMAGE_INSPECT_TIMEOUT_SECONDS",
70
+ "check_docker_platform",
71
+ "check_engine_eval",
72
+ "check_live_campaign",
73
+ "check_prime_auth",
74
+ "check_subject_image",
75
+ "execution_checks",
76
+ ]
77
+
78
+ #: Inspecting a local image is a metadata read, but the daemon may still be
79
+ #: waking up, so it gets the daemon's allowance rather than a version probe's.
80
+ IMAGE_INSPECT_TIMEOUT_SECONDS: Final = DAEMON_TIMEOUT_SECONDS
81
+
82
+ #: Substrings that mark a Campaign as a development fixture rather than
83
+ #: something that may be executed for real. The shipped Campaign carries none
84
+ #: of them any more — decisions document 0007 R5 pinned its container by
85
+ #: content and decisions document 0025 named its subject model — but a
86
+ #: Campaign is not always the shipped one, and every field is still scanned,
87
+ #: because which fields happen to carry a placeholder is not what the check is
88
+ #: about.
89
+ DEVELOPMENT_PLACEHOLDER_MARKERS: Final[tuple[str, ...]] = (
90
+ "development-placeholder",
91
+ "not-executed",
92
+ )
93
+
94
+ #: The platform strings a subject container can be served on.
95
+ _SUPPORTED_SUBJECT_PLATFORMS: Final[tuple[str, ...]] = ("linux/arm64", "linux/amd64")
96
+
97
+
98
+ def check_docker_platform() -> DoctorCheck:
99
+ """Confirm the daemon is reachable and serving a supported subject platform."""
100
+ metadata: dict[str, JsonValue] = {"host_platform": detect_host_platform()}
101
+
102
+ if shutil.which("docker") is None:
103
+ return DoctorCheck(
104
+ id="execution_docker_platform",
105
+ label="Docker subject platform",
106
+ status=CheckStatus.FAIL,
107
+ detail=(
108
+ "docker was not found on PATH; an evaluated subject runs in a "
109
+ "container on this host"
110
+ ),
111
+ blocking=True,
112
+ metadata=metadata,
113
+ )
114
+
115
+ served = _docker_platform()
116
+ if served is None:
117
+ return DoctorCheck(
118
+ id="execution_docker_platform",
119
+ label="Docker subject platform",
120
+ status=CheckStatus.FAIL,
121
+ detail=(
122
+ "the Docker daemon did not answer; start Docker before running "
123
+ "an evaluation"
124
+ ),
125
+ blocking=True,
126
+ metadata=metadata,
127
+ )
128
+
129
+ metadata["docker_platform"] = served
130
+ if served not in _SUPPORTED_SUBJECT_PLATFORMS:
131
+ return DoctorCheck(
132
+ id="execution_docker_platform",
133
+ label="Docker subject platform",
134
+ status=CheckStatus.FAIL,
135
+ detail=(
136
+ f"the Docker daemon serves {served}; an evaluated subject needs "
137
+ f"one of {', '.join(_SUPPORTED_SUBJECT_PLATFORMS)}"
138
+ ),
139
+ blocking=True,
140
+ metadata=metadata,
141
+ )
142
+
143
+ return DoctorCheck(
144
+ id="execution_docker_platform",
145
+ label="Docker subject platform",
146
+ status=CheckStatus.PASS,
147
+ detail=f"the Docker daemon is reachable and serving {served}",
148
+ blocking=False,
149
+ metadata=metadata,
150
+ )
151
+
152
+
153
+ def check_engine_eval(
154
+ engine_registry: EngineRegistry, digest: Digest | None = None
155
+ ) -> DoctorCheck:
156
+ """Confirm the engine's own ``eval`` exists and holds the expected revision.
157
+
158
+ The console script is addressed by absolute path inside the engine's virtual
159
+ environment. Checking that the file is there is checking the one thing a
160
+ real run cannot recover from: a ``PATH`` lookup would find *something*
161
+ called ``eval`` on almost any machine.
162
+ """
163
+ resolved = digest if digest is not None else engine_registry.active_digest()
164
+ metadata: dict[str, JsonValue] = {"engine_digest": resolved}
165
+
166
+ if resolved is None:
167
+ return DoctorCheck(
168
+ id="execution_engine_eval",
169
+ label="Engine eval entrypoint",
170
+ status=CheckStatus.FAIL,
171
+ detail=(
172
+ "no managed evaluation engine is active; install one before "
173
+ "running an evaluation"
174
+ ),
175
+ blocking=True,
176
+ metadata=metadata,
177
+ )
178
+
179
+ status = engine_registry.status(resolved)
180
+ if not status.installed:
181
+ return DoctorCheck(
182
+ id="execution_engine_eval",
183
+ label="Engine eval entrypoint",
184
+ status=CheckStatus.FAIL,
185
+ detail=f"engine {resolved} is not installed",
186
+ blocking=True,
187
+ metadata=metadata,
188
+ )
189
+ if not status.verified:
190
+ return DoctorCheck(
191
+ id="execution_engine_eval",
192
+ label="Engine eval entrypoint",
193
+ status=CheckStatus.FAIL,
194
+ detail=(
195
+ f"engine {resolved} is installed but has not been verified; a "
196
+ "real run executes only a verified engine"
197
+ ),
198
+ blocking=True,
199
+ metadata=metadata,
200
+ )
201
+
202
+ executable = engine_registry.executable(resolved, EVAL_EXECUTABLE)
203
+ metadata["eval_executable"] = str(executable)
204
+ if not executable.is_file():
205
+ return DoctorCheck(
206
+ id="execution_engine_eval",
207
+ label="Engine eval entrypoint",
208
+ status=CheckStatus.FAIL,
209
+ detail=(
210
+ f"engine {resolved} has no {EVAL_EXECUTABLE} entrypoint at "
211
+ f"{executable}; install the engine again"
212
+ ),
213
+ blocking=True,
214
+ metadata=metadata,
215
+ )
216
+
217
+ descriptor = read_engine_descriptor(engine_registry.path(resolved))
218
+ metadata["verifiers_version"] = descriptor.verifiers_version
219
+ metadata["verifiers_revision"] = descriptor.verifiers_revision
220
+ return DoctorCheck(
221
+ id="execution_engine_eval",
222
+ label="Engine eval entrypoint",
223
+ status=CheckStatus.PASS,
224
+ detail=(
225
+ f"{executable} is present, holding Verifiers "
226
+ f"{descriptor.verifiers_version} at {descriptor.verifiers_revision}"
227
+ ),
228
+ blocking=False,
229
+ metadata=metadata,
230
+ )
231
+
232
+
233
+ def check_prime_auth(model: ModelSpec) -> DoctorCheck:
234
+ """Confirm a run could authenticate, without reading anything.
235
+
236
+ The question is asked about the environment a detached run would be given,
237
+ not about the terminal this check was typed into. Those differ in exactly
238
+ one way that matters: a credential exported in a terminal is not among the
239
+ variables a run inherits, so a check that consulted its own environment
240
+ could report "ready" about a run that stops at its first model call. When
241
+ the export is there and unusable the check says so, because a person who
242
+ exported it has every reason to expect it to count.
243
+ """
244
+ status = credential_status(model, environ=worker_visible_environment())
245
+ exported = bool(os.environ.get(model.credential_env))
246
+ detail = status.detail
247
+ if not status.available and exported:
248
+ detail = (
249
+ f"{model.credential_env} is set in this terminal, but a run works in "
250
+ "a separate background process that is not given this terminal's "
251
+ f"variables, so nothing exported here can reach it: {detail}"
252
+ )
253
+ return DoctorCheck(
254
+ id="execution_model_credential",
255
+ label="Evaluation model credential",
256
+ status=CheckStatus.PASS if status.available else CheckStatus.FAIL,
257
+ detail=detail,
258
+ blocking=not status.available,
259
+ metadata={
260
+ "provider": status.provider,
261
+ "model_id": model.model_id,
262
+ "credential_env": status.credential_env,
263
+ "source": status.source,
264
+ "exported_in_this_terminal": exported,
265
+ },
266
+ )
267
+
268
+
269
+ def check_subject_image(runtime: RuntimeSpec) -> DoctorCheck:
270
+ """Report whether the pinned subject image is on this machine, as pinned.
271
+
272
+ Absent is a failure with an argument vector attached, not a prompt to pull.
273
+ Downloading an image is an explicit setup operation the operator asks for
274
+ (spec section 6.18); a check that did it silently would spend somebody's
275
+ bandwidth to make its own answer come out right.
276
+
277
+ Present-but-different is a failure too. The Campaign pins the image by
278
+ content and pins one manifest digest per platform it supports, so a daemon
279
+ holding something else, or serving the pin on a platform the Campaign never
280
+ recorded, cannot run this Campaign — and finding that out here is much
281
+ cheaper than finding it out from a comparison that has already been paid
282
+ for.
283
+ """
284
+ metadata: dict[str, JsonValue] = {"image": runtime.image}
285
+
286
+ try:
287
+ resolution = resolve_subject_image(runtime, VariantName.BASELINE)
288
+ except ValidationError as refusal:
289
+ return DoctorCheck(
290
+ id="execution_subject_image",
291
+ label="Subject runtime image",
292
+ status=CheckStatus.FAIL,
293
+ detail=refusal.message,
294
+ blocking=True,
295
+ metadata={**metadata, "pull": f"docker pull {runtime.image}"},
296
+ )
297
+
298
+ metadata["image_platform"] = resolution.platform
299
+ metadata["image_platform_digest"] = runtime.image_platform_digests[
300
+ resolution.platform
301
+ ]
302
+ return DoctorCheck(
303
+ id="execution_subject_image",
304
+ label="Subject runtime image",
305
+ status=CheckStatus.PASS,
306
+ detail=(
307
+ f"the daemon holds the pinned subject image and serves it as "
308
+ f"{resolution.platform}, which the Campaign pins a manifest digest "
309
+ "for"
310
+ ),
311
+ blocking=False,
312
+ metadata=metadata,
313
+ )
314
+
315
+
316
+ def check_live_campaign(campaign: CampaignSpec) -> DoctorCheck:
317
+ """Refuse a Campaign whose coordinates are development placeholders.
318
+
319
+ A development fixture names a model that exists to be compiled and dry-run,
320
+ never to be executed. Catching that here is the difference between a clear
321
+ refusal and a container that starts, authenticates against nothing, and
322
+ fails at the first model call.
323
+ """
324
+ subject = campaign.agents.get(SUBJECT_AGENT)
325
+ metadata: dict[str, JsonValue] = {"campaign_id": campaign.metadata.id}
326
+
327
+ if subject is None:
328
+ return DoctorCheck(
329
+ id="execution_live_campaign",
330
+ label="Campaign is executable",
331
+ status=CheckStatus.FAIL,
332
+ detail=f"the Campaign defines no {SUBJECT_AGENT!r} agent to execute",
333
+ blocking=True,
334
+ metadata=metadata,
335
+ )
336
+
337
+ metadata["model_id"] = subject.model.model_id
338
+ metadata["provider"] = subject.model.provider
339
+ metadata["image"] = subject.runtime.image
340
+
341
+ placeholders = sorted(
342
+ {
343
+ f"{field}={value}"
344
+ for field, value in (
345
+ ("provider", subject.model.provider),
346
+ ("model_id", subject.model.model_id),
347
+ ("image", subject.runtime.image),
348
+ )
349
+ for marker in DEVELOPMENT_PLACEHOLDER_MARKERS
350
+ if marker in value
351
+ }
352
+ )
353
+ if placeholders:
354
+ return DoctorCheck(
355
+ id="execution_live_campaign",
356
+ label="Campaign is executable",
357
+ status=CheckStatus.FAIL,
358
+ detail=(
359
+ "this Campaign carries development placeholders and must not be "
360
+ f"executed for real: {', '.join(placeholders)}"
361
+ ),
362
+ blocking=True,
363
+ metadata=metadata,
364
+ )
365
+
366
+ return DoctorCheck(
367
+ id="execution_live_campaign",
368
+ label="Campaign is executable",
369
+ status=CheckStatus.PASS,
370
+ detail=(
371
+ f"the Campaign names a real subject: {subject.model.model_id} on "
372
+ f"{subject.runtime.image}"
373
+ ),
374
+ blocking=False,
375
+ metadata=metadata,
376
+ )
377
+
378
+
379
+ def execution_checks(
380
+ *,
381
+ engine_registry: EngineRegistry,
382
+ campaign: CampaignSpec | None = None,
383
+ engine_digest: Digest | None = None,
384
+ ) -> list[DoctorCheck]:
385
+ """Run every execution check, in a deterministic order.
386
+
387
+ Without a Campaign only the machine-level questions can be asked, which is
388
+ the honest answer for ``techtree doctor --for-evaluation`` run before a
389
+ Climb has been chosen.
390
+ """
391
+ checks = [
392
+ check_docker_platform(),
393
+ check_engine_eval(engine_registry, engine_digest),
394
+ ]
395
+ if campaign is None:
396
+ return checks
397
+
398
+ checks.append(check_live_campaign(campaign))
399
+ subject = campaign.agents.get(SUBJECT_AGENT)
400
+ if subject is not None:
401
+ checks.append(check_prime_auth(subject.model))
402
+ checks.append(check_subject_image(subject.runtime))
403
+ return checks
404
+
405
+
406
+ def _docker_platform() -> str | None:
407
+ """Return the ``<os>/<arch>`` the daemon serves, or None if it did not answer."""
408
+ return _run(
409
+ ["docker", "version", "--format", "{{.Server.Os}}/{{.Server.Arch}}"],
410
+ timeout=DAEMON_TIMEOUT_SECONDS,
411
+ )
412
+
413
+
414
+ def _run(argv: list[str], *, timeout: float = VERSION_TIMEOUT_SECONDS) -> str | None:
415
+ """Run one argument vector and return its first line of output, or None."""
416
+ executable = shutil.which(argv[0])
417
+ if executable is None:
418
+ return None
419
+ try:
420
+ completed = subprocess.run(
421
+ [executable, *argv[1:]],
422
+ capture_output=True,
423
+ text=True,
424
+ timeout=timeout,
425
+ check=False,
426
+ stdin=subprocess.DEVNULL,
427
+ )
428
+ except (subprocess.TimeoutExpired, OSError):
429
+ return None
430
+ if completed.returncode != 0:
431
+ return None
432
+ for line in completed.stdout.splitlines():
433
+ if line.strip():
434
+ return line.strip()
435
+ return None