techtree 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""Publishing one run to the public log. Decisions document 0038.
|
|
2
|
+
|
|
3
|
+
The order of the steps below is the product, so it is written once, here, and
|
|
4
|
+
the command above it does no deciding of its own.
|
|
5
|
+
|
|
6
|
+
*The proof is checked before anything is offered.* A result whose own proof does
|
|
7
|
+
not hold together is never put in front of somebody as a thing they could
|
|
8
|
+
publish. Offering it and then refusing at the end would be the same code with
|
|
9
|
+
the trust the wrong way round; this way the only results that reach the question
|
|
10
|
+
are the ones that survive it.
|
|
11
|
+
|
|
12
|
+
*What is sent is the run's own bytes.* The proof directory, file by file,
|
|
13
|
+
exactly as it sits on disk, because the bundle manifest already commits to every
|
|
14
|
+
one of those files by digest under the participant's key. Re-deriving them would
|
|
15
|
+
send something nobody signed. The directory holds no transcripts: an episode
|
|
16
|
+
receipt carries digests, task hashes and scores, and the raw episodes are
|
|
17
|
+
outside it.
|
|
18
|
+
|
|
19
|
+
*Nothing about the run is written back.* A completed run's files are final. This
|
|
20
|
+
adds two: the countersigned receipt, and a journal of its own that says what was
|
|
21
|
+
attempted and how it went. Neither is inside the proof, and nothing already in
|
|
22
|
+
the run directory is touched.
|
|
23
|
+
|
|
24
|
+
*The attempt is recorded before it is made.* The pending line goes down first, so
|
|
25
|
+
a process that dies mid-request leaves a run that says an attempt was started
|
|
26
|
+
rather than a run that says nothing happened.
|
|
27
|
+
|
|
28
|
+
*Nothing is written down that has not been checked against the pinned key.* The
|
|
29
|
+
receipt is verified against the network key the release pins — not against the
|
|
30
|
+
key the receipt itself carries — before it becomes a file. An unverified receipt
|
|
31
|
+
is not weaker evidence; it is no evidence, and filing one would put something in
|
|
32
|
+
a run directory that looks exactly like proof of publication and is not.
|
|
33
|
+
|
|
34
|
+
*A retry converges rather than conflicting.* The failure this is for is real and
|
|
35
|
+
narrow: the run log accepts a submission, the answer is lost on the way back, the
|
|
36
|
+
CLI records a failure, and the person quite reasonably runs the command again.
|
|
37
|
+
The log answers the second attempt with the same entry, so the receipt write
|
|
38
|
+
finds the same bytes already there and succeeds. Different bytes under the same
|
|
39
|
+
name is the case that is genuinely wrong, and that is the one it refuses.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import base64
|
|
45
|
+
import re
|
|
46
|
+
from collections.abc import Callable
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from datetime import datetime
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Final, TypeVar, cast
|
|
51
|
+
|
|
52
|
+
from pydantic import BaseModel
|
|
53
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
54
|
+
|
|
55
|
+
from techtree.canonical import canonical_json_bytes, sha256_digest_bytes
|
|
56
|
+
from techtree.constants import (
|
|
57
|
+
PUBLICATION_JOURNAL_SCHEMA_VERSION,
|
|
58
|
+
PUBLICATION_SUBMISSION_SCHEMA_VERSION,
|
|
59
|
+
)
|
|
60
|
+
from techtree.errors import (
|
|
61
|
+
ConflictError,
|
|
62
|
+
NotFoundError,
|
|
63
|
+
PolicyError,
|
|
64
|
+
TechtreeError,
|
|
65
|
+
ValidationError,
|
|
66
|
+
VerificationError,
|
|
67
|
+
)
|
|
68
|
+
from techtree.fs import fsync_directory, open_exclusive
|
|
69
|
+
from techtree.identity.models import VerificationResult
|
|
70
|
+
from techtree.manifests.builder import skill_content_digest
|
|
71
|
+
from techtree.models.base import Digest, ObjectEnvelope
|
|
72
|
+
from techtree.models.experiment import ExperimentManifest
|
|
73
|
+
from techtree.models.skill import SkillArtifact, SubmissionDraft
|
|
74
|
+
from techtree.models.uplift_report import PublicationStatus, UpliftReport
|
|
75
|
+
from techtree.publication.journal import PublicationJournal, PublicationJournalEntry
|
|
76
|
+
from techtree.publication.models import (
|
|
77
|
+
PublicationReceiptPayload,
|
|
78
|
+
PublicationSubmission,
|
|
79
|
+
)
|
|
80
|
+
from techtree.publication.transport import (
|
|
81
|
+
PublicationMetadataTransport,
|
|
82
|
+
PublicationTransport,
|
|
83
|
+
resolved_endpoint,
|
|
84
|
+
)
|
|
85
|
+
from techtree.publication.verify import (
|
|
86
|
+
PUBLICATION_RECEIPT_INVALID,
|
|
87
|
+
verify_publication_receipt,
|
|
88
|
+
)
|
|
89
|
+
from techtree.receipts.bundle import (
|
|
90
|
+
BUNDLE_MANIFEST_FILENAME,
|
|
91
|
+
PROOF_BUNDLE_INVALID,
|
|
92
|
+
REPORT_FILENAME,
|
|
93
|
+
LocalProofBundleManifest,
|
|
94
|
+
proof_bundle_dir,
|
|
95
|
+
)
|
|
96
|
+
from techtree.receipts.uplift import publication_eligible_for
|
|
97
|
+
from techtree.receipts.verify import LocalProofVerifier
|
|
98
|
+
from techtree.release.models import PublicationCoordinates
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"PUBLICATION_NOT_ELIGIBLE",
|
|
102
|
+
"PUBLICATION_PROOF_NOT_FOUND",
|
|
103
|
+
"PUBLICATION_RECEIPT_CONFLICT",
|
|
104
|
+
"PUBLICATION_RECEIPT_FILENAME",
|
|
105
|
+
"RUN_ALREADY_PUBLISHED",
|
|
106
|
+
"PublicationOutcome",
|
|
107
|
+
"PublicationPlan",
|
|
108
|
+
"PublicationService",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
#: The countersigned receipt, written into the run directory as a new file.
|
|
112
|
+
PUBLICATION_RECEIPT_FILENAME: Final = "publication-receipt.json"
|
|
113
|
+
|
|
114
|
+
#: Stable error codes this module reports.
|
|
115
|
+
PUBLICATION_PROOF_NOT_FOUND: Final = "publication_proof_not_found"
|
|
116
|
+
PUBLICATION_NOT_ELIGIBLE: Final = "publication_not_eligible"
|
|
117
|
+
#: Two different receipts, under one name, for one run. Not a retry: a retry
|
|
118
|
+
#: brings back the same entry and therefore the same bytes.
|
|
119
|
+
PUBLICATION_RECEIPT_CONFLICT: Final = "publication_receipt_conflict"
|
|
120
|
+
RUN_ALREADY_PUBLISHED: Final = "run_already_published"
|
|
121
|
+
|
|
122
|
+
_INPUTS_DIRECTORY: Final = "inputs"
|
|
123
|
+
_DRAFT_FILENAME: Final = "draft.json"
|
|
124
|
+
_SKILL_DIRECTORY: Final = "skill"
|
|
125
|
+
_SKILL_ARTIFACT_FILENAME: Final = "artifact.json"
|
|
126
|
+
_CANDIDATE_MANIFEST_FILENAME: Final = "candidate-experiment.json"
|
|
127
|
+
_SKILL_NAME_PATTERN: Final = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}\Z")
|
|
128
|
+
|
|
129
|
+
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class PublicationPlan:
|
|
134
|
+
"""Exactly what one publication would send, worked out before it is asked.
|
|
135
|
+
|
|
136
|
+
The plan is what a person is shown so that they can agree to something they
|
|
137
|
+
have actually seen, so it carries the two numbers that answer "how much of
|
|
138
|
+
my machine is about to leave it" — how many files, and how many bytes — and
|
|
139
|
+
not the files themselves. The bytes on the wire are
|
|
140
|
+
:meth:`PublicationService.submission_bytes`'s answer and nobody else's, so
|
|
141
|
+
there is one place a submission is built and one shape it can have.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
run_id: str
|
|
145
|
+
bundle_digest: Digest
|
|
146
|
+
endpoint: str
|
|
147
|
+
#: How many files would travel, counted off the proof directory.
|
|
148
|
+
file_count: int
|
|
149
|
+
#: How many bytes of proof would travel, before base64 widens them on the
|
|
150
|
+
#: wire. It is the size of the thing a person recognises — the proof
|
|
151
|
+
#: directory — rather than the size of its encoding.
|
|
152
|
+
byte_count: int
|
|
153
|
+
report: UpliftReport
|
|
154
|
+
verification: VerificationResult
|
|
155
|
+
#: The candidate Skill's prepared public label, when the run still carries
|
|
156
|
+
#: its immutable inputs and that label's root digest matches the verified
|
|
157
|
+
#: candidate experiment. Older runs predate publication metadata and leave
|
|
158
|
+
#: this absent rather than inventing a name.
|
|
159
|
+
skill_name: str | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class PublicationOutcome:
|
|
164
|
+
"""What one completed publication produced."""
|
|
165
|
+
|
|
166
|
+
run_id: str
|
|
167
|
+
receipt: PublicationReceiptPayload
|
|
168
|
+
receipt_path: Path
|
|
169
|
+
status: PublicationStatus
|
|
170
|
+
#: Whether an address was sent with this submission. The address itself is
|
|
171
|
+
#: not here, and is not anywhere else on this machine either.
|
|
172
|
+
contributor_address_sent: bool
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PublicationService:
|
|
176
|
+
"""Plans and performs the publication of one finished run."""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
runs_dir: Path,
|
|
182
|
+
coordinates: PublicationCoordinates,
|
|
183
|
+
endpoint_override: str | None,
|
|
184
|
+
transport: PublicationTransport,
|
|
185
|
+
clock: Callable[[], datetime],
|
|
186
|
+
) -> None:
|
|
187
|
+
self._runs_dir = runs_dir
|
|
188
|
+
self._coordinates = coordinates
|
|
189
|
+
self._endpoint_override = endpoint_override
|
|
190
|
+
self._transport = transport
|
|
191
|
+
self._clock = clock
|
|
192
|
+
|
|
193
|
+
# -- planning -----------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def plan(self, run_id: str) -> PublicationPlan:
|
|
196
|
+
"""Return what publishing this run would send, or refuse to publish it.
|
|
197
|
+
|
|
198
|
+
Every refusal that can be reached without a network is reached here, so
|
|
199
|
+
a person is only ever asked about a submission that could actually be
|
|
200
|
+
made.
|
|
201
|
+
"""
|
|
202
|
+
directory = self._bundle_dir(run_id)
|
|
203
|
+
verification = LocalProofVerifier().verify_bundle(directory)
|
|
204
|
+
if not verification.verified:
|
|
205
|
+
raise VerificationError(
|
|
206
|
+
f"run {run_id}'s own proof does not verify, so there is nothing "
|
|
207
|
+
"here that could honestly be published: "
|
|
208
|
+
f"{verification.failures[0].detail}",
|
|
209
|
+
code=PROOF_BUNDLE_INVALID,
|
|
210
|
+
details={
|
|
211
|
+
"run_id": run_id,
|
|
212
|
+
"failed_checks": [message.id for message in verification.failures],
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
report = self._report(directory, run_id)
|
|
217
|
+
# Decided from the report's own grade and rights, never from the flag
|
|
218
|
+
# stored beside them. That flag records what the build that WROTE the
|
|
219
|
+
# report allowed, which is a fact about that build: every report signed
|
|
220
|
+
# before publishing existed stores ``false``, so reading it here refused
|
|
221
|
+
# every run that exists, including the certification runs. The offline
|
|
222
|
+
# verifier had the same bug from the other direction and was fixed the
|
|
223
|
+
# same way.
|
|
224
|
+
if not publication_eligible_for(
|
|
225
|
+
grade=report.proof_grade, publication=report.statuses.publication
|
|
226
|
+
):
|
|
227
|
+
raise PolicyError(
|
|
228
|
+
f"run {run_id}'s report may not be published: "
|
|
229
|
+
+ (
|
|
230
|
+
"its rights statement blocks publication"
|
|
231
|
+
if report.statuses.publication is PublicationStatus.BLOCKED
|
|
232
|
+
else f"it is graded {report.proof_grade}, and only a P1 "
|
|
233
|
+
"report is evidence of anything"
|
|
234
|
+
),
|
|
235
|
+
code=PUBLICATION_NOT_ELIGIBLE,
|
|
236
|
+
details={
|
|
237
|
+
"run_id": run_id,
|
|
238
|
+
"proof_grade": report.proof_grade,
|
|
239
|
+
"publication": report.statuses.publication.value,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
journal = PublicationJournal(self._run_dir(run_id))
|
|
244
|
+
published = journal.published()
|
|
245
|
+
if published is not None:
|
|
246
|
+
raise ConflictError(
|
|
247
|
+
f"run {run_id} is already in the public log, at "
|
|
248
|
+
f"{published.entry_url}. A published entry stays where it is",
|
|
249
|
+
code=RUN_ALREADY_PUBLISHED,
|
|
250
|
+
details={"run_id": run_id, "entry_url": published.entry_url},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
stored = self._proof_files(directory)
|
|
254
|
+
return PublicationPlan(
|
|
255
|
+
run_id=run_id,
|
|
256
|
+
bundle_digest=self._bundle_digest(directory, run_id),
|
|
257
|
+
endpoint=self.endpoint,
|
|
258
|
+
file_count=len(stored),
|
|
259
|
+
byte_count=sum(len(data) for data in stored.values()),
|
|
260
|
+
report=report,
|
|
261
|
+
verification=verification,
|
|
262
|
+
skill_name=self._skill_name(run_id, directory),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# -- publishing ---------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def publish(
|
|
268
|
+
self,
|
|
269
|
+
plan: PublicationPlan,
|
|
270
|
+
*,
|
|
271
|
+
contributor_address: str | None = None,
|
|
272
|
+
skill_github_url: str | None = None,
|
|
273
|
+
) -> PublicationOutcome:
|
|
274
|
+
"""Send the planned submission, record what happened, and return it.
|
|
275
|
+
|
|
276
|
+
``contributor_address`` is already canonical by the time it arrives:
|
|
277
|
+
checking it is :mod:`techtree.publication.address`'s job. This method's
|
|
278
|
+
job is to make sure it travels beside the request and nowhere else at
|
|
279
|
+
all — not into the submission, not into the journal, not into a log
|
|
280
|
+
line. The run log stores what it is sent and serves those exact bytes
|
|
281
|
+
back at a public address, so an address inside the body would be public
|
|
282
|
+
by construction; the receiving side reads it from a header for that
|
|
283
|
+
reason and refuses a body carrying anything but the proof.
|
|
284
|
+
"""
|
|
285
|
+
journal = PublicationJournal(self._run_dir(plan.run_id))
|
|
286
|
+
self._record(journal, plan, status=PublicationStatus.PENDING)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
envelope = self._submit(plan, contributor_address, skill_github_url)
|
|
290
|
+
path = self._write_receipt(plan.run_id, envelope)
|
|
291
|
+
except TechtreeError as error:
|
|
292
|
+
self._record(
|
|
293
|
+
journal, plan, status=PublicationStatus.FAILED, error_code=error.code
|
|
294
|
+
)
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
receipt = envelope.payload
|
|
298
|
+
self._record(
|
|
299
|
+
journal,
|
|
300
|
+
plan,
|
|
301
|
+
status=PublicationStatus.PUBLISHED,
|
|
302
|
+
entry_url=receipt.entry_url,
|
|
303
|
+
log_sequence=receipt.log_sequence,
|
|
304
|
+
)
|
|
305
|
+
return PublicationOutcome(
|
|
306
|
+
run_id=plan.run_id,
|
|
307
|
+
receipt=receipt,
|
|
308
|
+
receipt_path=path,
|
|
309
|
+
status=PublicationStatus.PUBLISHED,
|
|
310
|
+
contributor_address_sent=contributor_address is not None,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def status(self, run_id: str) -> PublicationStatus:
|
|
314
|
+
"""Return where this run stands, read from its journal."""
|
|
315
|
+
return PublicationJournal(self._run_dir(run_id)).status()
|
|
316
|
+
|
|
317
|
+
def publication_eligible(self, run_id: str) -> bool:
|
|
318
|
+
"""Return whether this run's own report says it may be published.
|
|
319
|
+
|
|
320
|
+
Asked by the surfaces that decide whether to *offer* publishing, so a
|
|
321
|
+
run with no proof on this machine is simply not eligible rather than an
|
|
322
|
+
error: nothing is being published here, and a missing bundle is a
|
|
323
|
+
complete answer to the question that was asked.
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
directory = self._bundle_dir(run_id)
|
|
327
|
+
except NotFoundError:
|
|
328
|
+
return False
|
|
329
|
+
report = self._report(directory, run_id)
|
|
330
|
+
return publication_eligible_for(
|
|
331
|
+
grade=report.proof_grade, publication=report.statuses.publication
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# -- the request --------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def submission_bytes(self, run_id: str) -> bytes:
|
|
337
|
+
"""Return the exact bytes a submission for this run puts on the wire.
|
|
338
|
+
|
|
339
|
+
This is the whole of the wire shape and the only place it is built, so
|
|
340
|
+
the request the transport sends and the conformance fixture the
|
|
341
|
+
receiving side is tested against cannot be two different documents that
|
|
342
|
+
happen to look alike. Decisions 0038 fixes the four members; the model
|
|
343
|
+
refuses a fifth, and the canonical encoding fixes the byte order, so
|
|
344
|
+
the same proof directory produces the same bytes on any machine.
|
|
345
|
+
|
|
346
|
+
It describes rather than sends. Whether this run *may* be published is
|
|
347
|
+
:meth:`plan`'s question, asked of the run's own verified proof and its
|
|
348
|
+
rights statement before anybody is offered anything, and :meth:`publish`
|
|
349
|
+
is the only method here that opens a socket.
|
|
350
|
+
"""
|
|
351
|
+
directory = self._bundle_dir(run_id)
|
|
352
|
+
submission = PublicationSubmission(
|
|
353
|
+
schema_version=PUBLICATION_SUBMISSION_SCHEMA_VERSION,
|
|
354
|
+
run_id=run_id,
|
|
355
|
+
bundle_digest=self._bundle_digest(directory, run_id),
|
|
356
|
+
files=self._files(directory),
|
|
357
|
+
)
|
|
358
|
+
return canonical_json_bytes(submission)
|
|
359
|
+
|
|
360
|
+
def _submit(
|
|
361
|
+
self,
|
|
362
|
+
plan: PublicationPlan,
|
|
363
|
+
contributor_address: str | None,
|
|
364
|
+
skill_github_url: str | None,
|
|
365
|
+
) -> ObjectEnvelope[PublicationReceiptPayload]:
|
|
366
|
+
"""Send one submission and return the receipt it came back with."""
|
|
367
|
+
body = self.submission_bytes(plan.run_id)
|
|
368
|
+
if plan.skill_name is None and skill_github_url is None:
|
|
369
|
+
# Keep compatibility with transports supplied by callers that
|
|
370
|
+
# predate the optional metadata headers.
|
|
371
|
+
response = self._transport.submit(
|
|
372
|
+
endpoint=plan.endpoint,
|
|
373
|
+
body=body,
|
|
374
|
+
contributor_address=contributor_address,
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
response = cast(PublicationMetadataTransport, self._transport).submit(
|
|
378
|
+
endpoint=plan.endpoint,
|
|
379
|
+
body=body,
|
|
380
|
+
contributor_address=contributor_address,
|
|
381
|
+
skill_name=plan.skill_name,
|
|
382
|
+
skill_github_url=skill_github_url,
|
|
383
|
+
)
|
|
384
|
+
return self._receipt(response, plan)
|
|
385
|
+
|
|
386
|
+
def _receipt(
|
|
387
|
+
self, response: bytes, plan: PublicationPlan
|
|
388
|
+
) -> ObjectEnvelope[PublicationReceiptPayload]:
|
|
389
|
+
"""Parse the answer and refuse everything that is not this run's receipt.
|
|
390
|
+
|
|
391
|
+
Parsing is here; the checks are in :mod:`techtree.publication.verify`,
|
|
392
|
+
because they are about the network's word rather than about this run
|
|
393
|
+
directory, and the withdrawal path needs the same ones.
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
envelope = ObjectEnvelope[PublicationReceiptPayload].model_validate_json(
|
|
397
|
+
response
|
|
398
|
+
)
|
|
399
|
+
except PydanticValidationError as error:
|
|
400
|
+
raise ValidationError(
|
|
401
|
+
"the run log answered with something that is not a publication "
|
|
402
|
+
"receipt, so nothing was recorded",
|
|
403
|
+
code=PUBLICATION_RECEIPT_INVALID,
|
|
404
|
+
details={"run_id": plan.run_id},
|
|
405
|
+
) from error
|
|
406
|
+
|
|
407
|
+
verify_publication_receipt(
|
|
408
|
+
envelope,
|
|
409
|
+
coordinates=self._coordinates,
|
|
410
|
+
run_id=plan.run_id,
|
|
411
|
+
bundle_digest=plan.bundle_digest,
|
|
412
|
+
)
|
|
413
|
+
return envelope
|
|
414
|
+
|
|
415
|
+
def _write_receipt(
|
|
416
|
+
self, run_id: str, receipt: ObjectEnvelope[PublicationReceiptPayload]
|
|
417
|
+
) -> Path:
|
|
418
|
+
"""Write the receipt into the run directory, converging on a retry.
|
|
419
|
+
|
|
420
|
+
Created with ``O_EXCL``, so nothing that is already there is opened for
|
|
421
|
+
writing. What is found there decides the answer: the same bytes mean the
|
|
422
|
+
run log answered the same way twice, which is what a retry after a lost
|
|
423
|
+
response looks like and is a success; different bytes under the same name
|
|
424
|
+
are two different records of one publication, and there is no version of
|
|
425
|
+
that which is safe to resolve by choosing one.
|
|
426
|
+
"""
|
|
427
|
+
path = self._run_dir(run_id) / PUBLICATION_RECEIPT_FILENAME
|
|
428
|
+
data = canonical_json_bytes(receipt)
|
|
429
|
+
try:
|
|
430
|
+
with open_exclusive(path) as handle:
|
|
431
|
+
handle.write(data)
|
|
432
|
+
handle.flush()
|
|
433
|
+
except ConflictError as error:
|
|
434
|
+
if path.read_bytes() == data:
|
|
435
|
+
return path
|
|
436
|
+
raise ConflictError(
|
|
437
|
+
f"run {run_id} already holds a different publication receipt, so "
|
|
438
|
+
"this one was not written: two receipts for one run cannot both "
|
|
439
|
+
"be its record",
|
|
440
|
+
code=PUBLICATION_RECEIPT_CONFLICT,
|
|
441
|
+
details={"run_id": run_id, "path": str(path)},
|
|
442
|
+
) from error
|
|
443
|
+
fsync_directory(path.parent)
|
|
444
|
+
return path
|
|
445
|
+
|
|
446
|
+
# -- recording ----------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def _record(
|
|
449
|
+
self,
|
|
450
|
+
journal: PublicationJournal,
|
|
451
|
+
plan: PublicationPlan,
|
|
452
|
+
*,
|
|
453
|
+
status: PublicationStatus,
|
|
454
|
+
entry_url: str | None = None,
|
|
455
|
+
log_sequence: int | None = None,
|
|
456
|
+
error_code: str | None = None,
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Append one line to the run's publication journal."""
|
|
459
|
+
journal.append(
|
|
460
|
+
PublicationJournalEntry(
|
|
461
|
+
schema_version=PUBLICATION_JOURNAL_SCHEMA_VERSION,
|
|
462
|
+
sequence=journal.next_sequence(),
|
|
463
|
+
at=self._clock(),
|
|
464
|
+
run_id=plan.run_id,
|
|
465
|
+
status=status,
|
|
466
|
+
bundle_digest=plan.bundle_digest,
|
|
467
|
+
endpoint=plan.endpoint,
|
|
468
|
+
file_count=plan.file_count,
|
|
469
|
+
byte_count=plan.byte_count,
|
|
470
|
+
entry_url=entry_url,
|
|
471
|
+
log_sequence=log_sequence,
|
|
472
|
+
error_code=error_code,
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# -- reading the run ----------------------------------------------------
|
|
477
|
+
|
|
478
|
+
def _run_dir(self, run_id: str) -> Path:
|
|
479
|
+
return self._runs_dir / run_id
|
|
480
|
+
|
|
481
|
+
def _skill_name(self, run_id: str, directory: Path) -> str | None:
|
|
482
|
+
"""Read a verified candidate Skill's public name from run inputs.
|
|
483
|
+
|
|
484
|
+
The proof verifies the candidate experiment, while the prepared Skill
|
|
485
|
+
artifact remains in the run's immutable inputs. The candidate manifest
|
|
486
|
+
is the bridge between them: only a draft artifact whose root digest is
|
|
487
|
+
exactly the candidate reference may contribute a public name. Metadata
|
|
488
|
+
is descriptive and optional, so an old or incomplete run simply has no
|
|
489
|
+
name rather than making its otherwise valid proof unpublishable.
|
|
490
|
+
"""
|
|
491
|
+
inputs = self._run_dir(run_id) / _INPUTS_DIRECTORY
|
|
492
|
+
try:
|
|
493
|
+
draft = self._load_json_model(inputs / _DRAFT_FILENAME, SubmissionDraft)
|
|
494
|
+
candidate = self._load_json_model(
|
|
495
|
+
directory / _CANDIDATE_MANIFEST_FILENAME, ExperimentManifest
|
|
496
|
+
)
|
|
497
|
+
skill = draft.skill_artifact
|
|
498
|
+
subject = candidate.configuration.agents.get("subject")
|
|
499
|
+
if subject is None or len(subject.harness.skills) != 1:
|
|
500
|
+
return None
|
|
501
|
+
if subject.harness.skills[0].digest != skill.root_digest:
|
|
502
|
+
return None
|
|
503
|
+
if skill_content_digest(skill.files) != skill.root_digest:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
# The copied artifact is part of the immutable prepared input. If
|
|
507
|
+
# present, require it to agree with the draft before exposing its
|
|
508
|
+
# label; this catches a stale or hand-edited input tree without
|
|
509
|
+
# affecting runs created before this metadata existed.
|
|
510
|
+
stored = inputs / _SKILL_DIRECTORY / _SKILL_ARTIFACT_FILENAME
|
|
511
|
+
if stored.is_file():
|
|
512
|
+
stored_skill = self._load_json_model(stored, SkillArtifact)
|
|
513
|
+
if stored_skill != skill:
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
files = inputs / _SKILL_DIRECTORY / "files"
|
|
517
|
+
if files.is_dir():
|
|
518
|
+
for entry in skill.files:
|
|
519
|
+
raw = (files / entry.path).read_bytes()
|
|
520
|
+
if (
|
|
521
|
+
len(raw) != entry.size
|
|
522
|
+
or sha256_digest_bytes(raw) != entry.digest
|
|
523
|
+
):
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
if _SKILL_NAME_PATTERN.fullmatch(skill.name) is None:
|
|
527
|
+
return None
|
|
528
|
+
return skill.name
|
|
529
|
+
except (OSError, PydanticValidationError, KeyError, TypeError, ValueError):
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
@staticmethod
|
|
533
|
+
def _load_json_model(path: Path, model: type[_ModelT]) -> _ModelT:
|
|
534
|
+
"""Load a strict protocol model from its exact stored JSON bytes."""
|
|
535
|
+
raw = path.read_bytes()
|
|
536
|
+
# The concrete model types are supplied by the two callers above. A
|
|
537
|
+
# tiny helper keeps both paths on model_validate_json rather than
|
|
538
|
+
# decoding to an untyped mapping first.
|
|
539
|
+
return model.model_validate_json(raw)
|
|
540
|
+
|
|
541
|
+
def _bundle_dir(self, run_id: str) -> Path:
|
|
542
|
+
directory = proof_bundle_dir(self._run_dir(run_id))
|
|
543
|
+
if not (directory / BUNDLE_MANIFEST_FILENAME).is_file():
|
|
544
|
+
raise NotFoundError(
|
|
545
|
+
f"run {run_id} has no proof to publish on this machine",
|
|
546
|
+
code=PUBLICATION_PROOF_NOT_FOUND,
|
|
547
|
+
details={"run_id": run_id},
|
|
548
|
+
)
|
|
549
|
+
return directory
|
|
550
|
+
|
|
551
|
+
def _bundle_digest(self, directory: Path, run_id: str) -> Digest:
|
|
552
|
+
"""Return the digest of the signed manifest that commits to the bundle.
|
|
553
|
+
|
|
554
|
+
One value identifies the whole submission, and it is the one the
|
|
555
|
+
verification that just passed checked every file against.
|
|
556
|
+
"""
|
|
557
|
+
raw = (directory / BUNDLE_MANIFEST_FILENAME).read_bytes()
|
|
558
|
+
try:
|
|
559
|
+
envelope = ObjectEnvelope[LocalProofBundleManifest].model_validate_json(raw)
|
|
560
|
+
except PydanticValidationError as error:
|
|
561
|
+
raise VerificationError(
|
|
562
|
+
f"run {run_id}'s proof manifest cannot be read",
|
|
563
|
+
code=PROOF_BUNDLE_INVALID,
|
|
564
|
+
details={"run_id": run_id},
|
|
565
|
+
) from error
|
|
566
|
+
return envelope.payload_digest
|
|
567
|
+
|
|
568
|
+
def _report(self, directory: Path, run_id: str) -> UpliftReport:
|
|
569
|
+
"""Return the report this bundle carries."""
|
|
570
|
+
raw = (directory / REPORT_FILENAME).read_bytes()
|
|
571
|
+
try:
|
|
572
|
+
envelope = ObjectEnvelope[UpliftReport].model_validate_json(raw)
|
|
573
|
+
except PydanticValidationError as error:
|
|
574
|
+
raise VerificationError(
|
|
575
|
+
f"run {run_id}'s report cannot be read out of its own proof",
|
|
576
|
+
code=PROOF_BUNDLE_INVALID,
|
|
577
|
+
details={"run_id": run_id},
|
|
578
|
+
) from error
|
|
579
|
+
return envelope.payload
|
|
580
|
+
|
|
581
|
+
def _proof_files(self, directory: Path) -> dict[str, bytes]:
|
|
582
|
+
"""Return every file in the proof directory against its stored bytes.
|
|
583
|
+
|
|
584
|
+
The whole directory rather than the manifest's list: the manifest does
|
|
585
|
+
not commit to itself, and a submission without the signed manifest is a
|
|
586
|
+
submission nothing can be checked against.
|
|
587
|
+
"""
|
|
588
|
+
return {
|
|
589
|
+
path.relative_to(directory).as_posix(): path.read_bytes()
|
|
590
|
+
for path in sorted(directory.rglob("*"))
|
|
591
|
+
if path.is_file()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
def _files(self, directory: Path) -> dict[str, str]:
|
|
595
|
+
"""Return the proof directory in the shape the wire carries it.
|
|
596
|
+
|
|
597
|
+
Path against base64 of the stored bytes, and nothing else. No digest
|
|
598
|
+
and no size travel beside a file, because both would be the submitter's
|
|
599
|
+
own arithmetic over the submitter's own bytes: they prove nothing, and
|
|
600
|
+
a receiving side that read them instead of the bundle's signed manifest
|
|
601
|
+
would be trusting the one party the manifest exists to avoid trusting.
|
|
602
|
+
Decisions 0038 fixes this, and the reason is worth more than the eight
|
|
603
|
+
bytes it saves.
|
|
604
|
+
"""
|
|
605
|
+
return {
|
|
606
|
+
path: base64.b64encode(data).decode("ascii")
|
|
607
|
+
for path, data in self._proof_files(directory).items()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def endpoint(self) -> str:
|
|
612
|
+
"""Return the address this service publishes to, override first.
|
|
613
|
+
|
|
614
|
+
Whichever wins is what the plan carries and what the review prints, so
|
|
615
|
+
somebody agreeing to a publication is agreeing to the address it is
|
|
616
|
+
actually going to.
|
|
617
|
+
"""
|
|
618
|
+
return resolved_endpoint(self._coordinates, self._endpoint_override)
|