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,156 @@
|
|
|
1
|
+
"""What may appear in a result, enforced rather than intended.
|
|
2
|
+
|
|
3
|
+
Spec section 7.17.
|
|
4
|
+
|
|
5
|
+
The evidence a run produces contains material that must not travel into a
|
|
6
|
+
rendering. Some of it is obvious — an absolute path naming somebody's home
|
|
7
|
+
directory. Some of it is the whole point of the evaluation: the taskset's
|
|
8
|
+
hidden expected answers and the grader that knows them. A presentation that
|
|
9
|
+
leaked either would not be a cosmetic bug; it would be a contaminated
|
|
10
|
+
benchmark.
|
|
11
|
+
|
|
12
|
+
So the rule here is that the payload's *shape* keeps hidden material out and
|
|
13
|
+
these functions keep it out of the free text the shape still allows:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
may appear task ordinal or public name, public prompt summary when the
|
|
17
|
+
policy allows it, score, delta
|
|
18
|
+
never appears hidden expected answer, grader source, absolute private path,
|
|
19
|
+
a traceback carrying environment values
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Nothing here inspects a string for credential-shaped text. Decision 0036
|
|
23
|
+
removed that from the whole project: a value's shape is not evidence of what
|
|
24
|
+
it is, in either direction.
|
|
25
|
+
|
|
26
|
+
One decision is worth stating because it was a decision and not an oversight.
|
|
27
|
+
The engine's normalized output carries each rollout's *final reply* — what the
|
|
28
|
+
subject actually answered. It is the participant's own material, it is often
|
|
29
|
+
the most useful thing in a failed task, and it is not hidden verifier material.
|
|
30
|
+
It still does not appear in a presentation payload in this release: a reply is
|
|
31
|
+
free text from a model, a rendering is a place a reader trusts, and the release
|
|
32
|
+
that shows replies should show them where a person asked for them (spec section
|
|
33
|
+
7.18's improvement context) rather than in the summary a phone message quotes.
|
|
34
|
+
The payload therefore has no field for one.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
from typing import Final
|
|
41
|
+
|
|
42
|
+
from pydantic import BaseModel
|
|
43
|
+
|
|
44
|
+
from techtree.errors import ValidationError
|
|
45
|
+
from techtree.presentation.models import UpliftPresentationPayload
|
|
46
|
+
from techtree.verifiers.models import NormalizedExecutionError
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"PRESENTATION_REDACTION_FAILED",
|
|
50
|
+
"ensure_no_control_or_local_path",
|
|
51
|
+
"ensure_no_hidden_task_material",
|
|
52
|
+
"sanitize_error_summary",
|
|
53
|
+
"sanitize_label",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: Stable error code. Spec section 15.
|
|
57
|
+
PRESENTATION_REDACTION_FAILED: Final = "presentation_redaction_failed"
|
|
58
|
+
|
|
59
|
+
#: Escape sequences of every shape a terminal would act on. A payload is
|
|
60
|
+
#: channel-neutral, so an escape sequence in it is either an accident or an
|
|
61
|
+
#: attempt to control someone else's terminal.
|
|
62
|
+
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-_]|\x9b[0-9;?]*[@-~]")
|
|
63
|
+
|
|
64
|
+
#: Everything a terminal treats as a command rather than as text, newline and
|
|
65
|
+
#: tab included: a rendering places its own line breaks.
|
|
66
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
67
|
+
|
|
68
|
+
_WHITESPACE_RUN = re.compile(r"\s+")
|
|
69
|
+
|
|
70
|
+
#: A rooted filesystem path with at least two segments, POSIX or Windows.
|
|
71
|
+
#: Digests, identifiers and prose do not look like this; ``~/.techtree/...``
|
|
72
|
+
#: does, and so does a temporary directory naming a person's account.
|
|
73
|
+
_ABSOLUTE_PATH = re.compile(r"(?:[A-Za-z]:\\|/)[\w.-]+[/\\][\w.\-/\\]*")
|
|
74
|
+
|
|
75
|
+
#: What a truncated label ends with. One character, and not an escape.
|
|
76
|
+
_ELLIPSIS: Final = "…"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sanitize_label(value: str, maximum: int = 120) -> str:
|
|
80
|
+
"""Return one line of plain, bounded text.
|
|
81
|
+
|
|
82
|
+
Applied to everything a person or another system supplied — a Climb title,
|
|
83
|
+
a Skill name — because those are the fields that reach a rendering without
|
|
84
|
+
having been through a protocol validator that cared what was in them.
|
|
85
|
+
Escape sequences and control characters go, whitespace is flattened, and
|
|
86
|
+
the result is truncated. The remaining words are left exactly as written.
|
|
87
|
+
"""
|
|
88
|
+
flattened = _WHITESPACE_RUN.sub(" ", _CONTROL.sub(" ", _ANSI.sub("", value)))
|
|
89
|
+
label = flattened.strip()
|
|
90
|
+
if len(label) <= maximum:
|
|
91
|
+
return label
|
|
92
|
+
return label[: maximum - 1].rstrip() + _ELLIPSIS
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sanitize_error_summary(error: NormalizedExecutionError, maximum: int = 200) -> str:
|
|
96
|
+
"""Summarize one recorded failure in one line.
|
|
97
|
+
|
|
98
|
+
The traceback is dropped rather than shortened. Its diagnostic worth is to
|
|
99
|
+
somebody reading the run directory, not to somebody reading a result.
|
|
100
|
+
"""
|
|
101
|
+
summary = f"{error.type}: {error.message}" if error.message else error.type
|
|
102
|
+
return sanitize_label(summary, maximum)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ensure_no_control_or_local_path(value: str, *, field: str) -> None:
|
|
106
|
+
"""Raise when one string would carry a terminal command or a local path.
|
|
107
|
+
|
|
108
|
+
Shared by the presentation payload and by spec section 7.18's improvement
|
|
109
|
+
context, because the two answer the same question about their free text:
|
|
110
|
+
a payload is read by a terminal and a context is read by a model, and
|
|
111
|
+
neither may be handed an escape sequence or somebody's home directory.
|
|
112
|
+
"""
|
|
113
|
+
if _ANSI.search(value) or _CONTROL.search(value):
|
|
114
|
+
raise ValidationError(
|
|
115
|
+
"a result rendering may not carry terminal control sequences",
|
|
116
|
+
code=PRESENTATION_REDACTION_FAILED,
|
|
117
|
+
details={"field": field},
|
|
118
|
+
)
|
|
119
|
+
if _ABSOLUTE_PATH.search(value):
|
|
120
|
+
raise ValidationError(
|
|
121
|
+
"a result rendering may not name an absolute local path",
|
|
122
|
+
code=PRESENTATION_REDACTION_FAILED,
|
|
123
|
+
details={"field": field},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def ensure_no_hidden_task_material(payload: UpliftPresentationPayload) -> None:
|
|
128
|
+
"""Check every string in a payload before anything renders it.
|
|
129
|
+
|
|
130
|
+
Walks the payload rather than the fields somebody remembered, so a field
|
|
131
|
+
added later is covered the day it is added.
|
|
132
|
+
"""
|
|
133
|
+
for path, value in _strings(payload, ""):
|
|
134
|
+
ensure_no_control_or_local_path(value, field=path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _strings(value: object, path: str) -> list[tuple[str, str]]:
|
|
138
|
+
"""Return every string inside a payload, with the field it came from."""
|
|
139
|
+
if isinstance(value, str):
|
|
140
|
+
return [(path, value)]
|
|
141
|
+
if isinstance(value, BaseModel):
|
|
142
|
+
found: list[tuple[str, str]] = []
|
|
143
|
+
for name in type(value).model_fields:
|
|
144
|
+
found.extend(_strings(getattr(value, name), f"{path}.{name}".lstrip(".")))
|
|
145
|
+
return found
|
|
146
|
+
if isinstance(value, dict):
|
|
147
|
+
found = []
|
|
148
|
+
for key, item in value.items():
|
|
149
|
+
found.extend(_strings(item, f"{path}[{key}]"))
|
|
150
|
+
return found
|
|
151
|
+
if isinstance(value, list | tuple):
|
|
152
|
+
found = []
|
|
153
|
+
for position, item in enumerate(value):
|
|
154
|
+
found.extend(_strings(item, f"{path}[{position}]"))
|
|
155
|
+
return found
|
|
156
|
+
return []
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Publishing one finished run to the public log. Decisions document 0038.
|
|
2
|
+
|
|
3
|
+
This is the only package in Techtree that sends anything anywhere, and it sends
|
|
4
|
+
one thing: a proof bundle a person asked it to publish, after that bundle's own
|
|
5
|
+
proof has been checked and found to hold together. Nothing here runs on its own,
|
|
6
|
+
nothing here runs during a run, and nothing here is reachable without somebody
|
|
7
|
+
answering a question first.
|
|
8
|
+
|
|
9
|
+
Four rules shape every module below.
|
|
10
|
+
|
|
11
|
+
*A result that does not verify is never offered.* The verification is the first
|
|
12
|
+
step of publishing, not a check somebody could skip, because the whole of what
|
|
13
|
+
this product is for is that a published number came with its own evidence.
|
|
14
|
+
|
|
15
|
+
*A completed run's files are final.* Publishing adds a countersigned receipt and
|
|
16
|
+
a journal of its own to the run directory. It rewrites nothing, including the
|
|
17
|
+
run's own event log, which closed when the run ended.
|
|
18
|
+
|
|
19
|
+
*The address bar carries nothing.* A submission is a request body. No proof, no
|
|
20
|
+
digest and no volunteered address is ever put in a URL or a query string.
|
|
21
|
+
|
|
22
|
+
*A volunteered address is sent and not kept.* It is optional, it defaults to no,
|
|
23
|
+
its field name says it is unverified because nobody proved control of it, and
|
|
24
|
+
nothing on this machine writes it down.
|
|
25
|
+
|
|
26
|
+
The division of labour:
|
|
27
|
+
|
|
28
|
+
``address``
|
|
29
|
+
Trimming, shape, the EIP-55 checksum, and the canonical lowercase form.
|
|
30
|
+
``keccak``
|
|
31
|
+
The one hash EIP-55 needs and no standard library provides.
|
|
32
|
+
``models``
|
|
33
|
+
What a submission is and what a receipt is.
|
|
34
|
+
``journal``
|
|
35
|
+
The append-only record of what has been published about one run.
|
|
36
|
+
``transport``
|
|
37
|
+
The single seam that opens a socket, and the only substitutable part.
|
|
38
|
+
``service``
|
|
39
|
+
The order the steps happen in, which is the product.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Checking an EVM address somebody typed. Decisions document 0038.
|
|
2
|
+
|
|
3
|
+
An address is optional, it is volunteered, and it is unverifiable. Nobody has
|
|
4
|
+
proved control of the account, and this module cannot change that — what it can
|
|
5
|
+
do is refuse the two mistakes that are cheap to catch and expensive to make.
|
|
6
|
+
|
|
7
|
+
*The shape.* ``0x`` and exactly forty hexadecimal characters, after the
|
|
8
|
+
surrounding whitespace a paste brings with it has been trimmed off. Anything
|
|
9
|
+
else is a typo or a different kind of identifier, and either way it is not what
|
|
10
|
+
was asked for.
|
|
11
|
+
|
|
12
|
+
*The checksum, where there is one.* EIP-55 hides a checksum in the letter case
|
|
13
|
+
of an address: the hexadecimal is case-insensitive, so the case of each letter
|
|
14
|
+
is free to carry four bits of a Keccak-256 digest of the address itself. An
|
|
15
|
+
address written in one case carries no checksum and there is nothing to check.
|
|
16
|
+
An address written in mixed case does carry one, and checking it costs a hash
|
|
17
|
+
and catches a single mistyped character — which is the difference between a
|
|
18
|
+
recognisable contributor and an address that belongs to nobody and always will,
|
|
19
|
+
because there is no undo and no support desk.
|
|
20
|
+
|
|
21
|
+
What travels is the lowercase spelling. It is the same address, it is the form
|
|
22
|
+
the checksum is computed from, and one canonical form is what makes an address
|
|
23
|
+
usable as a key.
|
|
24
|
+
|
|
25
|
+
Nothing here stores anything. The caller sends the canonical form and keeps no
|
|
26
|
+
copy: an address is a detail somebody volunteered about themselves, not
|
|
27
|
+
evidence, and it belongs in no journal, no proof bundle and no log.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from typing import Final
|
|
34
|
+
|
|
35
|
+
from techtree.errors import ValidationError
|
|
36
|
+
from techtree.publication.keccak import keccak256
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"CONTRIBUTOR_ADDRESS_INVALID",
|
|
40
|
+
"SKILL_GITHUB_URL_INVALID",
|
|
41
|
+
"canonical_contributor_address",
|
|
42
|
+
"canonical_skill_github_url",
|
|
43
|
+
"eip55_checksum",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
#: Stable error code for "that is not an address anybody could have meant".
|
|
47
|
+
CONTRIBUTOR_ADDRESS_INVALID: Final = "contributor_address_invalid"
|
|
48
|
+
|
|
49
|
+
#: Stable error code for an optional public Skill repository URL that is not a
|
|
50
|
+
#: canonical GitHub repository address.
|
|
51
|
+
SKILL_GITHUB_URL_INVALID: Final = "skill_github_url_invalid"
|
|
52
|
+
|
|
53
|
+
_ADDRESS_PATTERN: Final = re.compile(r"\A0x[0-9a-fA-F]{40}\Z")
|
|
54
|
+
|
|
55
|
+
# GitHub user and organization names are at most 39 characters and contain
|
|
56
|
+
# alphanumerics or dashes (never at either edge). Repository names are at most
|
|
57
|
+
# 100 characters and may also contain dots and underscores. The URL is
|
|
58
|
+
# deliberately narrower than what a browser might accept: one spelling, no
|
|
59
|
+
# redirects, no .git suffix, and no extra path or URL components.
|
|
60
|
+
_GITHUB_OWNER_PATTERN: Final = re.compile(
|
|
61
|
+
r"\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\Z"
|
|
62
|
+
)
|
|
63
|
+
_GITHUB_REPOSITORY_PATTERN: Final = re.compile(
|
|
64
|
+
r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: A digest nibble of eight or more uppercases the hexadecimal character it
|
|
68
|
+
#: sits beside. EIP-55, in the one sentence it amounts to.
|
|
69
|
+
_UPPERCASE_THRESHOLD: Final = 8
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def canonical_contributor_address(typed: str) -> str:
|
|
73
|
+
"""Return the lowercase form of a typed address, or refuse it.
|
|
74
|
+
|
|
75
|
+
Refusals carry the reason rather than a generic "invalid": somebody who has
|
|
76
|
+
pasted the wrong forty characters and somebody who has mistyped one of the
|
|
77
|
+
right forty need to be told different things.
|
|
78
|
+
"""
|
|
79
|
+
trimmed = typed.strip()
|
|
80
|
+
if not _ADDRESS_PATTERN.match(trimmed):
|
|
81
|
+
raise ValidationError(
|
|
82
|
+
"an address is 0x followed by exactly 40 hexadecimal characters, "
|
|
83
|
+
f"and this one is not: {_shape_of(trimmed)}",
|
|
84
|
+
code=CONTRIBUTOR_ADDRESS_INVALID,
|
|
85
|
+
details={"reason": "shape"},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
body = trimmed[2:]
|
|
89
|
+
if body == body.lower() or body == body.upper():
|
|
90
|
+
# One case throughout carries no checksum, so there is nothing here to
|
|
91
|
+
# be right or wrong about.
|
|
92
|
+
return trimmed.lower()
|
|
93
|
+
|
|
94
|
+
if trimmed != eip55_checksum(trimmed):
|
|
95
|
+
raise ValidationError(
|
|
96
|
+
"this address is written in mixed case, which carries a checksum, "
|
|
97
|
+
"and the checksum does not match: one character of it is wrong. "
|
|
98
|
+
"An address nobody controls cannot be undone, so it is refused "
|
|
99
|
+
"rather than sent",
|
|
100
|
+
code=CONTRIBUTOR_ADDRESS_INVALID,
|
|
101
|
+
details={"reason": "checksum"},
|
|
102
|
+
)
|
|
103
|
+
return trimmed.lower()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def canonical_skill_github_url(typed: str) -> str:
|
|
107
|
+
"""Return a canonical GitHub repository URL, or refuse it.
|
|
108
|
+
|
|
109
|
+
This is descriptive metadata, not proof that a repository exists or that
|
|
110
|
+
its publisher controls it. Keeping the accepted form strict prevents a
|
|
111
|
+
public link from silently becoming a redirect, a different host, or a
|
|
112
|
+
clone URL with credentials and query data attached.
|
|
113
|
+
"""
|
|
114
|
+
from urllib.parse import urlsplit
|
|
115
|
+
|
|
116
|
+
if typed != typed.strip():
|
|
117
|
+
raise ValidationError(
|
|
118
|
+
"a Skill GitHub URL has no surrounding whitespace",
|
|
119
|
+
code=SKILL_GITHUB_URL_INVALID,
|
|
120
|
+
details={"reason": "whitespace"},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
parts = urlsplit(typed)
|
|
125
|
+
# Accessing ``port`` validates a malformed explicit port and may raise
|
|
126
|
+
# ValueError even when the rest of the URL can be split.
|
|
127
|
+
port = parts.port
|
|
128
|
+
except ValueError:
|
|
129
|
+
parts = None
|
|
130
|
+
port = None
|
|
131
|
+
|
|
132
|
+
path_parts = parts.path.split("/") if parts is not None else []
|
|
133
|
+
if (
|
|
134
|
+
parts is None
|
|
135
|
+
or parts.scheme != "https"
|
|
136
|
+
or parts.netloc != "github.com"
|
|
137
|
+
or parts.username is not None
|
|
138
|
+
or parts.password is not None
|
|
139
|
+
or port is not None
|
|
140
|
+
or parts.query
|
|
141
|
+
or parts.fragment
|
|
142
|
+
or "?" in typed
|
|
143
|
+
or "#" in typed
|
|
144
|
+
or len(path_parts) != 3
|
|
145
|
+
or path_parts[0]
|
|
146
|
+
or not _GITHUB_OWNER_PATTERN.fullmatch(path_parts[1])
|
|
147
|
+
or not _GITHUB_REPOSITORY_PATTERN.fullmatch(path_parts[2])
|
|
148
|
+
or path_parts[2].lower().endswith(".git")
|
|
149
|
+
):
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
"a Skill GitHub URL must be exactly https://github.com/owner/repo, "
|
|
152
|
+
"with no .git suffix, query, fragment, credentials, or extra path",
|
|
153
|
+
code=SKILL_GITHUB_URL_INVALID,
|
|
154
|
+
details={"reason": "shape"},
|
|
155
|
+
)
|
|
156
|
+
return typed
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def eip55_checksum(address: str) -> str:
|
|
160
|
+
"""Return the mixed-case EIP-55 spelling of a well-formed address."""
|
|
161
|
+
body = address[2:].lower()
|
|
162
|
+
digest = keccak256(body.encode("ascii")).hex()
|
|
163
|
+
return "0x" + "".join(
|
|
164
|
+
character.upper()
|
|
165
|
+
if int(digest[position], 16) >= _UPPERCASE_THRESHOLD
|
|
166
|
+
else character
|
|
167
|
+
for position, character in enumerate(body)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _shape_of(typed: str) -> str:
|
|
172
|
+
"""Describe what was typed without repeating it back.
|
|
173
|
+
|
|
174
|
+
A refusal that echoed the string would put it in the envelope, in the
|
|
175
|
+
terminal's scrollback, and in whatever a host agent keeps — which is
|
|
176
|
+
exactly where a volunteered address must not end up. The length and whether
|
|
177
|
+
the prefix was there are enough for somebody to see what they did.
|
|
178
|
+
"""
|
|
179
|
+
prefix = "starts with 0x" if typed[:2].lower() == "0x" else "no 0x prefix"
|
|
180
|
+
return f"{prefix}, {len(typed)} characters"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Where this build publishes, read off the release it belongs to.
|
|
2
|
+
|
|
3
|
+
Decisions document 0038, founder ruling of 2026-08-27.
|
|
4
|
+
|
|
5
|
+
One function, in one place, because two commands need the same answer and an
|
|
6
|
+
answer that differed between them would be a build that publishes to one address
|
|
7
|
+
and withdraws at another.
|
|
8
|
+
|
|
9
|
+
It is read from the ReleaseCore the wheel carries rather than from a setting,
|
|
10
|
+
which is what makes a stable release able to publish the moment it is installed.
|
|
11
|
+
A build that ships no ReleaseCore was not produced by the release generator, and
|
|
12
|
+
it says so: publishing needs to know not only where the run log is but which
|
|
13
|
+
key's countersignature would be worth anything, and neither is a thing to guess.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from techtree.release.document import packaged_release_core_bytes, parse_release_core
|
|
19
|
+
from techtree.release.models import PublicationCoordinates
|
|
20
|
+
|
|
21
|
+
__all__ = ["packaged_publication_coordinates"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def packaged_publication_coordinates() -> PublicationCoordinates:
|
|
25
|
+
"""Return the publication coordinates this build's release pins."""
|
|
26
|
+
return parse_release_core(packaged_release_core_bytes()).publication
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""A completed run's publication journal. Decisions document 0038.
|
|
2
|
+
|
|
3
|
+
A run's own event log closes when the run ends. That is not an inconvenience to
|
|
4
|
+
work around: ``events.jsonl`` is the record of an execution, the state machine
|
|
5
|
+
is what makes it derivable, and a file that a later command appends to is a file
|
|
6
|
+
whose earlier bytes were not final. Publishing happens after all of that, is not
|
|
7
|
+
part of the execution, and must not touch a byte the run wrote.
|
|
8
|
+
|
|
9
|
+
So it is recorded in a journal of its own, in the same run directory, created
|
|
10
|
+
the first time somebody publishes and appended to after that. Every property the
|
|
11
|
+
run's own log has, this one has for the same reasons: canonical JSON one line at
|
|
12
|
+
a time, sequence numbers that start at zero and increase by exactly one, and an
|
|
13
|
+
``O_APPEND`` write followed by an ``fsync``, so a crash can lose a trailing line
|
|
14
|
+
but cannot interleave two.
|
|
15
|
+
|
|
16
|
+
What it does not carry is a contributor address. An address is a detail somebody
|
|
17
|
+
volunteered about themselves rather than evidence about a run; it is sent and
|
|
18
|
+
not kept, so there is nothing here to read it back out of.
|
|
19
|
+
|
|
20
|
+
The publication status of a run is derived from this file and from nowhere else.
|
|
21
|
+
The signed report inside the proof bundle says ``not_requested``, permanently
|
|
22
|
+
and correctly: it was not requested when the report was written, and rewriting a
|
|
23
|
+
signed document to say otherwise would break the proof it is part of.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Final, Literal, Self
|
|
31
|
+
|
|
32
|
+
from pydantic import Field, model_validator
|
|
33
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
34
|
+
|
|
35
|
+
from techtree.canonical import canonical_json_bytes
|
|
36
|
+
from techtree.errors import ValidationError
|
|
37
|
+
from techtree.fs import fsync_directory
|
|
38
|
+
from techtree.models.base import Digest, NonEmptyString, ProtocolModel, UtcDateTime
|
|
39
|
+
from techtree.models.uplift_report import PublicationStatus
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"ATTEMPT_STATUSES",
|
|
43
|
+
"PUBLICATION_JOURNAL_CORRUPT",
|
|
44
|
+
"PUBLICATION_JOURNAL_FILENAME",
|
|
45
|
+
"PublicationJournal",
|
|
46
|
+
"PublicationJournalEntry",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
#: The file, beside the run's own records and never inside its proof.
|
|
50
|
+
PUBLICATION_JOURNAL_FILENAME: Final = "publication.jsonl"
|
|
51
|
+
|
|
52
|
+
#: Stable error code for a journal whose lines do not form a history.
|
|
53
|
+
PUBLICATION_JOURNAL_CORRUPT: Final = "publication_journal_corrupt"
|
|
54
|
+
|
|
55
|
+
_LINE_SEPARATOR: Final = b"\n"
|
|
56
|
+
#: Owner read and write only, matching every other file Techtree writes.
|
|
57
|
+
_FILE_MODE: Final = 0o600
|
|
58
|
+
|
|
59
|
+
#: The three statuses a publication attempt moves through. The other two
|
|
60
|
+
#: members of :class:`~techtree.models.uplift_report.PublicationStatus` describe
|
|
61
|
+
#: a report rather than an attempt: ``not_requested`` is what a run with no
|
|
62
|
+
#: journal at all is, and ``blocked`` is a data policy's answer, decided before
|
|
63
|
+
#: anything is attempted.
|
|
64
|
+
ATTEMPT_STATUSES: Final[frozenset[PublicationStatus]] = frozenset(
|
|
65
|
+
{
|
|
66
|
+
PublicationStatus.PENDING,
|
|
67
|
+
PublicationStatus.PUBLISHED,
|
|
68
|
+
PublicationStatus.FAILED,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PublicationJournalEntry(ProtocolModel):
|
|
74
|
+
"""One thing that happened when somebody published this run."""
|
|
75
|
+
|
|
76
|
+
schema_version: Literal["techtree.publication-journal.v1alpha1"]
|
|
77
|
+
sequence: int = Field(ge=0)
|
|
78
|
+
at: UtcDateTime
|
|
79
|
+
run_id: NonEmptyString
|
|
80
|
+
status: PublicationStatus
|
|
81
|
+
#: The bundle this attempt was about, so a journal with several attempts in
|
|
82
|
+
#: it says which proof each one carried.
|
|
83
|
+
bundle_digest: Digest
|
|
84
|
+
#: Where it was sent. An origin and a path; a submission never travels in a
|
|
85
|
+
#: query string, so nothing here can contain one.
|
|
86
|
+
endpoint: NonEmptyString
|
|
87
|
+
file_count: int = Field(gt=0)
|
|
88
|
+
byte_count: int = Field(gt=0)
|
|
89
|
+
#: Set by a published attempt: where the entry now lives, and the log
|
|
90
|
+
#: position the network gave it.
|
|
91
|
+
entry_url: NonEmptyString | None = None
|
|
92
|
+
log_sequence: int | None = Field(default=None, ge=0)
|
|
93
|
+
#: Set by a failed attempt: the stable code of what went wrong. The message
|
|
94
|
+
#: belongs to the envelope the command already returned; a code is what a
|
|
95
|
+
#: later reader can act on.
|
|
96
|
+
error_code: NonEmptyString | None = None
|
|
97
|
+
|
|
98
|
+
@model_validator(mode="after")
|
|
99
|
+
def _check_the_outcome_carries_what_it_means(self) -> Self:
|
|
100
|
+
"""Reject an entry that reports one outcome and describes another."""
|
|
101
|
+
if self.status not in ATTEMPT_STATUSES:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"a journal entry records an attempt, which is pending, "
|
|
104
|
+
"published or failed"
|
|
105
|
+
)
|
|
106
|
+
published = self.status is PublicationStatus.PUBLISHED
|
|
107
|
+
landed = self.entry_url is not None or self.log_sequence is not None
|
|
108
|
+
if published and (self.entry_url is None or self.log_sequence is None):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"a published entry records where it landed and its log position"
|
|
111
|
+
)
|
|
112
|
+
if not published and landed:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"only a published entry has somewhere it landed and a log position"
|
|
115
|
+
)
|
|
116
|
+
failed = self.status is PublicationStatus.FAILED
|
|
117
|
+
if failed and self.error_code is None:
|
|
118
|
+
raise ValueError("a failed entry records why it failed")
|
|
119
|
+
if not failed and self.error_code is not None:
|
|
120
|
+
raise ValueError("only a failed entry carries a failure code")
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PublicationJournal:
|
|
125
|
+
"""The append-only record of what has been published about one run."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, run_root: Path) -> None:
|
|
128
|
+
self._path = run_root / PUBLICATION_JOURNAL_FILENAME
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def path(self) -> Path:
|
|
132
|
+
"""Return the journal file, whether or not it exists yet."""
|
|
133
|
+
return self._path
|
|
134
|
+
|
|
135
|
+
def entries(self) -> list[PublicationJournalEntry]:
|
|
136
|
+
"""Return every entry, refusing a history with a hole in it.
|
|
137
|
+
|
|
138
|
+
A sequence that skips is a journal that lost a line, and a status
|
|
139
|
+
derived from it would be quietly wrong. That check also catches a
|
|
140
|
+
half-written trailing line, which fails to parse before its sequence is
|
|
141
|
+
ever considered.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
raw = self._path.read_bytes()
|
|
145
|
+
except FileNotFoundError:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
entries: list[PublicationJournalEntry] = []
|
|
149
|
+
for position, line in enumerate(raw.splitlines()):
|
|
150
|
+
if not line.strip():
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
entry = PublicationJournalEntry.model_validate_json(line)
|
|
154
|
+
except PydanticValidationError as error:
|
|
155
|
+
raise ValidationError(
|
|
156
|
+
f"line {position + 1} of this run's publication journal "
|
|
157
|
+
"cannot be read",
|
|
158
|
+
code=PUBLICATION_JOURNAL_CORRUPT,
|
|
159
|
+
details={"path": str(self._path), "line": position + 1},
|
|
160
|
+
) from error
|
|
161
|
+
if entry.sequence != len(entries):
|
|
162
|
+
raise ValidationError(
|
|
163
|
+
f"this run's publication journal jumps from {len(entries) - 1} "
|
|
164
|
+
f"to {entry.sequence}, so a line is missing",
|
|
165
|
+
code=PUBLICATION_JOURNAL_CORRUPT,
|
|
166
|
+
details={"path": str(self._path), "sequence": entry.sequence},
|
|
167
|
+
)
|
|
168
|
+
entries.append(entry)
|
|
169
|
+
return entries
|
|
170
|
+
|
|
171
|
+
def status(self) -> PublicationStatus:
|
|
172
|
+
"""Return where this run stands, derived from the journal alone.
|
|
173
|
+
|
|
174
|
+
A run nobody has published has no journal, and that is
|
|
175
|
+
``not_requested`` — the same thing its report has said since it was
|
|
176
|
+
written.
|
|
177
|
+
"""
|
|
178
|
+
entries = self.entries()
|
|
179
|
+
if not entries:
|
|
180
|
+
return PublicationStatus.NOT_REQUESTED
|
|
181
|
+
return entries[-1].status
|
|
182
|
+
|
|
183
|
+
def published(self) -> PublicationJournalEntry | None:
|
|
184
|
+
"""Return the entry that published this run, if one did."""
|
|
185
|
+
for entry in reversed(self.entries()):
|
|
186
|
+
if entry.status is PublicationStatus.PUBLISHED:
|
|
187
|
+
return entry
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def next_sequence(self) -> int:
|
|
191
|
+
"""Return the sequence the next appended entry takes."""
|
|
192
|
+
return len(self.entries())
|
|
193
|
+
|
|
194
|
+
def append(self, entry: PublicationJournalEntry) -> None:
|
|
195
|
+
"""Append one entry durably.
|
|
196
|
+
|
|
197
|
+
A single ``O_APPEND`` write of one whole line, then an ``fsync``. The
|
|
198
|
+
entry is serialized before the file is opened, so an entry that cannot
|
|
199
|
+
be written down does not leave a partial line behind it.
|
|
200
|
+
"""
|
|
201
|
+
line = canonical_json_bytes(entry) + _LINE_SEPARATOR
|
|
202
|
+
descriptor = os.open(
|
|
203
|
+
self._path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, _FILE_MODE
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
written = 0
|
|
207
|
+
while written < len(line):
|
|
208
|
+
written += os.write(descriptor, line[written:])
|
|
209
|
+
os.fsync(descriptor)
|
|
210
|
+
finally:
|
|
211
|
+
os.close(descriptor)
|
|
212
|
+
fsync_directory(self._path.parent)
|