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/runs/machine.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""The run state machine and its projection. Spec sections 18.2, 7.6.
|
|
2
|
+
|
|
3
|
+
Everything in this module is a pure function of the event log. Nothing here
|
|
4
|
+
touches the filesystem, the clock, or the process table, which is what makes
|
|
5
|
+
"the state rebuilds from the events alone" a property that can be tested rather
|
|
6
|
+
than a claim.
|
|
7
|
+
|
|
8
|
+
The normal path is a straight line::
|
|
9
|
+
|
|
10
|
+
created → validating_taskset → running_baseline → running_candidate
|
|
11
|
+
→ building_receipts → verifying_comparison → building_report → completed
|
|
12
|
+
|
|
13
|
+
``running_variants`` is the one branch off it: a run whose two variants execute
|
|
14
|
+
side by side leaves ``validating_taskset`` for it instead of for
|
|
15
|
+
``running_baseline``, and rejoins the line at ``building_receipts``. The
|
|
16
|
+
sequential pair stays for the fake executor.
|
|
17
|
+
|
|
18
|
+
Two escapes leave it. Any phase that is still working may fail, and any phase
|
|
19
|
+
that is still working may have cancellation requested of it; a run that has been
|
|
20
|
+
asked to stop then reaches ``cancelled`` when the worker winds down, or
|
|
21
|
+
``failed`` if it breaks while doing so. ``completed``, ``failed``, and
|
|
22
|
+
``cancelled`` have no outgoing edges at all: once a run has ended, its log is
|
|
23
|
+
closed.
|
|
24
|
+
|
|
25
|
+
No phase has an edge to itself. A run does still have things to report while it
|
|
26
|
+
stays where it is — the worker's process id, its progress through a phase, the
|
|
27
|
+
digest of the report it just wrote, and how each variant of a concurrent
|
|
28
|
+
comparison is getting on — but those are a closed set of events, named
|
|
29
|
+
in :data:`techtree.runs.events.SAME_PHASE_EVENT_KINDS`, and
|
|
30
|
+
:func:`validate_same_phase_event` rather than the transition table is what
|
|
31
|
+
admits them. Recording them as events is what keeps the projection derivable
|
|
32
|
+
from the log; recording them anywhere else would make ``state.json`` hold facts
|
|
33
|
+
the log cannot rebuild.
|
|
34
|
+
|
|
35
|
+
One fact deliberately does not come from events: ``heartbeat_at``. A liveness
|
|
36
|
+
signal refreshed every couple of seconds would bury a run's actual history under
|
|
37
|
+
thousands of lines that mean nothing after the fact, so the heartbeat is a
|
|
38
|
+
single overwritten file and :func:`reduce_events` always leaves the field unset.
|
|
39
|
+
:class:`techtree.runs.store.RunStore` merges it in.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from typing import Final
|
|
45
|
+
|
|
46
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
47
|
+
|
|
48
|
+
from techtree.canonical import validate_digest
|
|
49
|
+
from techtree.errors import RunError, ValidationError
|
|
50
|
+
from techtree.models.base import Digest, JsonValue
|
|
51
|
+
from techtree.models.cli import CliError
|
|
52
|
+
from techtree.models.run import (
|
|
53
|
+
RunEvent,
|
|
54
|
+
RunPhase,
|
|
55
|
+
RunProgress,
|
|
56
|
+
RunState,
|
|
57
|
+
VariantProgress,
|
|
58
|
+
)
|
|
59
|
+
from techtree.runs.events import (
|
|
60
|
+
CANCEL_REQUESTED,
|
|
61
|
+
DETAIL_COMPLETED,
|
|
62
|
+
DETAIL_CURRENT,
|
|
63
|
+
DETAIL_ERROR,
|
|
64
|
+
DETAIL_ERRORED,
|
|
65
|
+
DETAIL_LABEL,
|
|
66
|
+
DETAIL_RESULT_DIGEST,
|
|
67
|
+
DETAIL_RUNNING,
|
|
68
|
+
DETAIL_STATE,
|
|
69
|
+
DETAIL_TOTAL,
|
|
70
|
+
DETAIL_VARIANT,
|
|
71
|
+
DETAIL_WORKER_PID,
|
|
72
|
+
PROGRESS_UPDATED,
|
|
73
|
+
RESULT_WRITTEN,
|
|
74
|
+
RUN_COMPLETED,
|
|
75
|
+
RUN_CREATED,
|
|
76
|
+
RUN_FAILED,
|
|
77
|
+
SAME_PHASE_EVENT_KINDS,
|
|
78
|
+
VARIANT_EVENT_KINDS,
|
|
79
|
+
WORKER_STARTED,
|
|
80
|
+
validate_event_kind,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"ALLOWED_TRANSITIONS",
|
|
85
|
+
"NORMAL_PATH",
|
|
86
|
+
"apply_event",
|
|
87
|
+
"can_cancel",
|
|
88
|
+
"initial_state",
|
|
89
|
+
"is_terminal",
|
|
90
|
+
"phase_progress_allowed",
|
|
91
|
+
"reduce_events",
|
|
92
|
+
"validate_same_phase_event",
|
|
93
|
+
"validate_transition",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Transitions
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
#: The successful sequence, in order. Each phase's successor is the next entry.
|
|
102
|
+
NORMAL_PATH: Final[tuple[RunPhase, ...]] = (
|
|
103
|
+
RunPhase.CREATED,
|
|
104
|
+
RunPhase.VALIDATING_TASKSET,
|
|
105
|
+
RunPhase.RUNNING_BASELINE,
|
|
106
|
+
RunPhase.RUNNING_CANDIDATE,
|
|
107
|
+
RunPhase.BUILDING_RECEIPTS,
|
|
108
|
+
RunPhase.VERIFYING_COMPARISON,
|
|
109
|
+
RunPhase.BUILDING_REPORT,
|
|
110
|
+
RunPhase.COMPLETED,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_TERMINAL_PHASES: Final[frozenset[RunPhase]] = frozenset(
|
|
114
|
+
{RunPhase.COMPLETED, RunPhase.FAILED, RunPhase.CANCELLED}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
#: The concurrent alternative to ``running_baseline → running_candidate``, as
|
|
118
|
+
#: the phase it leaves, the phase it is, and the phase it rejoins at. Spec
|
|
119
|
+
#: section 3.3 adds it beside the sequential pair rather than in place of it, so
|
|
120
|
+
#: ``validating_taskset`` has two successors and both routes meet again at
|
|
121
|
+
#: ``building_receipts``.
|
|
122
|
+
_VARIANT_BRANCH: Final[tuple[RunPhase, RunPhase, RunPhase]] = (
|
|
123
|
+
RunPhase.VALIDATING_TASKSET,
|
|
124
|
+
RunPhase.RUNNING_VARIANTS,
|
|
125
|
+
RunPhase.BUILDING_RECEIPTS,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_allowed_transitions() -> dict[RunPhase, frozenset[RunPhase]]:
|
|
130
|
+
"""Derive the transition table from the normal path and the two escapes."""
|
|
131
|
+
allowed: dict[RunPhase, frozenset[RunPhase]] = {}
|
|
132
|
+
|
|
133
|
+
working = [phase for phase in NORMAL_PATH if phase not in _TERMINAL_PHASES]
|
|
134
|
+
for position, phase in enumerate(working):
|
|
135
|
+
allowed[phase] = frozenset(
|
|
136
|
+
{
|
|
137
|
+
NORMAL_PATH[position + 1],
|
|
138
|
+
RunPhase.FAILED,
|
|
139
|
+
RunPhase.CANCEL_REQUESTED,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
entered_from, branch, rejoins_at = _VARIANT_BRANCH
|
|
144
|
+
allowed[entered_from] = allowed[entered_from] | {branch}
|
|
145
|
+
allowed[branch] = frozenset(
|
|
146
|
+
{rejoins_at, RunPhase.FAILED, RunPhase.CANCEL_REQUESTED}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
allowed[RunPhase.CANCEL_REQUESTED] = frozenset(
|
|
150
|
+
{
|
|
151
|
+
RunPhase.CANCELLED,
|
|
152
|
+
RunPhase.FAILED,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
for phase in _TERMINAL_PHASES:
|
|
157
|
+
allowed[phase] = frozenset()
|
|
158
|
+
|
|
159
|
+
return allowed
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
#: Every phase a run may move to from each phase it can be in. A phase mapped to
|
|
163
|
+
#: the empty set is terminal. No phase maps to itself: an event that leaves the
|
|
164
|
+
#: run where it is passes :func:`validate_same_phase_event` instead.
|
|
165
|
+
ALLOWED_TRANSITIONS: Final[dict[RunPhase, frozenset[RunPhase]]] = (
|
|
166
|
+
_build_allowed_transitions()
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def validate_transition(current: RunPhase, target: RunPhase) -> None:
|
|
171
|
+
"""Raise on invalid transition."""
|
|
172
|
+
if target in ALLOWED_TRANSITIONS[current]:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
if is_terminal(current):
|
|
176
|
+
raise RunError(
|
|
177
|
+
f"run has already ended in {current.value}; it records no further "
|
|
178
|
+
f"events, so it cannot move to {target.value}",
|
|
179
|
+
code="run_transition_invalid",
|
|
180
|
+
details={"phase": current.value, "target_phase": target.value},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if current is target:
|
|
184
|
+
raise RunError(
|
|
185
|
+
f"a run in {current.value} does not move to itself; only "
|
|
186
|
+
f"{_named(SAME_PHASE_EVENT_KINDS)} report without leaving a phase",
|
|
187
|
+
code="run_transition_invalid",
|
|
188
|
+
details={"phase": current.value, "target_phase": target.value},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
allowed: list[JsonValue] = [
|
|
192
|
+
phase.value for phase in sorted(ALLOWED_TRANSITIONS[current])
|
|
193
|
+
]
|
|
194
|
+
raise RunError(
|
|
195
|
+
f"a run in {current.value} cannot move to {target.value}",
|
|
196
|
+
code="run_transition_invalid",
|
|
197
|
+
details={
|
|
198
|
+
"phase": current.value,
|
|
199
|
+
"target_phase": target.value,
|
|
200
|
+
"allowed": allowed,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def validate_same_phase_event(state: RunState, event: RunEvent) -> None:
|
|
206
|
+
"""Raise unless this event may leave the run in the phase it is in."""
|
|
207
|
+
if is_terminal(state.phase):
|
|
208
|
+
raise RunError(
|
|
209
|
+
f"run has already ended in {state.phase.value}; it records no "
|
|
210
|
+
"further events",
|
|
211
|
+
code="run_transition_invalid",
|
|
212
|
+
details={"run_id": state.run_id, "phase": state.phase.value},
|
|
213
|
+
)
|
|
214
|
+
if event.kind not in SAME_PHASE_EVENT_KINDS:
|
|
215
|
+
raise RunError(
|
|
216
|
+
f"a run records nothing without leaving {state.phase.value} except "
|
|
217
|
+
f"{_named(SAME_PHASE_EVENT_KINDS)}",
|
|
218
|
+
code="run_transition_invalid",
|
|
219
|
+
details={
|
|
220
|
+
"run_id": state.run_id,
|
|
221
|
+
"phase": state.phase.value,
|
|
222
|
+
"kind": event.kind,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
if event.kind == PROGRESS_UPDATED and not phase_progress_allowed(state.phase):
|
|
226
|
+
raise RunError(
|
|
227
|
+
f"a run in {state.phase.value} has no progress to report",
|
|
228
|
+
code="run_transition_invalid",
|
|
229
|
+
details={"run_id": state.run_id, "phase": state.phase.value},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def is_terminal(phase: RunPhase) -> bool:
|
|
234
|
+
"""Return terminal state."""
|
|
235
|
+
return not ALLOWED_TRANSITIONS[phase]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def can_cancel(phase: RunPhase) -> bool:
|
|
239
|
+
"""Return cancellation eligibility.
|
|
240
|
+
|
|
241
|
+
A run that has ended cannot be stopped, and a run that has already been
|
|
242
|
+
asked to stop is not asked twice — the second request would change nothing
|
|
243
|
+
and the caller deserves to be told so rather than given a second receipt.
|
|
244
|
+
"""
|
|
245
|
+
return RunPhase.CANCEL_REQUESTED in ALLOWED_TRANSITIONS[phase]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def phase_progress_allowed(phase: RunPhase) -> bool:
|
|
249
|
+
"""Return whether a phase measures work a run can be part-way through.
|
|
250
|
+
|
|
251
|
+
A run that has ended is not part-way through anything, and ``created`` is a
|
|
252
|
+
run that exists rather than a run doing something.
|
|
253
|
+
"""
|
|
254
|
+
return not is_terminal(phase) and phase is not RunPhase.CREATED
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _named(kinds: frozenset[str]) -> str:
|
|
258
|
+
"""Return a list of event kinds as English, for a message a human reads."""
|
|
259
|
+
return ", ".join(sorted(kinds))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Projection
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def reduce_events(events: list[RunEvent]) -> RunState:
|
|
268
|
+
"""Project complete state."""
|
|
269
|
+
if not events:
|
|
270
|
+
raise ValidationError(
|
|
271
|
+
"a run event log cannot be empty; every run opens with its created event",
|
|
272
|
+
code="run_event_log_corrupt",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
state = initial_state(events[0])
|
|
276
|
+
for event in events[1:]:
|
|
277
|
+
state = apply_event(state, event)
|
|
278
|
+
return state
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def initial_state(created_event: RunEvent) -> RunState:
|
|
282
|
+
"""Project the event every run opens with."""
|
|
283
|
+
if created_event.kind != RUN_CREATED:
|
|
284
|
+
raise ValidationError(
|
|
285
|
+
f"a run event log opens with {RUN_CREATED}, not {created_event.kind}",
|
|
286
|
+
code="run_event_kind_invalid",
|
|
287
|
+
details={"run_id": created_event.run_id, "kind": created_event.kind},
|
|
288
|
+
)
|
|
289
|
+
validate_event_kind(created_event)
|
|
290
|
+
return _project(_opening_state(created_event), created_event)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def apply_event(state: RunState, event: RunEvent) -> RunState:
|
|
294
|
+
"""Apply one event."""
|
|
295
|
+
if event.run_id != state.run_id:
|
|
296
|
+
raise ValidationError(
|
|
297
|
+
f"run event belongs to {event.run_id}, not to {state.run_id}",
|
|
298
|
+
code="run_event_log_corrupt",
|
|
299
|
+
details={"run_id": state.run_id, "event_run_id": event.run_id},
|
|
300
|
+
)
|
|
301
|
+
if event.sequence != state.sequence + 1:
|
|
302
|
+
raise ValidationError(
|
|
303
|
+
f"run event {event.sequence} does not follow {state.sequence}",
|
|
304
|
+
code="run_event_sequence_invalid",
|
|
305
|
+
details={
|
|
306
|
+
"run_id": state.run_id,
|
|
307
|
+
"sequence": event.sequence,
|
|
308
|
+
"expected_sequence": state.sequence + 1,
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
if event.previous_phase is not state.phase:
|
|
312
|
+
recorded = None if event.previous_phase is None else event.previous_phase.value
|
|
313
|
+
raise ValidationError(
|
|
314
|
+
f"run event claims to leave {recorded}, but the run is in "
|
|
315
|
+
f"{state.phase.value}",
|
|
316
|
+
code="run_event_log_corrupt",
|
|
317
|
+
details={
|
|
318
|
+
"run_id": state.run_id,
|
|
319
|
+
"phase": state.phase.value,
|
|
320
|
+
"event_previous_phase": recorded,
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
validate_event_kind(event)
|
|
325
|
+
if event.phase is state.phase:
|
|
326
|
+
validate_same_phase_event(state, event)
|
|
327
|
+
else:
|
|
328
|
+
validate_transition(state.phase, event.phase)
|
|
329
|
+
return _project(state, event)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _project(previous: RunState, event: RunEvent) -> RunState:
|
|
333
|
+
"""Return the state that results from applying one validated event.
|
|
334
|
+
|
|
335
|
+
Which fields an event touches follows from its kind, which is why the kind
|
|
336
|
+
is a closed set: a reader never has to guess whether a detail was meant to
|
|
337
|
+
be interpreted.
|
|
338
|
+
"""
|
|
339
|
+
worker_pid = _worker_pid(event) if event.kind == WORKER_STARTED else None
|
|
340
|
+
progress = _progress(event) if event.kind == PROGRESS_UPDATED else None
|
|
341
|
+
carries_result = event.kind in (RESULT_WRITTEN, RUN_COMPLETED)
|
|
342
|
+
entered_new_phase = previous.phase is not event.phase
|
|
343
|
+
|
|
344
|
+
# Variant progress belongs to the phase that reported it, exactly as
|
|
345
|
+
# ``progress`` does, so entering a new phase discards it. Each variant keeps
|
|
346
|
+
# its own entry, replaced whole by the most recent event that named it.
|
|
347
|
+
variant_progress = {} if entered_new_phase else dict(previous.variant_progress)
|
|
348
|
+
if event.kind in VARIANT_EVENT_KINDS:
|
|
349
|
+
reported = _variant_progress(event)
|
|
350
|
+
variant_progress[reported.variant] = reported
|
|
351
|
+
|
|
352
|
+
return RunState(
|
|
353
|
+
run_id=event.run_id,
|
|
354
|
+
phase=event.phase,
|
|
355
|
+
sequence=event.sequence,
|
|
356
|
+
updated_at=event.timestamp,
|
|
357
|
+
worker_pid=worker_pid if worker_pid is not None else previous.worker_pid,
|
|
358
|
+
# The worker's start time is the moment it announced its process id.
|
|
359
|
+
worker_started_at=(
|
|
360
|
+
event.timestamp if worker_pid is not None else previous.worker_started_at
|
|
361
|
+
),
|
|
362
|
+
# Liveness is not an event-sourced fact. See the module docstring.
|
|
363
|
+
heartbeat_at=None,
|
|
364
|
+
cancel_requested_at=(
|
|
365
|
+
event.timestamp
|
|
366
|
+
if event.kind == CANCEL_REQUESTED
|
|
367
|
+
else previous.cancel_requested_at
|
|
368
|
+
),
|
|
369
|
+
error=(_error(event) if event.kind == RUN_FAILED else previous.error),
|
|
370
|
+
# Progress measures a position inside one phase, so entering a new phase
|
|
371
|
+
# discards it.
|
|
372
|
+
progress=(
|
|
373
|
+
progress
|
|
374
|
+
if progress is not None
|
|
375
|
+
else (None if entered_new_phase else previous.progress)
|
|
376
|
+
),
|
|
377
|
+
variant_progress=variant_progress,
|
|
378
|
+
result_digest=(
|
|
379
|
+
_result_digest(event) if carries_result else previous.result_digest
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _opening_state(event: RunEvent) -> RunState:
|
|
385
|
+
"""Return the empty state a run's created event is applied to.
|
|
386
|
+
|
|
387
|
+
A run starts with nothing known about it. Spelling that as a state rather
|
|
388
|
+
than as a special case in every field keeps the projection one shape.
|
|
389
|
+
"""
|
|
390
|
+
return RunState(
|
|
391
|
+
run_id=event.run_id,
|
|
392
|
+
phase=RunPhase.CREATED,
|
|
393
|
+
sequence=0,
|
|
394
|
+
updated_at=event.timestamp,
|
|
395
|
+
worker_pid=None,
|
|
396
|
+
worker_started_at=None,
|
|
397
|
+
heartbeat_at=None,
|
|
398
|
+
cancel_requested_at=None,
|
|
399
|
+
error=None,
|
|
400
|
+
progress=None,
|
|
401
|
+
variant_progress={},
|
|
402
|
+
result_digest=None,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _worker_pid(event: RunEvent) -> int:
|
|
407
|
+
"""Return the worker process id this event announces."""
|
|
408
|
+
raw = event.details[DETAIL_WORKER_PID]
|
|
409
|
+
if not isinstance(raw, int) or isinstance(raw, bool) or raw <= 0:
|
|
410
|
+
raise _detail_invalid(event, f"{DETAIL_WORKER_PID} must be a positive integer")
|
|
411
|
+
return raw
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _progress(event: RunEvent) -> RunProgress:
|
|
415
|
+
"""Return the progress this event reports."""
|
|
416
|
+
try:
|
|
417
|
+
return RunProgress.model_validate(
|
|
418
|
+
{
|
|
419
|
+
"current": event.details[DETAIL_CURRENT],
|
|
420
|
+
"total": event.details[DETAIL_TOTAL],
|
|
421
|
+
"label": event.details[DETAIL_LABEL],
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
except PydanticValidationError as error:
|
|
425
|
+
raise _detail_invalid(
|
|
426
|
+
event,
|
|
427
|
+
f"progress is not valid ({error.errors()[0]['msg']})",
|
|
428
|
+
) from error
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _variant_progress(event: RunEvent) -> VariantProgress:
|
|
432
|
+
"""Return the one variant's position this event reports."""
|
|
433
|
+
try:
|
|
434
|
+
return VariantProgress.model_validate(
|
|
435
|
+
{
|
|
436
|
+
"variant": event.details[DETAIL_VARIANT],
|
|
437
|
+
"completed": event.details[DETAIL_COMPLETED],
|
|
438
|
+
"total": event.details[DETAIL_TOTAL],
|
|
439
|
+
"running": event.details[DETAIL_RUNNING],
|
|
440
|
+
"errored": event.details[DETAIL_ERRORED],
|
|
441
|
+
"state": event.details[DETAIL_STATE],
|
|
442
|
+
}
|
|
443
|
+
)
|
|
444
|
+
except PydanticValidationError as error:
|
|
445
|
+
raise _detail_invalid(
|
|
446
|
+
event,
|
|
447
|
+
f"variant progress is not valid ({error.errors()[0]['msg']})",
|
|
448
|
+
) from error
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _result_digest(event: RunEvent) -> Digest:
|
|
452
|
+
"""Return the report digest this event records."""
|
|
453
|
+
raw = event.details[DETAIL_RESULT_DIGEST]
|
|
454
|
+
if not isinstance(raw, str):
|
|
455
|
+
raise _detail_invalid(event, f"{DETAIL_RESULT_DIGEST} must be a digest string")
|
|
456
|
+
try:
|
|
457
|
+
return validate_digest(raw)
|
|
458
|
+
except ValidationError as error:
|
|
459
|
+
raise _detail_invalid(
|
|
460
|
+
event, f"{DETAIL_RESULT_DIGEST} is not a digest"
|
|
461
|
+
) from error
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _error(event: RunEvent) -> CliError:
|
|
465
|
+
"""Return the failure this event carries.
|
|
466
|
+
|
|
467
|
+
A run that failed without saying why leaves the caller nothing to act on, so
|
|
468
|
+
the error is part of the transition rather than an optional annotation.
|
|
469
|
+
"""
|
|
470
|
+
try:
|
|
471
|
+
return CliError.model_validate(event.details[DETAIL_ERROR])
|
|
472
|
+
except PydanticValidationError as error:
|
|
473
|
+
raise _detail_invalid(
|
|
474
|
+
event,
|
|
475
|
+
f"{DETAIL_ERROR} is not a CLI error ({error.errors()[0]['msg']})",
|
|
476
|
+
) from error
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _detail_invalid(event: RunEvent, reason: str) -> ValidationError:
|
|
480
|
+
"""Return the failure a detail that its kind cannot interpret raises."""
|
|
481
|
+
return ValidationError(
|
|
482
|
+
f"run event {reason}",
|
|
483
|
+
code="run_event_kind_invalid",
|
|
484
|
+
details={
|
|
485
|
+
"run_id": event.run_id,
|
|
486
|
+
"sequence": event.sequence,
|
|
487
|
+
"kind": event.kind,
|
|
488
|
+
},
|
|
489
|
+
)
|