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
techtree/drafts/store.py
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
"""The draft directory, and the only way anything writes to it. Spec PR6 §6.7.
|
|
2
|
+
|
|
3
|
+
A draft is a self-contained claim. Everything needed to check it — the public
|
|
4
|
+
Climb when there is one, the Campaign, the DataPolicy, the publisher's
|
|
5
|
+
validation receipt, the normalized evidence that receipt was issued from, both
|
|
6
|
+
experiment variants, the controlled comparison, and every skill the pair
|
|
7
|
+
declares as both an archive and an expanded tree — is copied in at prepare
|
|
8
|
+
time. Nothing is left as a reference into the catalog, because a draft that
|
|
9
|
+
needed the catalog to be verifiable would stop being verifiable the moment the
|
|
10
|
+
build shipping that catalog changed. Decisions document 0003 A4 states the
|
|
11
|
+
rule; this module is where it is paid for.
|
|
12
|
+
|
|
13
|
+
Two of those are conditional, and what decides them is the Campaign rather than
|
|
14
|
+
a caller's preference. ``public/climb.json`` exists when a public Climb invited
|
|
15
|
+
the submission; a Skill-replacement Campaign is derived locally (spec section
|
|
16
|
+
7.19) and no Climb wraps it. ``baseline-skill/`` exists when the mutation kind
|
|
17
|
+
is ``skill_replacement``, because then the baseline variant carries the Skill
|
|
18
|
+
being revised and the subject has to be handed its files.
|
|
19
|
+
|
|
20
|
+
Three properties govern the layout.
|
|
21
|
+
|
|
22
|
+
*Nothing partial ever becomes a draft.* The whole tree is assembled in a
|
|
23
|
+
staging directory beside the drafts, verified there, and only then renamed into
|
|
24
|
+
place. A rename within one directory is atomic, so a crash at any point leaves
|
|
25
|
+
either no draft or a complete one, never a half-written graph that a later
|
|
26
|
+
command would have to guess about.
|
|
27
|
+
|
|
28
|
+
*Written once, or replaced atomically, never edited.* ``draft.json``,
|
|
29
|
+
``comparison.json``, the manifests, the public snapshot, and the skill artifact
|
|
30
|
+
are created with ``O_EXCL`` in canonical bytes: their digests are their
|
|
31
|
+
identities and a second write is a conflict, not an update. ``start.json`` is
|
|
32
|
+
mutable state and is replaced whole.
|
|
33
|
+
|
|
34
|
+
*The start is claimed exactly once.* :meth:`DraftStore.claim_start` verifies
|
|
35
|
+
the graph and creates the start record under one lock hold. A second call
|
|
36
|
+
returns the record the first one wrote — the same run identifier — so a
|
|
37
|
+
retried or duplicated start cannot produce two runs from one draft.
|
|
38
|
+
|
|
39
|
+
Verification is offline and total. :meth:`DraftStore.verify_snapshot` recomputes
|
|
40
|
+
every digest in the graph and checks every edge between the objects, including
|
|
41
|
+
the ones the catalog already checked. It is deliberately redundant: the catalog
|
|
42
|
+
proved something about the bytes it shipped, and this proves something about
|
|
43
|
+
the bytes on this disk now.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
import shutil
|
|
50
|
+
import uuid
|
|
51
|
+
from collections.abc import Iterator
|
|
52
|
+
from contextlib import contextmanager
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from datetime import UTC, datetime
|
|
55
|
+
from enum import StrEnum
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Final
|
|
58
|
+
|
|
59
|
+
from filelock import FileLock, Timeout
|
|
60
|
+
from pydantic import BaseModel
|
|
61
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
62
|
+
|
|
63
|
+
from techtree.canonical import canonical_json_bytes, digest_object, sha256_digest_bytes
|
|
64
|
+
from techtree.drafts.source import CampaignSource, StagedSkill
|
|
65
|
+
from techtree.errors import (
|
|
66
|
+
ConflictError,
|
|
67
|
+
NotFoundError,
|
|
68
|
+
ValidationError,
|
|
69
|
+
VerificationError,
|
|
70
|
+
)
|
|
71
|
+
from techtree.fs import (
|
|
72
|
+
atomic_write_bytes,
|
|
73
|
+
ensure_private_directory,
|
|
74
|
+
fsync_directory,
|
|
75
|
+
open_exclusive,
|
|
76
|
+
remove_tree,
|
|
77
|
+
)
|
|
78
|
+
from techtree.ids import validate_id
|
|
79
|
+
from techtree.manifests.builder import skill_content_digest
|
|
80
|
+
from techtree.models.base import ArtifactRef, JsonValue, StateModel, UtcDateTime
|
|
81
|
+
from techtree.models.campaign import SUBJECT_AGENT, CampaignSpec
|
|
82
|
+
from techtree.models.climb import ClimbManifest, ResolvedClimb
|
|
83
|
+
from techtree.models.data_policy import DataPolicy
|
|
84
|
+
from techtree.models.experiment import ExperimentManifest, ManifestComparison
|
|
85
|
+
from techtree.models.skill import SkillArtifact, SubmissionDraft
|
|
86
|
+
from techtree.models.validation import TasksetValidationReceipt, ValidationEvidence
|
|
87
|
+
from techtree.paths import TechtreePaths
|
|
88
|
+
from techtree.skills.archive import verify_archive
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"DRAFT_ALREADY_EXISTS",
|
|
92
|
+
"DRAFT_WRITE_FAILED",
|
|
93
|
+
"LOCK_TIMEOUT_SECONDS",
|
|
94
|
+
"DraftSnapshot",
|
|
95
|
+
"DraftStartRecord",
|
|
96
|
+
"DraftStartStatus",
|
|
97
|
+
"DraftStore",
|
|
98
|
+
"utc_now",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
#: Stable error codes this module reports. Spec PR6 §6.10.
|
|
102
|
+
DRAFT_ALREADY_EXISTS: Final = "draft_already_exists"
|
|
103
|
+
DRAFT_WRITE_FAILED: Final = "draft_write_failed"
|
|
104
|
+
_CATALOG_GRAPH_INVALID: Final = "catalog_graph_invalid"
|
|
105
|
+
_CAMPAIGN_DIGEST_MISMATCH: Final = "campaign_digest_mismatch"
|
|
106
|
+
_DATA_POLICY_DIGEST_MISMATCH: Final = "data_policy_digest_mismatch"
|
|
107
|
+
_PUBLISHER_VALIDATION_MISSING: Final = "publisher_validation_missing"
|
|
108
|
+
_PUBLISHER_EVIDENCE_MISSING: Final = "publisher_validation_evidence_missing"
|
|
109
|
+
_SKILL_INVALID: Final = "skill_invalid"
|
|
110
|
+
_MANIFEST_BUILD_FAILED: Final = "manifest_build_failed"
|
|
111
|
+
_MANIFEST_COMPARISON_INVALID: Final = "manifest_comparison_invalid"
|
|
112
|
+
_DRAFT_NOT_FOUND: Final = "draft_not_found"
|
|
113
|
+
_DRAFT_START_CONFLICT: Final = "draft_start_conflict"
|
|
114
|
+
_DRAFT_NOT_STARTED: Final = "draft_not_started"
|
|
115
|
+
|
|
116
|
+
#: A lock hold is a handful of file operations. Waiting longer than this means
|
|
117
|
+
#: the holder died with the lock, and failing beats hanging.
|
|
118
|
+
LOCK_TIMEOUT_SECONDS: Final = 30.0
|
|
119
|
+
|
|
120
|
+
_LOCK_FILE: Final = ".lock"
|
|
121
|
+
_DRAFT_FILE: Final = "draft.json"
|
|
122
|
+
_COMPARISON_FILE: Final = "comparison.json"
|
|
123
|
+
_START_FILE: Final = "start.json"
|
|
124
|
+
|
|
125
|
+
_PUBLIC_DIR: Final = "public"
|
|
126
|
+
_CLIMB_FILE: Final = "climb.json"
|
|
127
|
+
_CAMPAIGN_FILE: Final = "campaign.json"
|
|
128
|
+
_DATA_POLICY_FILE: Final = "data-policy.json"
|
|
129
|
+
_VALIDATION_FILE: Final = "publisher-validation.json"
|
|
130
|
+
_EVIDENCE_FILE: Final = "publisher-validation-evidence.json"
|
|
131
|
+
|
|
132
|
+
_MANIFESTS_DIR: Final = "manifests"
|
|
133
|
+
_BASELINE_FILE: Final = "baseline.json"
|
|
134
|
+
_CANDIDATE_FILE: Final = "candidate.json"
|
|
135
|
+
|
|
136
|
+
_SKILL_DIR: Final = "skill"
|
|
137
|
+
_BASELINE_SKILL_DIR: Final = "baseline-skill"
|
|
138
|
+
_ARTIFACT_FILE: Final = "artifact.json"
|
|
139
|
+
_BUNDLE_FILE: Final = "bundle.tar"
|
|
140
|
+
_FILES_DIR: Final = "files"
|
|
141
|
+
|
|
142
|
+
#: Staging directories live beside the drafts so that the final placement is a
|
|
143
|
+
#: rename within one directory, which is what makes it atomic.
|
|
144
|
+
_STAGING_PREFIX: Final = ".staging-"
|
|
145
|
+
|
|
146
|
+
_FILE_MODE: Final = 0o600
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def utc_now() -> datetime:
|
|
150
|
+
"""Return the current instant in UTC."""
|
|
151
|
+
return datetime.now(UTC)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DraftStartStatus(StrEnum):
|
|
155
|
+
"""How far the one-time handover from a draft to a run has got."""
|
|
156
|
+
|
|
157
|
+
CLAIMED = "claimed"
|
|
158
|
+
LAUNCHED = "launched"
|
|
159
|
+
LAUNCH_FAILED = "launch_failed"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class DraftStartRecord(StateModel):
|
|
163
|
+
"""The single run this draft was spent on, and what became of the launch.
|
|
164
|
+
|
|
165
|
+
Local operational state, not a protocol artifact. It exists so that a
|
|
166
|
+
retried start returns the run that already exists rather than creating a
|
|
167
|
+
second one, and so that a launch that failed after the draft was spent is
|
|
168
|
+
visible instead of silently lost.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
draft_id: str
|
|
172
|
+
run_id: str
|
|
173
|
+
status: DraftStartStatus
|
|
174
|
+
claimed_at: UtcDateTime
|
|
175
|
+
launched_at: UtcDateTime | None = None
|
|
176
|
+
launch_error_code: str | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class DraftSnapshot:
|
|
181
|
+
"""Everything one draft holds, loaded and ready to be checked together."""
|
|
182
|
+
|
|
183
|
+
draft: SubmissionDraft
|
|
184
|
+
source: CampaignSource
|
|
185
|
+
validation_evidence: ValidationEvidence
|
|
186
|
+
baseline: ExperimentManifest
|
|
187
|
+
candidate: ExperimentManifest
|
|
188
|
+
comparison: ManifestComparison
|
|
189
|
+
candidate_skill: StagedSkill
|
|
190
|
+
baseline_skill: StagedSkill | None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class DraftStore:
|
|
194
|
+
"""Persists and independently verifies complete draft graphs."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, paths: TechtreePaths) -> None:
|
|
197
|
+
self._paths = paths
|
|
198
|
+
|
|
199
|
+
# -- Placement ---------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def draft_dir(self, draft_id: str) -> Path:
|
|
202
|
+
"""Return the directory one draft occupies."""
|
|
203
|
+
return self._paths.draft_dir(draft_id)
|
|
204
|
+
|
|
205
|
+
def skill_files_dir(self, draft_id: str) -> Path:
|
|
206
|
+
"""Return the immutable expanded candidate skill directory."""
|
|
207
|
+
return self.draft_dir(draft_id) / _SKILL_DIR / _FILES_DIR
|
|
208
|
+
|
|
209
|
+
def skill_archive_path(self, draft_id: str) -> Path:
|
|
210
|
+
"""Return the deterministic candidate skill archive."""
|
|
211
|
+
return self.draft_dir(draft_id) / _SKILL_DIR / _BUNDLE_FILE
|
|
212
|
+
|
|
213
|
+
def baseline_skill_files_dir(self, draft_id: str) -> Path:
|
|
214
|
+
"""Return the expanded Skill a replacement's baseline carries."""
|
|
215
|
+
return self.draft_dir(draft_id) / _BASELINE_SKILL_DIR / _FILES_DIR
|
|
216
|
+
|
|
217
|
+
def baseline_skill_archive_path(self, draft_id: str) -> Path:
|
|
218
|
+
"""Return the archive of the Skill a replacement's baseline carries."""
|
|
219
|
+
return self.draft_dir(draft_id) / _BASELINE_SKILL_DIR / _BUNDLE_FILE
|
|
220
|
+
|
|
221
|
+
# -- Creation ----------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def create(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
draft: SubmissionDraft,
|
|
227
|
+
baseline: ExperimentManifest,
|
|
228
|
+
candidate: ExperimentManifest,
|
|
229
|
+
comparison: ManifestComparison,
|
|
230
|
+
source: CampaignSource,
|
|
231
|
+
validation_evidence: ValidationEvidence,
|
|
232
|
+
staged_candidate_skill: StagedSkill,
|
|
233
|
+
staged_baseline_skill: StagedSkill | None = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Write a new draft graph, atomically, or write nothing at all."""
|
|
236
|
+
final = self.draft_dir(draft.id)
|
|
237
|
+
if final.exists():
|
|
238
|
+
raise ConflictError(
|
|
239
|
+
f"a draft called {draft.id} already exists",
|
|
240
|
+
code=DRAFT_ALREADY_EXISTS,
|
|
241
|
+
details={"draft_id": draft.id},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
ensure_private_directory(self._paths.drafts_dir)
|
|
245
|
+
staging = self._paths.drafts_dir / f"{_STAGING_PREFIX}{uuid.uuid4().hex}"
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
self._assemble(
|
|
249
|
+
staging,
|
|
250
|
+
draft=draft,
|
|
251
|
+
baseline=baseline,
|
|
252
|
+
candidate=candidate,
|
|
253
|
+
comparison=comparison,
|
|
254
|
+
source=source,
|
|
255
|
+
validation_evidence=validation_evidence,
|
|
256
|
+
staged_candidate_skill=staged_candidate_skill,
|
|
257
|
+
staged_baseline_skill=staged_baseline_skill,
|
|
258
|
+
)
|
|
259
|
+
self.verify_snapshot(
|
|
260
|
+
DraftSnapshot(
|
|
261
|
+
draft=draft,
|
|
262
|
+
source=source,
|
|
263
|
+
validation_evidence=validation_evidence,
|
|
264
|
+
baseline=baseline,
|
|
265
|
+
candidate=candidate,
|
|
266
|
+
comparison=comparison,
|
|
267
|
+
candidate_skill=_staged_at(
|
|
268
|
+
staging / _SKILL_DIR, draft.skill_artifact
|
|
269
|
+
),
|
|
270
|
+
baseline_skill=(
|
|
271
|
+
None
|
|
272
|
+
if staged_baseline_skill is None
|
|
273
|
+
else _staged_at(
|
|
274
|
+
staging / _BASELINE_SKILL_DIR,
|
|
275
|
+
staged_baseline_skill.artifact,
|
|
276
|
+
)
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
self._place(staging, final, draft.id)
|
|
281
|
+
except BaseException:
|
|
282
|
+
remove_tree(staging)
|
|
283
|
+
raise
|
|
284
|
+
|
|
285
|
+
def _assemble(
|
|
286
|
+
self,
|
|
287
|
+
staging: Path,
|
|
288
|
+
*,
|
|
289
|
+
draft: SubmissionDraft,
|
|
290
|
+
baseline: ExperimentManifest,
|
|
291
|
+
candidate: ExperimentManifest,
|
|
292
|
+
comparison: ManifestComparison,
|
|
293
|
+
source: CampaignSource,
|
|
294
|
+
validation_evidence: ValidationEvidence,
|
|
295
|
+
staged_candidate_skill: StagedSkill,
|
|
296
|
+
staged_baseline_skill: StagedSkill | None,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Write the complete tree into a staging directory."""
|
|
299
|
+
reference = source.publisher_validation.normalized_evidence
|
|
300
|
+
if reference is None:
|
|
301
|
+
raise VerificationError(
|
|
302
|
+
"the publisher's validation receipt names no normalized "
|
|
303
|
+
"evidence, so this draft could not be checked offline",
|
|
304
|
+
code=_PUBLISHER_EVIDENCE_MISSING,
|
|
305
|
+
details={"draft_id": draft.id},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
ensure_private_directory(staging)
|
|
310
|
+
self._write_immutable(staging / _DRAFT_FILE, draft)
|
|
311
|
+
self._write_immutable(staging / _COMPARISON_FILE, comparison)
|
|
312
|
+
|
|
313
|
+
public = staging / _PUBLIC_DIR
|
|
314
|
+
ensure_private_directory(public)
|
|
315
|
+
if source.climb is not None:
|
|
316
|
+
self._write_immutable(public / _CLIMB_FILE, source.climb)
|
|
317
|
+
self._write_immutable(public / _CAMPAIGN_FILE, source.campaign)
|
|
318
|
+
self._write_immutable(public / _DATA_POLICY_FILE, source.data_policy)
|
|
319
|
+
self._write_immutable(
|
|
320
|
+
public / _VALIDATION_FILE, source.publisher_validation
|
|
321
|
+
)
|
|
322
|
+
self._write_immutable(public / _EVIDENCE_FILE, validation_evidence)
|
|
323
|
+
|
|
324
|
+
manifests = staging / _MANIFESTS_DIR
|
|
325
|
+
ensure_private_directory(manifests)
|
|
326
|
+
self._write_immutable(manifests / _BASELINE_FILE, baseline)
|
|
327
|
+
self._write_immutable(manifests / _CANDIDATE_FILE, candidate)
|
|
328
|
+
|
|
329
|
+
self._copy_skill(
|
|
330
|
+
staging / _SKILL_DIR,
|
|
331
|
+
StagedSkill(
|
|
332
|
+
artifact=draft.skill_artifact,
|
|
333
|
+
archive=staged_candidate_skill.archive,
|
|
334
|
+
files=staged_candidate_skill.files,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
if staged_baseline_skill is not None:
|
|
338
|
+
self._copy_skill(staging / _BASELINE_SKILL_DIR, staged_baseline_skill)
|
|
339
|
+
except OSError as error:
|
|
340
|
+
raise ValidationError(
|
|
341
|
+
f"the draft could not be written: {error.strerror or error}",
|
|
342
|
+
code=DRAFT_WRITE_FAILED,
|
|
343
|
+
details={"draft_id": draft.id},
|
|
344
|
+
) from error
|
|
345
|
+
|
|
346
|
+
def _copy_skill(self, destination: Path, staged: StagedSkill) -> None:
|
|
347
|
+
"""Write one skill's artifact, archive, and exactly the files it lists.
|
|
348
|
+
|
|
349
|
+
The artifact document is written from the object the caller committed
|
|
350
|
+
to rather than copied off disk, so the only artifact a draft can hold
|
|
351
|
+
is the one its own digest covers.
|
|
352
|
+
"""
|
|
353
|
+
ensure_private_directory(destination)
|
|
354
|
+
self._write_immutable(destination / _ARTIFACT_FILE, staged.artifact)
|
|
355
|
+
shutil.copyfile(staged.archive, destination / _BUNDLE_FILE)
|
|
356
|
+
os.chmod(destination / _BUNDLE_FILE, _FILE_MODE)
|
|
357
|
+
|
|
358
|
+
files = destination / _FILES_DIR
|
|
359
|
+
ensure_private_directory(files)
|
|
360
|
+
for entry in staged.artifact.files:
|
|
361
|
+
target = files / entry.path
|
|
362
|
+
ensure_private_directory(target.parent)
|
|
363
|
+
shutil.copyfile(staged.files / entry.path, target)
|
|
364
|
+
os.chmod(target, _FILE_MODE)
|
|
365
|
+
|
|
366
|
+
def _place(self, staging: Path, final: Path, draft_id: str) -> None:
|
|
367
|
+
"""Move a fully written staging tree into its final name."""
|
|
368
|
+
try:
|
|
369
|
+
os.rename(staging, final)
|
|
370
|
+
except OSError as error:
|
|
371
|
+
raise ConflictError(
|
|
372
|
+
f"a draft called {draft_id} already exists",
|
|
373
|
+
code=DRAFT_ALREADY_EXISTS,
|
|
374
|
+
details={"draft_id": draft_id},
|
|
375
|
+
) from error
|
|
376
|
+
fsync_directory(self._paths.drafts_dir)
|
|
377
|
+
|
|
378
|
+
# -- Loading -----------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
def get(self, draft_id: str) -> SubmissionDraft:
|
|
381
|
+
"""Load and validate ``draft.json``."""
|
|
382
|
+
return self._load(draft_id, _DRAFT_FILE, SubmissionDraft)
|
|
383
|
+
|
|
384
|
+
def get_manifests(
|
|
385
|
+
self, draft_id: str
|
|
386
|
+
) -> tuple[ExperimentManifest, ExperimentManifest]:
|
|
387
|
+
"""Load immutable manifests."""
|
|
388
|
+
return (
|
|
389
|
+
self._load(
|
|
390
|
+
draft_id, f"{_MANIFESTS_DIR}/{_BASELINE_FILE}", ExperimentManifest
|
|
391
|
+
),
|
|
392
|
+
self._load(
|
|
393
|
+
draft_id, f"{_MANIFESTS_DIR}/{_CANDIDATE_FILE}", ExperimentManifest
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def get_comparison(self, draft_id: str) -> ManifestComparison:
|
|
398
|
+
"""Load prepared comparison."""
|
|
399
|
+
return self._load(draft_id, _COMPARISON_FILE, ManifestComparison)
|
|
400
|
+
|
|
401
|
+
def get_source(self, draft_id: str) -> CampaignSource:
|
|
402
|
+
"""Load the snapshotted Campaign graph.
|
|
403
|
+
|
|
404
|
+
The digests are recomputed from the snapshotted bytes rather than read
|
|
405
|
+
from anywhere, so assembling the graph is itself the check that the
|
|
406
|
+
objects still describe each other. When a public Climb was snapshotted
|
|
407
|
+
the graph is assembled as a :class:`ResolvedClimb` first, so that its
|
|
408
|
+
own validator runs against the bytes on this disk rather than against
|
|
409
|
+
the ones the catalog once shipped.
|
|
410
|
+
"""
|
|
411
|
+
campaign = self._load(draft_id, f"{_PUBLIC_DIR}/{_CAMPAIGN_FILE}", CampaignSpec)
|
|
412
|
+
data_policy = self._load(
|
|
413
|
+
draft_id, f"{_PUBLIC_DIR}/{_DATA_POLICY_FILE}", DataPolicy
|
|
414
|
+
)
|
|
415
|
+
receipt = self._load(
|
|
416
|
+
draft_id, f"{_PUBLIC_DIR}/{_VALIDATION_FILE}", TasksetValidationReceipt
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if not (self.draft_dir(draft_id) / _PUBLIC_DIR / _CLIMB_FILE).exists():
|
|
420
|
+
return CampaignSource.local(
|
|
421
|
+
campaign=campaign,
|
|
422
|
+
data_policy=data_policy,
|
|
423
|
+
publisher_validation=receipt,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
climb = self._load(draft_id, f"{_PUBLIC_DIR}/{_CLIMB_FILE}", ClimbManifest)
|
|
427
|
+
try:
|
|
428
|
+
resolved = ResolvedClimb(
|
|
429
|
+
climb=climb,
|
|
430
|
+
climb_digest=digest_object(climb),
|
|
431
|
+
campaign=campaign,
|
|
432
|
+
campaign_digest=digest_object(campaign),
|
|
433
|
+
data_policy=data_policy,
|
|
434
|
+
data_policy_digest=digest_object(data_policy),
|
|
435
|
+
publisher_validation=receipt,
|
|
436
|
+
publisher_validation_digest=digest_object(receipt),
|
|
437
|
+
)
|
|
438
|
+
except PydanticValidationError as error:
|
|
439
|
+
raise VerificationError(
|
|
440
|
+
"the public objects snapshotted in this draft no longer "
|
|
441
|
+
f"describe each other: {_first_problem(error)}",
|
|
442
|
+
code=_CATALOG_GRAPH_INVALID,
|
|
443
|
+
details={"draft_id": draft_id},
|
|
444
|
+
) from error
|
|
445
|
+
return CampaignSource.from_climb(resolved)
|
|
446
|
+
|
|
447
|
+
def get_validation_evidence(self, draft_id: str) -> ValidationEvidence:
|
|
448
|
+
"""Load evidence referenced by the publisher receipt."""
|
|
449
|
+
return self._load(
|
|
450
|
+
draft_id, f"{_PUBLIC_DIR}/{_EVIDENCE_FILE}", ValidationEvidence
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def get_skill(self, draft_id: str) -> SkillArtifact:
|
|
454
|
+
"""Load the candidate ``SkillArtifact`` and verify its files and archive."""
|
|
455
|
+
staged = self._staged_skill(draft_id, _SKILL_DIR)
|
|
456
|
+
self._verify_skill(staged)
|
|
457
|
+
return staged.artifact
|
|
458
|
+
|
|
459
|
+
def load_snapshot(self, draft_id: str) -> DraftSnapshot:
|
|
460
|
+
"""Load the complete graph and verify it before returning it."""
|
|
461
|
+
baseline, candidate = self.get_manifests(draft_id)
|
|
462
|
+
has_baseline_skill = (
|
|
463
|
+
self.draft_dir(draft_id) / _BASELINE_SKILL_DIR / _ARTIFACT_FILE
|
|
464
|
+
).exists()
|
|
465
|
+
snapshot = DraftSnapshot(
|
|
466
|
+
draft=self.get(draft_id),
|
|
467
|
+
source=self.get_source(draft_id),
|
|
468
|
+
validation_evidence=self.get_validation_evidence(draft_id),
|
|
469
|
+
baseline=baseline,
|
|
470
|
+
candidate=candidate,
|
|
471
|
+
comparison=self.get_comparison(draft_id),
|
|
472
|
+
candidate_skill=self._staged_skill(draft_id, _SKILL_DIR),
|
|
473
|
+
baseline_skill=(
|
|
474
|
+
self._staged_skill(draft_id, _BASELINE_SKILL_DIR)
|
|
475
|
+
if has_baseline_skill
|
|
476
|
+
else None
|
|
477
|
+
),
|
|
478
|
+
)
|
|
479
|
+
self.verify_snapshot(snapshot)
|
|
480
|
+
return snapshot
|
|
481
|
+
|
|
482
|
+
def _staged_skill(self, draft_id: str, directory: str) -> StagedSkill:
|
|
483
|
+
"""Load one skill's artifact and name the bytes stored beside it."""
|
|
484
|
+
return _staged_at(
|
|
485
|
+
self.draft_dir(draft_id) / directory,
|
|
486
|
+
self._load(draft_id, f"{directory}/{_ARTIFACT_FILE}", SkillArtifact),
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# -- Verification ------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def verify_snapshot(self, snapshot: DraftSnapshot) -> None:
|
|
492
|
+
"""Verify every digest and cross-object invariant, offline."""
|
|
493
|
+
draft = snapshot.draft
|
|
494
|
+
source = snapshot.source
|
|
495
|
+
campaign = source.campaign
|
|
496
|
+
receipt = source.publisher_validation
|
|
497
|
+
|
|
498
|
+
self._verify_public_context(snapshot)
|
|
499
|
+
|
|
500
|
+
campaign_digest = digest_object(campaign)
|
|
501
|
+
_require(
|
|
502
|
+
campaign_digest == draft.campaign_spec_digest,
|
|
503
|
+
"this draft was prepared against a different Campaign than the one "
|
|
504
|
+
"it snapshotted",
|
|
505
|
+
_CAMPAIGN_DIGEST_MISMATCH,
|
|
506
|
+
expected=draft.campaign_spec_digest,
|
|
507
|
+
computed=campaign_digest,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
policy_digest = digest_object(source.data_policy)
|
|
511
|
+
_require(
|
|
512
|
+
policy_digest == campaign.data_policy_digest,
|
|
513
|
+
"the snapshotted DataPolicy is not the one the Campaign runs under",
|
|
514
|
+
_DATA_POLICY_DIGEST_MISMATCH,
|
|
515
|
+
expected=campaign.data_policy_digest,
|
|
516
|
+
computed=policy_digest,
|
|
517
|
+
)
|
|
518
|
+
_require(
|
|
519
|
+
policy_digest == draft.data_policy_digest,
|
|
520
|
+
"this draft states rights from a different DataPolicy than the one "
|
|
521
|
+
"it snapshotted",
|
|
522
|
+
_DATA_POLICY_DIGEST_MISMATCH,
|
|
523
|
+
expected=draft.data_policy_digest,
|
|
524
|
+
computed=policy_digest,
|
|
525
|
+
)
|
|
526
|
+
_require(
|
|
527
|
+
draft.policy_acceptance.data_policy_digest == policy_digest,
|
|
528
|
+
"this draft asks for acceptance of a DataPolicy it did not snapshot",
|
|
529
|
+
_DATA_POLICY_DIGEST_MISMATCH,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
receipt_digest = digest_object(receipt)
|
|
533
|
+
_require(
|
|
534
|
+
receipt_digest == campaign.taskset.validation_receipt_digest,
|
|
535
|
+
"the snapshotted publisher validation is not the one the Campaign "
|
|
536
|
+
"commits to",
|
|
537
|
+
_PUBLISHER_VALIDATION_MISSING,
|
|
538
|
+
expected=campaign.taskset.validation_receipt_digest,
|
|
539
|
+
computed=receipt_digest,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
reference = receipt.normalized_evidence
|
|
543
|
+
if reference is None:
|
|
544
|
+
raise VerificationError(
|
|
545
|
+
"the publisher's validation receipt names no normalized "
|
|
546
|
+
"evidence, so this draft cannot be checked offline",
|
|
547
|
+
code=_PUBLISHER_EVIDENCE_MISSING,
|
|
548
|
+
details={"draft_id": draft.id},
|
|
549
|
+
)
|
|
550
|
+
evidence_digest = digest_object(snapshot.validation_evidence)
|
|
551
|
+
_require(
|
|
552
|
+
evidence_digest == reference.digest,
|
|
553
|
+
"the snapshotted validation evidence is not what the publisher's "
|
|
554
|
+
"receipt was issued from",
|
|
555
|
+
_PUBLISHER_EVIDENCE_MISSING,
|
|
556
|
+
expected=reference.digest,
|
|
557
|
+
computed=evidence_digest,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
_require(
|
|
561
|
+
draft.outcome_contract_digest == campaign.context.outcome_contract_digest,
|
|
562
|
+
"this draft names a different OutcomeContract than the Campaign",
|
|
563
|
+
_CATALOG_GRAPH_INVALID,
|
|
564
|
+
)
|
|
565
|
+
_require(
|
|
566
|
+
draft.program_ref == campaign.context.program_ref,
|
|
567
|
+
"this draft names a different improvement program than the Campaign",
|
|
568
|
+
_CATALOG_GRAPH_INVALID,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
self._verify_manifests(snapshot)
|
|
572
|
+
self._verify_skill(snapshot.candidate_skill)
|
|
573
|
+
candidate_artifact = snapshot.candidate_skill.artifact
|
|
574
|
+
_require(
|
|
575
|
+
list(draft.included_files)
|
|
576
|
+
== [file.path for file in candidate_artifact.files],
|
|
577
|
+
"this draft lists different files than the candidate skill it snapshotted",
|
|
578
|
+
_SKILL_INVALID,
|
|
579
|
+
)
|
|
580
|
+
_require(
|
|
581
|
+
digest_object(draft.skill_artifact) == digest_object(candidate_artifact),
|
|
582
|
+
"this draft carries a different skill artifact than the one "
|
|
583
|
+
"snapshotted beside it",
|
|
584
|
+
_SKILL_INVALID,
|
|
585
|
+
)
|
|
586
|
+
self._verify_baseline_skill(snapshot)
|
|
587
|
+
|
|
588
|
+
def _verify_public_context(self, snapshot: DraftSnapshot) -> None:
|
|
589
|
+
"""Require the draft and the snapshot to agree about a public Climb.
|
|
590
|
+
|
|
591
|
+
A draft prepared under a Climb names it and snapshots it. A draft
|
|
592
|
+
prepared against a locally derived Campaign — spec section 7.19's
|
|
593
|
+
Skill replacement — names no public context and snapshots no Climb.
|
|
594
|
+
Either is complete; one of each is a draft that cannot be read.
|
|
595
|
+
"""
|
|
596
|
+
draft = snapshot.draft
|
|
597
|
+
climb = snapshot.source.climb
|
|
598
|
+
|
|
599
|
+
if climb is None:
|
|
600
|
+
_require(
|
|
601
|
+
draft.public_context is None,
|
|
602
|
+
"this draft names a public Climb and did not snapshot one",
|
|
603
|
+
_CATALOG_GRAPH_INVALID,
|
|
604
|
+
draft_id=draft.id,
|
|
605
|
+
)
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
public_context = draft.public_context
|
|
609
|
+
if public_context is None:
|
|
610
|
+
raise VerificationError(
|
|
611
|
+
"this draft names no public Climb, and it was prepared under one",
|
|
612
|
+
code=_CATALOG_GRAPH_INVALID,
|
|
613
|
+
details={"draft_id": draft.id},
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
climb_digest = digest_object(climb)
|
|
617
|
+
_require(
|
|
618
|
+
climb_digest == public_context.climb_digest,
|
|
619
|
+
"the snapshotted Climb is not the Climb this draft was prepared for",
|
|
620
|
+
_CATALOG_GRAPH_INVALID,
|
|
621
|
+
expected=public_context.climb_digest,
|
|
622
|
+
computed=climb_digest,
|
|
623
|
+
)
|
|
624
|
+
_require(
|
|
625
|
+
climb_digest == snapshot.source.climb_digest,
|
|
626
|
+
"the snapshotted Climb does not match the digest it was resolved under",
|
|
627
|
+
_CATALOG_GRAPH_INVALID,
|
|
628
|
+
)
|
|
629
|
+
_require(
|
|
630
|
+
digest_object(snapshot.source.campaign) == climb.campaign_spec_digest,
|
|
631
|
+
"the snapshotted Campaign is not the one the Climb wraps",
|
|
632
|
+
_CAMPAIGN_DIGEST_MISMATCH,
|
|
633
|
+
expected=climb.campaign_spec_digest,
|
|
634
|
+
computed=digest_object(snapshot.source.campaign),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def _verify_baseline_skill(self, snapshot: DraftSnapshot) -> None:
|
|
638
|
+
"""Require the baseline's Skill to be present exactly when it is declared.
|
|
639
|
+
|
|
640
|
+
Which it is follows from the Campaign, not from what happens to be on
|
|
641
|
+
disk: a ``skill_replacement`` baseline carries the Skill being revised
|
|
642
|
+
and the subject has to be handed its files, and an insertion baseline
|
|
643
|
+
carries none, so a stored tree for it would be a file nothing declares.
|
|
644
|
+
"""
|
|
645
|
+
declared = _declared_baseline_skill(snapshot.baseline)
|
|
646
|
+
staged = snapshot.baseline_skill
|
|
647
|
+
|
|
648
|
+
if declared is None:
|
|
649
|
+
_require(
|
|
650
|
+
staged is None,
|
|
651
|
+
"this draft snapshotted a baseline skill its baseline variant "
|
|
652
|
+
"does not declare",
|
|
653
|
+
_SKILL_INVALID,
|
|
654
|
+
draft_id=snapshot.draft.id,
|
|
655
|
+
)
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
if staged is None:
|
|
659
|
+
raise VerificationError(
|
|
660
|
+
"this draft's baseline variant declares the skill it replaces "
|
|
661
|
+
"and the draft did not snapshot it",
|
|
662
|
+
code=_SKILL_INVALID,
|
|
663
|
+
details={
|
|
664
|
+
"draft_id": snapshot.draft.id,
|
|
665
|
+
"declared": declared.digest,
|
|
666
|
+
},
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
self._verify_skill(staged)
|
|
670
|
+
_require(
|
|
671
|
+
staged.artifact.root_digest == declared.digest,
|
|
672
|
+
"the snapshotted baseline skill is not the skill the baseline "
|
|
673
|
+
"variant declares",
|
|
674
|
+
_SKILL_INVALID,
|
|
675
|
+
expected=declared.digest,
|
|
676
|
+
computed=staged.artifact.root_digest,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def _verify_manifests(self, snapshot: DraftSnapshot) -> None:
|
|
680
|
+
draft = snapshot.draft
|
|
681
|
+
baseline_digest = digest_object(snapshot.baseline)
|
|
682
|
+
candidate_digest = digest_object(snapshot.candidate)
|
|
683
|
+
|
|
684
|
+
_require(
|
|
685
|
+
baseline_digest == draft.baseline_manifest_digest,
|
|
686
|
+
"the snapshotted baseline manifest is not the one this draft names",
|
|
687
|
+
_MANIFEST_BUILD_FAILED,
|
|
688
|
+
expected=draft.baseline_manifest_digest,
|
|
689
|
+
computed=baseline_digest,
|
|
690
|
+
)
|
|
691
|
+
_require(
|
|
692
|
+
candidate_digest == draft.candidate_manifest_digest,
|
|
693
|
+
"the snapshotted candidate manifest is not the one this draft names",
|
|
694
|
+
_MANIFEST_BUILD_FAILED,
|
|
695
|
+
expected=draft.candidate_manifest_digest,
|
|
696
|
+
computed=candidate_digest,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
comparison = snapshot.comparison
|
|
700
|
+
_require(
|
|
701
|
+
comparison.baseline_configuration_digest
|
|
702
|
+
== snapshot.baseline.configuration_digest
|
|
703
|
+
and comparison.candidate_configuration_digest
|
|
704
|
+
== snapshot.candidate.configuration_digest,
|
|
705
|
+
"the stored comparison was made between different configurations "
|
|
706
|
+
"than the manifests in this draft",
|
|
707
|
+
_MANIFEST_COMPARISON_INVALID,
|
|
708
|
+
)
|
|
709
|
+
_require(
|
|
710
|
+
comparison.controlled,
|
|
711
|
+
"the stored comparison says this candidate differs from its "
|
|
712
|
+
"baseline somewhere the Campaign does not permit",
|
|
713
|
+
_MANIFEST_COMPARISON_INVALID,
|
|
714
|
+
violations=list(comparison.violations),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def _verify_skill(self, staged: StagedSkill) -> None:
|
|
718
|
+
"""Check one skill's artifact against the bytes stored beside it."""
|
|
719
|
+
artifact = staged.artifact
|
|
720
|
+
recomputed = skill_content_digest(artifact.files)
|
|
721
|
+
_require(
|
|
722
|
+
recomputed == artifact.root_digest,
|
|
723
|
+
"the candidate skill's root digest does not describe the files it lists",
|
|
724
|
+
_SKILL_INVALID,
|
|
725
|
+
expected=artifact.root_digest,
|
|
726
|
+
computed=recomputed,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
for entry in artifact.files:
|
|
730
|
+
path = staged.files / entry.path
|
|
731
|
+
try:
|
|
732
|
+
data = path.read_bytes()
|
|
733
|
+
except OSError as error:
|
|
734
|
+
raise VerificationError(
|
|
735
|
+
f"the candidate skill is missing {entry.path}",
|
|
736
|
+
code=_SKILL_INVALID,
|
|
737
|
+
details={"path": entry.path},
|
|
738
|
+
) from error
|
|
739
|
+
_require(
|
|
740
|
+
len(data) == entry.size and sha256_digest_bytes(data) == entry.digest,
|
|
741
|
+
f"the snapshotted candidate skill file {entry.path} is not what "
|
|
742
|
+
"the artifact says it is",
|
|
743
|
+
_SKILL_INVALID,
|
|
744
|
+
path=entry.path,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
_require(
|
|
748
|
+
verify_archive(staged.archive, artifact),
|
|
749
|
+
"the candidate skill archive does not match the artifact beside it",
|
|
750
|
+
_SKILL_INVALID,
|
|
751
|
+
archive_digest=artifact.archive_digest,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# -- The start claim ---------------------------------------------------
|
|
755
|
+
|
|
756
|
+
def claim_start(
|
|
757
|
+
self,
|
|
758
|
+
*,
|
|
759
|
+
draft_id: str,
|
|
760
|
+
run_id: str,
|
|
761
|
+
) -> DraftStartRecord:
|
|
762
|
+
"""Spend this draft on exactly one run, or return the run it was spent on."""
|
|
763
|
+
validate_id(run_id, "run")
|
|
764
|
+
self._require_draft(draft_id)
|
|
765
|
+
|
|
766
|
+
with self._lock(draft_id):
|
|
767
|
+
existing = self._read_start_record(draft_id)
|
|
768
|
+
if existing is not None:
|
|
769
|
+
# A retried start is not a second start. Returning the claim
|
|
770
|
+
# the first one wrote is what makes the operation idempotent
|
|
771
|
+
# rather than merely safe.
|
|
772
|
+
return existing
|
|
773
|
+
|
|
774
|
+
self.load_snapshot(draft_id)
|
|
775
|
+
|
|
776
|
+
record = DraftStartRecord(
|
|
777
|
+
draft_id=draft_id,
|
|
778
|
+
run_id=run_id,
|
|
779
|
+
status=DraftStartStatus.CLAIMED,
|
|
780
|
+
claimed_at=utc_now(),
|
|
781
|
+
launched_at=None,
|
|
782
|
+
launch_error_code=None,
|
|
783
|
+
)
|
|
784
|
+
self._write_immutable(self.draft_dir(draft_id) / _START_FILE, record)
|
|
785
|
+
return record
|
|
786
|
+
|
|
787
|
+
def mark_launched(
|
|
788
|
+
self,
|
|
789
|
+
*,
|
|
790
|
+
draft_id: str,
|
|
791
|
+
run_id: str,
|
|
792
|
+
launched_at: datetime,
|
|
793
|
+
) -> DraftStartRecord:
|
|
794
|
+
"""Record that the claimed run is now running."""
|
|
795
|
+
return self._update_start(
|
|
796
|
+
draft_id,
|
|
797
|
+
run_id,
|
|
798
|
+
status=DraftStartStatus.LAUNCHED,
|
|
799
|
+
launched_at=launched_at,
|
|
800
|
+
launch_error_code=None,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def mark_launch_failed(
|
|
804
|
+
self,
|
|
805
|
+
*,
|
|
806
|
+
draft_id: str,
|
|
807
|
+
run_id: str,
|
|
808
|
+
error_code: str,
|
|
809
|
+
) -> DraftStartRecord:
|
|
810
|
+
"""Record that the claimed run could not be launched."""
|
|
811
|
+
return self._update_start(
|
|
812
|
+
draft_id,
|
|
813
|
+
run_id,
|
|
814
|
+
status=DraftStartStatus.LAUNCH_FAILED,
|
|
815
|
+
launched_at=None,
|
|
816
|
+
launch_error_code=error_code,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
def start_record(self, draft_id: str) -> DraftStartRecord | None:
|
|
820
|
+
"""Return the existing start claim, or ``None`` if there is not one."""
|
|
821
|
+
self._require_draft(draft_id)
|
|
822
|
+
return self._read_start_record(draft_id)
|
|
823
|
+
|
|
824
|
+
def _update_start(
|
|
825
|
+
self,
|
|
826
|
+
draft_id: str,
|
|
827
|
+
run_id: str,
|
|
828
|
+
*,
|
|
829
|
+
status: DraftStartStatus,
|
|
830
|
+
launched_at: datetime | None,
|
|
831
|
+
launch_error_code: str | None,
|
|
832
|
+
) -> DraftStartRecord:
|
|
833
|
+
self._require_draft(draft_id)
|
|
834
|
+
with self._lock(draft_id):
|
|
835
|
+
record = self._read_start_record(draft_id)
|
|
836
|
+
if record is None:
|
|
837
|
+
raise NotFoundError(
|
|
838
|
+
f"draft {draft_id} has not been started, so there is no "
|
|
839
|
+
"claim to update",
|
|
840
|
+
code=_DRAFT_NOT_STARTED,
|
|
841
|
+
details={"draft_id": draft_id},
|
|
842
|
+
)
|
|
843
|
+
if record.run_id != run_id:
|
|
844
|
+
raise ConflictError(
|
|
845
|
+
f"draft {draft_id} was already claimed by run {record.run_id}",
|
|
846
|
+
code=_DRAFT_START_CONFLICT,
|
|
847
|
+
details={
|
|
848
|
+
"draft_id": draft_id,
|
|
849
|
+
"claimed_run_id": record.run_id,
|
|
850
|
+
"requested_run_id": run_id,
|
|
851
|
+
},
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
updated = DraftStartRecord(
|
|
855
|
+
draft_id=record.draft_id,
|
|
856
|
+
run_id=record.run_id,
|
|
857
|
+
status=status,
|
|
858
|
+
claimed_at=record.claimed_at,
|
|
859
|
+
launched_at=launched_at or record.launched_at,
|
|
860
|
+
launch_error_code=launch_error_code,
|
|
861
|
+
)
|
|
862
|
+
self._write_state(self.draft_dir(draft_id) / _START_FILE, updated)
|
|
863
|
+
return updated
|
|
864
|
+
|
|
865
|
+
def _read_start_record(self, draft_id: str) -> DraftStartRecord | None:
|
|
866
|
+
path = self.draft_dir(draft_id) / _START_FILE
|
|
867
|
+
if not path.exists():
|
|
868
|
+
return None
|
|
869
|
+
return self._parse(path, DraftStartRecord, draft_id)
|
|
870
|
+
|
|
871
|
+
# -- Internals ---------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
def _load[ModelT: BaseModel](
|
|
874
|
+
self, draft_id: str, relative: str, model: type[ModelT]
|
|
875
|
+
) -> ModelT:
|
|
876
|
+
self._require_draft(draft_id)
|
|
877
|
+
return self._parse(self.draft_dir(draft_id) / relative, model, draft_id)
|
|
878
|
+
|
|
879
|
+
def _parse[ModelT: BaseModel](
|
|
880
|
+
self, path: Path, model: type[ModelT], draft_id: str
|
|
881
|
+
) -> ModelT:
|
|
882
|
+
try:
|
|
883
|
+
raw = path.read_bytes()
|
|
884
|
+
except FileNotFoundError as error:
|
|
885
|
+
raise NotFoundError(
|
|
886
|
+
f"draft {draft_id} is missing {path.name}",
|
|
887
|
+
code=_DRAFT_NOT_FOUND,
|
|
888
|
+
details={"draft_id": draft_id, "file": path.name},
|
|
889
|
+
) from error
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
return model.model_validate_json(raw)
|
|
893
|
+
except PydanticValidationError as error:
|
|
894
|
+
raise ValidationError(
|
|
895
|
+
f"draft {draft_id} holds an invalid {path.name}: "
|
|
896
|
+
f"{_first_problem(error)}",
|
|
897
|
+
code=_CATALOG_GRAPH_INVALID,
|
|
898
|
+
details={"draft_id": draft_id, "file": path.name},
|
|
899
|
+
) from error
|
|
900
|
+
|
|
901
|
+
def _require_draft(self, draft_id: str) -> None:
|
|
902
|
+
if not (self.draft_dir(draft_id) / _DRAFT_FILE).exists():
|
|
903
|
+
raise NotFoundError(
|
|
904
|
+
f"no such draft: {draft_id}",
|
|
905
|
+
code=_DRAFT_NOT_FOUND,
|
|
906
|
+
details={"draft_id": draft_id},
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
def _write_immutable(self, path: Path, value: object) -> None:
|
|
910
|
+
"""Create a write-once file holding one object's canonical bytes."""
|
|
911
|
+
data = canonical_json_bytes(value)
|
|
912
|
+
with open_exclusive(path, _FILE_MODE) as handle:
|
|
913
|
+
handle.write(data)
|
|
914
|
+
handle.flush()
|
|
915
|
+
os.fsync(handle.fileno())
|
|
916
|
+
fsync_directory(path.parent)
|
|
917
|
+
|
|
918
|
+
def _write_state(self, path: Path, value: object) -> None:
|
|
919
|
+
"""Replace one mutable projection atomically, in canonical bytes."""
|
|
920
|
+
atomic_write_bytes(path, canonical_json_bytes(value), mode=_FILE_MODE)
|
|
921
|
+
|
|
922
|
+
@contextmanager
|
|
923
|
+
def _lock(self, draft_id: str) -> Iterator[None]:
|
|
924
|
+
"""Hold the per-draft lock, or report the wait as a conflict."""
|
|
925
|
+
lock = FileLock(
|
|
926
|
+
self.draft_dir(draft_id) / _LOCK_FILE,
|
|
927
|
+
timeout=LOCK_TIMEOUT_SECONDS,
|
|
928
|
+
mode=_FILE_MODE,
|
|
929
|
+
)
|
|
930
|
+
try:
|
|
931
|
+
lock.acquire()
|
|
932
|
+
except Timeout as error:
|
|
933
|
+
raise ConflictError(
|
|
934
|
+
f"another process is holding the lock on draft {draft_id}",
|
|
935
|
+
details={"draft_id": draft_id, "waited_seconds": LOCK_TIMEOUT_SECONDS},
|
|
936
|
+
) from error
|
|
937
|
+
try:
|
|
938
|
+
yield
|
|
939
|
+
finally:
|
|
940
|
+
lock.release()
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _staged_at(directory: Path, artifact: SkillArtifact) -> StagedSkill:
|
|
944
|
+
"""Return one skill placed in the layout every skill directory uses."""
|
|
945
|
+
return StagedSkill(
|
|
946
|
+
artifact=artifact,
|
|
947
|
+
archive=directory / _BUNDLE_FILE,
|
|
948
|
+
files=directory / _FILES_DIR,
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _declared_baseline_skill(baseline: ExperimentManifest) -> ArtifactRef | None:
|
|
953
|
+
"""Return the one skill a baseline variant declares, when it declares one.
|
|
954
|
+
|
|
955
|
+
Only the mutation kinds v0.1 defines can occur here: an insertion baseline
|
|
956
|
+
declares none and a replacement baseline declares exactly the skill being
|
|
957
|
+
revised. A baseline naming more than one is refused by the manifest
|
|
958
|
+
builder, the comparison, and the Campaign model before it could reach this.
|
|
959
|
+
"""
|
|
960
|
+
subject = baseline.configuration.agents.get(SUBJECT_AGENT)
|
|
961
|
+
if subject is None or not subject.harness.skills:
|
|
962
|
+
return None
|
|
963
|
+
return subject.harness.skills[0]
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _require(
|
|
967
|
+
condition: bool,
|
|
968
|
+
message: str,
|
|
969
|
+
code: str,
|
|
970
|
+
**details: object,
|
|
971
|
+
) -> None:
|
|
972
|
+
"""Raise a typed verification failure unless the condition holds."""
|
|
973
|
+
if condition:
|
|
974
|
+
return
|
|
975
|
+
raise VerificationError(
|
|
976
|
+
message,
|
|
977
|
+
code=code,
|
|
978
|
+
details={key: _as_json(value) for key, value in details.items()},
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _as_json(value: object) -> JsonValue:
|
|
983
|
+
if isinstance(value, list):
|
|
984
|
+
items: list[JsonValue] = [str(item) for item in value]
|
|
985
|
+
return items
|
|
986
|
+
return str(value)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _first_problem(error: PydanticValidationError) -> str:
|
|
990
|
+
first = error.errors()[0]
|
|
991
|
+
location = ".".join(str(part) for part in first["loc"])
|
|
992
|
+
return f"{location}: {first['msg']}" if location else str(first["msg"])
|