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,228 @@
|
|
|
1
|
+
"""Which evaluation children a run currently owns. Spec section 6.16.
|
|
2
|
+
|
|
3
|
+
Two variants running side by side turn a single question — "stop this run" —
|
|
4
|
+
into two answers, and neither of them is reachable from the run's journal. The
|
|
5
|
+
journal records that a run was asked to stop; only the process that started the
|
|
6
|
+
children knows which processes those are. This module is that knowledge, and it
|
|
7
|
+
is deliberately the smallest thing that can hold it: an in-memory map from run
|
|
8
|
+
identifier to the live children, guarded by a lock because the poller and a
|
|
9
|
+
signal-driven wind-down touch it from different threads.
|
|
10
|
+
|
|
11
|
+
Nothing here is scientific truth. A registry entry says a process exists, not
|
|
12
|
+
that an experiment happened, and the registry is discarded when the worker
|
|
13
|
+
exits. What survives the worker is one diagnostic file,
|
|
14
|
+
``execution/children.json``, written once both children are up: if the worker
|
|
15
|
+
dies without unwinding, that file is the only record of which process groups
|
|
16
|
+
were left holding Docker containers, and an operator cleaning up by hand has
|
|
17
|
+
nothing else to go on.
|
|
18
|
+
|
|
19
|
+
The file is written after both children start rather than after each one,
|
|
20
|
+
because a write between the two launches would land inside the interval the
|
|
21
|
+
run is measuring as launch skew.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Final, Protocol, runtime_checkable
|
|
32
|
+
|
|
33
|
+
from techtree.canonical import to_json_value
|
|
34
|
+
from techtree.fs import atomic_write_json, ensure_private_directory
|
|
35
|
+
from techtree.models.base import Digest, JsonValue
|
|
36
|
+
from techtree.models.campaign import VariantSchedule
|
|
37
|
+
from techtree.verifiers.models import ChildProcessOutcome, VariantName
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CHILDREN_FILENAME",
|
|
41
|
+
"CHILDREN_RECORD_SCHEMA_VERSION",
|
|
42
|
+
"EXECUTION_DIRECTORY",
|
|
43
|
+
"ChildRegistry",
|
|
44
|
+
"EvaluationChild",
|
|
45
|
+
"LaunchedChild",
|
|
46
|
+
"children_record_path",
|
|
47
|
+
"execution_dir",
|
|
48
|
+
"write_children_record",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
#: Where a run keeps what its execution did, as opposed to what it was given.
|
|
52
|
+
#: Spec section 6.19.
|
|
53
|
+
EXECUTION_DIRECTORY: Final = "execution"
|
|
54
|
+
|
|
55
|
+
#: The diagnostic record of which processes a run started. Spec sections 6.16
|
|
56
|
+
#: and 6.19.
|
|
57
|
+
CHILDREN_FILENAME: Final = "children.json"
|
|
58
|
+
|
|
59
|
+
#: A local operational document, not a protocol object. It is named and
|
|
60
|
+
#: versioned anyway so that a file found after a crash says what it is.
|
|
61
|
+
CHILDREN_RECORD_SCHEMA_VERSION: Final = "techtree.run-children.v1"
|
|
62
|
+
|
|
63
|
+
#: Variants always appear in comparison order, never in start order, so that a
|
|
64
|
+
#: registry listing reads the same way whichever child happened to be quicker.
|
|
65
|
+
_VARIANT_ORDER: Final[tuple[VariantName, ...]] = (
|
|
66
|
+
VariantName.BASELINE,
|
|
67
|
+
VariantName.CANDIDATE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@runtime_checkable
|
|
72
|
+
class EvaluationChild(Protocol):
|
|
73
|
+
"""The part of one evaluation child process a run's control path uses.
|
|
74
|
+
|
|
75
|
+
:class:`~techtree.verifiers.child.VerifiersChild` is the implementation.
|
|
76
|
+
Stating the surface as a protocol is what lets the scheduler's own
|
|
77
|
+
behaviour — the start barrier, sibling termination, the launch-skew
|
|
78
|
+
measurement — be tested without a subprocess, a container, or a provider.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def variant(self) -> VariantName:
|
|
83
|
+
"""Which side of the comparison this child is running."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def pid(self) -> int | None:
|
|
88
|
+
"""The child's process id once it has started."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def argv_digest(self) -> Digest:
|
|
93
|
+
"""The digest of this child's invocation."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
def start(self) -> int:
|
|
97
|
+
"""Start the child and return its pid."""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
def poll(self) -> int | None:
|
|
101
|
+
"""Return the exit code, or ``None`` while the child is still running."""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
def terminate(self, grace_seconds: float = ...) -> None:
|
|
105
|
+
"""Stop the child's process group, allowing runtime teardown first."""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
def outcome(self) -> ChildProcessOutcome:
|
|
109
|
+
"""Describe the finished child."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class LaunchedChild:
|
|
115
|
+
"""One child as it was at the moment the run started it.
|
|
116
|
+
|
|
117
|
+
The start time is the parent's observation of the launch rather than the
|
|
118
|
+
child's own, because it is taken the instant ``start`` returns and the
|
|
119
|
+
child's is only readable once the process has exited. The two bracket each
|
|
120
|
+
other within the cost of a ``fork``.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
variant: VariantName
|
|
124
|
+
pid: int | None
|
|
125
|
+
argv_digest: Digest
|
|
126
|
+
started_at: datetime
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ChildRegistry:
|
|
130
|
+
"""The live evaluation children of every run this process is executing.
|
|
131
|
+
|
|
132
|
+
Registration is by variant, so a run holds at most one child per side and
|
|
133
|
+
re-registering a variant replaces it rather than accumulating a second
|
|
134
|
+
entry that nothing would ever terminate.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
self._lock = threading.Lock()
|
|
139
|
+
self._children: dict[str, dict[VariantName, EvaluationChild]] = {}
|
|
140
|
+
|
|
141
|
+
def register(self, run_id: str, child: EvaluationChild) -> None:
|
|
142
|
+
"""Record that this run owns one child process."""
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._children.setdefault(run_id, {})[child.variant] = child
|
|
145
|
+
|
|
146
|
+
def children(self, run_id: str) -> tuple[EvaluationChild, ...]:
|
|
147
|
+
"""Return this run's live children, in comparison order."""
|
|
148
|
+
with self._lock:
|
|
149
|
+
registered = dict(self._children.get(run_id, {}))
|
|
150
|
+
return tuple(
|
|
151
|
+
registered[variant] for variant in _VARIANT_ORDER if variant in registered
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def terminate_all(self, run_id: str, grace_seconds: float) -> None:
|
|
155
|
+
"""Stop every child this run owns, then forget them.
|
|
156
|
+
|
|
157
|
+
Each child is given the same grace period rather than sharing one, so
|
|
158
|
+
the second variant's containers get as long to tear down as the first
|
|
159
|
+
variant's did. Every child is attempted even if an earlier one raised:
|
|
160
|
+
a run that failed to stop one process group must still stop the other.
|
|
161
|
+
"""
|
|
162
|
+
failures: list[BaseException] = []
|
|
163
|
+
for child in self.children(run_id):
|
|
164
|
+
try:
|
|
165
|
+
child.terminate(grace_seconds)
|
|
166
|
+
except BaseException as error:
|
|
167
|
+
failures.append(error)
|
|
168
|
+
finally:
|
|
169
|
+
self.unregister(run_id, child.variant)
|
|
170
|
+
if failures:
|
|
171
|
+
raise failures[0]
|
|
172
|
+
|
|
173
|
+
def unregister(self, run_id: str, variant: VariantName) -> None:
|
|
174
|
+
"""Forget one child, and the run itself once it owns none."""
|
|
175
|
+
with self._lock:
|
|
176
|
+
registered = self._children.get(run_id)
|
|
177
|
+
if registered is None:
|
|
178
|
+
return
|
|
179
|
+
registered.pop(variant, None)
|
|
180
|
+
if not registered:
|
|
181
|
+
del self._children[run_id]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def execution_dir(run_root: Path) -> Path:
|
|
185
|
+
"""Return where one run records what its execution did."""
|
|
186
|
+
return run_root / EXECUTION_DIRECTORY
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def children_record_path(run_root: Path) -> Path:
|
|
190
|
+
"""Return where one run's diagnostic child record lives."""
|
|
191
|
+
return execution_dir(run_root) / CHILDREN_FILENAME
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def write_children_record(
|
|
195
|
+
*,
|
|
196
|
+
run_root: Path,
|
|
197
|
+
run_id: str,
|
|
198
|
+
schedule: VariantSchedule,
|
|
199
|
+
children: Sequence[LaunchedChild],
|
|
200
|
+
launch_skew_seconds: float | None,
|
|
201
|
+
) -> Path:
|
|
202
|
+
"""Write the diagnostic record of what this run started.
|
|
203
|
+
|
|
204
|
+
``launch_skew_seconds`` is the gap between the two launches under a
|
|
205
|
+
parallel schedule and ``None`` under a sequential one, where the second
|
|
206
|
+
child does not exist yet and a gap would be a measurement of the first
|
|
207
|
+
variant's duration rather than of a launch.
|
|
208
|
+
"""
|
|
209
|
+
path = children_record_path(run_root)
|
|
210
|
+
ensure_private_directory(path.parent)
|
|
211
|
+
rows: list[JsonValue] = [
|
|
212
|
+
{
|
|
213
|
+
"variant": child.variant.value,
|
|
214
|
+
"pid": child.pid,
|
|
215
|
+
"argv_digest": child.argv_digest,
|
|
216
|
+
"started_at": to_json_value(child.started_at),
|
|
217
|
+
}
|
|
218
|
+
for child in children
|
|
219
|
+
]
|
|
220
|
+
document: dict[str, JsonValue] = {
|
|
221
|
+
"schema_version": CHILDREN_RECORD_SCHEMA_VERSION,
|
|
222
|
+
"run_id": run_id,
|
|
223
|
+
"schedule": schedule.value,
|
|
224
|
+
"launch_skew_seconds": launch_skew_seconds,
|
|
225
|
+
"children": rows,
|
|
226
|
+
}
|
|
227
|
+
atomic_write_json(path, document)
|
|
228
|
+
return path
|
techtree/runs/events.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""The append-only run event log and its vocabulary. Spec sections 18.1, 7.4.
|
|
2
|
+
|
|
3
|
+
One run event is one line of canonical JSON. The log is only ever appended to,
|
|
4
|
+
never rewritten, which is what lets a reader that arrives halfway through a run
|
|
5
|
+
reconstruct everything that has happened without coordinating with the writer.
|
|
6
|
+
|
|
7
|
+
Three properties are deliberate.
|
|
8
|
+
|
|
9
|
+
*Canonical bytes.* Lines are serialized with
|
|
10
|
+
:func:`techtree.canonical.canonical_json_bytes`, so the same events always
|
|
11
|
+
produce the same file and :func:`event_digest` is a meaningful identity for a
|
|
12
|
+
run's history rather than an accident of dictionary ordering.
|
|
13
|
+
|
|
14
|
+
*Durability.* Each append is a single write to a file opened with ``O_APPEND``
|
|
15
|
+
followed by an ``fsync``. A crash can therefore lose a whole trailing event, but
|
|
16
|
+
it cannot interleave one event's bytes with another's.
|
|
17
|
+
|
|
18
|
+
*Discontinuity is fatal.* Sequence numbers start at zero and increase by exactly
|
|
19
|
+
one. A log that skips is a log that lost something, and a state projected from
|
|
20
|
+
it would be quietly wrong; :func:`read_events` refuses it instead. That check is
|
|
21
|
+
also what catches a truncated final line, because a partly written event fails
|
|
22
|
+
to parse before its sequence is ever considered.
|
|
23
|
+
|
|
24
|
+
The kinds a run may record are a closed set. ``RunEvent.kind`` is a string in
|
|
25
|
+
the frozen protocol model, but thirteen names exhaust what a local run can say
|
|
26
|
+
about itself, and each name fixes the phases it may appear between and the
|
|
27
|
+
details it carries. :func:`validate_event_kind` holds every reader to that, so a
|
|
28
|
+
log cannot contain an event whose name and position disagree.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Final
|
|
36
|
+
|
|
37
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
38
|
+
|
|
39
|
+
from techtree.canonical import canonical_json_bytes, sha256_digest_bytes
|
|
40
|
+
from techtree.errors import NotFoundError, ValidationError
|
|
41
|
+
from techtree.fs import fsync_directory
|
|
42
|
+
from techtree.models.base import Digest, JsonValue
|
|
43
|
+
from techtree.models.run import RunEvent, RunPhase
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"CANCEL_REQUESTED",
|
|
47
|
+
"DETAIL_ACTOR",
|
|
48
|
+
"DETAIL_APPROVED_AT",
|
|
49
|
+
"DETAIL_COMPLETED",
|
|
50
|
+
"DETAIL_CURRENT",
|
|
51
|
+
"DETAIL_DRAFT_DIGEST",
|
|
52
|
+
"DETAIL_ERROR",
|
|
53
|
+
"DETAIL_ERRORED",
|
|
54
|
+
"DETAIL_LABEL",
|
|
55
|
+
"DETAIL_REQUESTED_BY",
|
|
56
|
+
"DETAIL_REQUEST_DIGEST",
|
|
57
|
+
"DETAIL_RESULT_DIGEST",
|
|
58
|
+
"DETAIL_RUNNING",
|
|
59
|
+
"DETAIL_STATE",
|
|
60
|
+
"DETAIL_TOTAL",
|
|
61
|
+
"DETAIL_VARIANT",
|
|
62
|
+
"DETAIL_WORKER_PID",
|
|
63
|
+
"EVENT_KINDS",
|
|
64
|
+
"PHASE_ENTERED",
|
|
65
|
+
"PROGRESS_UPDATED",
|
|
66
|
+
"RESULT_WRITTEN",
|
|
67
|
+
"RUN_APPROVED",
|
|
68
|
+
"RUN_CANCELLED",
|
|
69
|
+
"RUN_COMPLETED",
|
|
70
|
+
"RUN_CREATED",
|
|
71
|
+
"RUN_FAILED",
|
|
72
|
+
"SAME_PHASE_EVENT_KINDS",
|
|
73
|
+
"VARIANT_COMPLETED",
|
|
74
|
+
"VARIANT_EVENT_KINDS",
|
|
75
|
+
"VARIANT_PROGRESS",
|
|
76
|
+
"VARIANT_STARTED",
|
|
77
|
+
"WORKER_STARTED",
|
|
78
|
+
"append_event",
|
|
79
|
+
"event_digest",
|
|
80
|
+
"next_sequence",
|
|
81
|
+
"read_events",
|
|
82
|
+
"validate_event_kind",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
_LINE_SEPARATOR: Final = b"\n"
|
|
86
|
+
#: Owner read and write only, matching every other file Techtree writes.
|
|
87
|
+
_FILE_MODE: Final = 0o600
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Event kinds
|
|
92
|
+
#
|
|
93
|
+
# Twelve names, and no thirteenth. A kind is not a free-text annotation: it
|
|
94
|
+
# fixes what the event means, which phases it may sit between, and which details
|
|
95
|
+
# it must carry, so a reader can interpret an event without guessing.
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
#: The run exists and its request is fixed. Always sequence zero.
|
|
99
|
+
RUN_CREATED: Final = "run.created"
|
|
100
|
+
#: A person read the review of what this run would do and approved it. Decisions
|
|
101
|
+
#: document 0019 section 2: an audit fact recorded beside everything else that
|
|
102
|
+
#: happened to the run, not a cryptographic acceptance artifact.
|
|
103
|
+
RUN_APPROVED: Final = "run.approved"
|
|
104
|
+
#: A detached worker took the run on and announced its process id.
|
|
105
|
+
WORKER_STARTED: Final = "worker.started"
|
|
106
|
+
#: The run moved from one phase to another.
|
|
107
|
+
PHASE_ENTERED: Final = "phase.entered"
|
|
108
|
+
#: How far through the current phase the worker is.
|
|
109
|
+
PROGRESS_UPDATED: Final = "progress.updated"
|
|
110
|
+
#: Someone asked the run to stop.
|
|
111
|
+
CANCEL_REQUESTED: Final = "cancel.requested"
|
|
112
|
+
#: The run stopped because something went wrong.
|
|
113
|
+
RUN_FAILED: Final = "run.failed"
|
|
114
|
+
#: The run stopped because it was asked to.
|
|
115
|
+
RUN_CANCELLED: Final = "run.cancelled"
|
|
116
|
+
#: The report was persisted and its digest recorded.
|
|
117
|
+
RESULT_WRITTEN: Final = "result.written"
|
|
118
|
+
#: The run finished the normal path.
|
|
119
|
+
RUN_COMPLETED: Final = "run.completed"
|
|
120
|
+
#: One side of a concurrent comparison began. Spec section 3.3.
|
|
121
|
+
VARIANT_STARTED: Final = "variant.started"
|
|
122
|
+
#: How far one side of a concurrent comparison has got.
|
|
123
|
+
VARIANT_PROGRESS: Final = "variant.progress"
|
|
124
|
+
#: One side of a concurrent comparison stopped, whichever way it stopped.
|
|
125
|
+
VARIANT_COMPLETED: Final = "variant.completed"
|
|
126
|
+
|
|
127
|
+
#: The three kinds that speak for one variant rather than for the run. They
|
|
128
|
+
#: exist only while both variants are in flight, which is one phase and no
|
|
129
|
+
#: other.
|
|
130
|
+
VARIANT_EVENT_KINDS: Final[frozenset[str]] = frozenset(
|
|
131
|
+
{VARIANT_STARTED, VARIANT_PROGRESS, VARIANT_COMPLETED}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
#: Every kind a run may record. Anything else is a corrupt log.
|
|
135
|
+
EVENT_KINDS: Final[frozenset[str]] = frozenset(
|
|
136
|
+
{
|
|
137
|
+
RUN_CREATED,
|
|
138
|
+
RUN_APPROVED,
|
|
139
|
+
WORKER_STARTED,
|
|
140
|
+
PHASE_ENTERED,
|
|
141
|
+
PROGRESS_UPDATED,
|
|
142
|
+
CANCEL_REQUESTED,
|
|
143
|
+
RUN_FAILED,
|
|
144
|
+
RUN_CANCELLED,
|
|
145
|
+
RESULT_WRITTEN,
|
|
146
|
+
RUN_COMPLETED,
|
|
147
|
+
*VARIANT_EVENT_KINDS,
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
#: The kinds that report something true of a run without moving it on. Every
|
|
152
|
+
#: other kind changes the phase. Spec section 7.6, extended by section 3.3.
|
|
153
|
+
SAME_PHASE_EVENT_KINDS: Final[frozenset[str]] = frozenset(
|
|
154
|
+
{
|
|
155
|
+
RUN_APPROVED,
|
|
156
|
+
WORKER_STARTED,
|
|
157
|
+
PROGRESS_UPDATED,
|
|
158
|
+
RESULT_WRITTEN,
|
|
159
|
+
*VARIANT_EVENT_KINDS,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Detail keys
|
|
166
|
+
#
|
|
167
|
+
# ``RunEvent.details`` is free-form JSON so that a worker can record whatever a
|
|
168
|
+
# human will want to read later. These keys are the exception: each is part of
|
|
169
|
+
# the definition of the kind that carries it, and the projection interprets it.
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
#: The digest of the ``RunRequest`` this run executes.
|
|
173
|
+
DETAIL_REQUEST_DIGEST: Final = "request_digest"
|
|
174
|
+
#: The digest of the draft that was reviewed and approved.
|
|
175
|
+
DETAIL_DRAFT_DIGEST: Final = "draft_digest"
|
|
176
|
+
#: Who approved it: a person answering the prompt, or an operator who passed
|
|
177
|
+
#: the flag that stands in for the answer.
|
|
178
|
+
DETAIL_ACTOR: Final = "actor"
|
|
179
|
+
#: When the approval was given.
|
|
180
|
+
DETAIL_APPROVED_AT: Final = "approved_at"
|
|
181
|
+
#: The process id of the detached worker executing this run.
|
|
182
|
+
DETAIL_WORKER_PID: Final = "worker_pid"
|
|
183
|
+
#: Position within the work the current phase describes.
|
|
184
|
+
DETAIL_CURRENT: Final = "current"
|
|
185
|
+
#: How much work the current phase amounts to.
|
|
186
|
+
DETAIL_TOTAL: Final = "total"
|
|
187
|
+
#: What the current phase is working through.
|
|
188
|
+
DETAIL_LABEL: Final = "label"
|
|
189
|
+
#: Who asked the run to stop.
|
|
190
|
+
DETAIL_REQUESTED_BY: Final = "requested_by"
|
|
191
|
+
#: A ``CliError`` object saying why the run failed.
|
|
192
|
+
DETAIL_ERROR: Final = "error"
|
|
193
|
+
#: The digest of the ``UpliftReport`` this run produced.
|
|
194
|
+
DETAIL_RESULT_DIGEST: Final = "result_digest"
|
|
195
|
+
#: Which side of a concurrent comparison a variant event speaks for.
|
|
196
|
+
DETAIL_VARIANT: Final = "variant"
|
|
197
|
+
#: Episodes of this variant that have finished.
|
|
198
|
+
DETAIL_COMPLETED: Final = "completed"
|
|
199
|
+
#: Episodes of this variant currently in flight.
|
|
200
|
+
DETAIL_RUNNING: Final = "running"
|
|
201
|
+
#: Episodes of this variant that failed.
|
|
202
|
+
DETAIL_ERRORED: Final = "errored"
|
|
203
|
+
#: Where this variant is in its own lifecycle.
|
|
204
|
+
DETAIL_STATE: Final = "state"
|
|
205
|
+
|
|
206
|
+
#: The details every variant event carries. They are the whole of
|
|
207
|
+
#: :class:`~techtree.models.run.VariantProgress`, because a variant event that
|
|
208
|
+
#: reported only part of it would leave the projection guessing at the rest.
|
|
209
|
+
_VARIANT_DETAILS: Final[tuple[str, ...]] = (
|
|
210
|
+
DETAIL_VARIANT,
|
|
211
|
+
DETAIL_COMPLETED,
|
|
212
|
+
DETAIL_TOTAL,
|
|
213
|
+
DETAIL_RUNNING,
|
|
214
|
+
DETAIL_ERRORED,
|
|
215
|
+
DETAIL_STATE,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
#: What each kind is defined to carry. A kind missing one of its own details
|
|
219
|
+
#: cannot be interpreted, so it is refused rather than projected as a blank.
|
|
220
|
+
_REQUIRED_DETAILS: Final[dict[str, tuple[str, ...]]] = {
|
|
221
|
+
RUN_CREATED: (DETAIL_REQUEST_DIGEST,),
|
|
222
|
+
RUN_APPROVED: (DETAIL_DRAFT_DIGEST, DETAIL_ACTOR, DETAIL_APPROVED_AT),
|
|
223
|
+
WORKER_STARTED: (DETAIL_WORKER_PID,),
|
|
224
|
+
PROGRESS_UPDATED: (DETAIL_CURRENT, DETAIL_TOTAL, DETAIL_LABEL),
|
|
225
|
+
CANCEL_REQUESTED: (DETAIL_REQUESTED_BY,),
|
|
226
|
+
RUN_FAILED: (DETAIL_ERROR,),
|
|
227
|
+
RESULT_WRITTEN: (DETAIL_RESULT_DIGEST,),
|
|
228
|
+
RUN_COMPLETED: (DETAIL_RESULT_DIGEST,),
|
|
229
|
+
VARIANT_STARTED: _VARIANT_DETAILS,
|
|
230
|
+
VARIANT_PROGRESS: _VARIANT_DETAILS,
|
|
231
|
+
VARIANT_COMPLETED: _VARIANT_DETAILS,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Kind and phase agreement
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
#: Four phases each belong to exactly one kind, in both directions: no other
|
|
240
|
+
#: kind may enter the phase, and the kind may enter no other phase. Ending a run
|
|
241
|
+
#: is too consequential to be recorded by a generic phase change.
|
|
242
|
+
_EXCLUSIVE_PHASE_KINDS: Final[dict[RunPhase, str]] = {
|
|
243
|
+
RunPhase.CANCEL_REQUESTED: CANCEL_REQUESTED,
|
|
244
|
+
RunPhase.FAILED: RUN_FAILED,
|
|
245
|
+
RunPhase.CANCELLED: RUN_CANCELLED,
|
|
246
|
+
RunPhase.COMPLETED: RUN_COMPLETED,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#: The one phase each of these kinds may appear in. A worker announces itself
|
|
250
|
+
#: before the run starts working; a result exists only while the report is
|
|
251
|
+
#: being built.
|
|
252
|
+
_ONLY_IN_PHASE: Final[dict[str, RunPhase]] = {
|
|
253
|
+
# Approval is given before anything is launched, so it can only ever be
|
|
254
|
+
# recorded against the phase a run exists in and has not yet left.
|
|
255
|
+
RUN_APPROVED: RunPhase.CREATED,
|
|
256
|
+
WORKER_STARTED: RunPhase.CREATED,
|
|
257
|
+
RESULT_WRITTEN: RunPhase.BUILDING_REPORT,
|
|
258
|
+
# A variant is a side of a concurrent comparison, and nothing is running
|
|
259
|
+
# two variants at once outside the one phase that says so.
|
|
260
|
+
**dict.fromkeys(VARIANT_EVENT_KINDS, RunPhase.RUNNING_VARIANTS),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#: The phase each of these kinds must have been reached from. Both are the last
|
|
264
|
+
#: step of a path, and the step before it is not optional.
|
|
265
|
+
_REQUIRED_PREVIOUS_PHASE: Final[dict[str, RunPhase]] = {
|
|
266
|
+
RUN_CANCELLED: RunPhase.CANCEL_REQUESTED,
|
|
267
|
+
RUN_COMPLETED: RunPhase.BUILDING_REPORT,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def validate_event_kind(event: RunEvent) -> None:
|
|
272
|
+
"""Reject unknown kinds, kind and phase mismatches, and missing details."""
|
|
273
|
+
if event.kind not in EVENT_KINDS:
|
|
274
|
+
known: list[JsonValue] = [kind for kind in sorted(EVENT_KINDS)]
|
|
275
|
+
raise ValidationError(
|
|
276
|
+
f"{event.kind!r} is not a run event kind",
|
|
277
|
+
code="run_event_kind_invalid",
|
|
278
|
+
details={
|
|
279
|
+
"run_id": event.run_id,
|
|
280
|
+
"sequence": event.sequence,
|
|
281
|
+
"kind": event.kind,
|
|
282
|
+
"known": known,
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
_check_phases(event)
|
|
287
|
+
_check_required_details(event)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _check_phases(event: RunEvent) -> None:
|
|
291
|
+
"""Refuse an event whose kind and phases describe different things."""
|
|
292
|
+
if event.kind == RUN_CREATED:
|
|
293
|
+
_check_created_event(event)
|
|
294
|
+
elif event.previous_phase is None:
|
|
295
|
+
raise _kind_mismatch(
|
|
296
|
+
event,
|
|
297
|
+
f"only {RUN_CREATED} comes from no earlier phase",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# The exclusive kinds own the *transition into* their phase. An event that
|
|
301
|
+
# stays where it is (a progress report landing just after cancellation was
|
|
302
|
+
# requested, for instance) is governed by the same-phase whitelist instead.
|
|
303
|
+
entering = event.previous_phase is not event.phase
|
|
304
|
+
for phase, kind in _EXCLUSIVE_PHASE_KINDS.items():
|
|
305
|
+
if entering and event.phase is phase and event.kind != kind:
|
|
306
|
+
raise _kind_mismatch(event, f"only {kind} enters {phase.value}")
|
|
307
|
+
if event.kind == kind and event.phase is not phase:
|
|
308
|
+
raise _kind_mismatch(event, f"{kind} enters {phase.value}")
|
|
309
|
+
|
|
310
|
+
required_phase = _ONLY_IN_PHASE.get(event.kind)
|
|
311
|
+
if required_phase is not None and event.phase is not required_phase:
|
|
312
|
+
raise _kind_mismatch(
|
|
313
|
+
event,
|
|
314
|
+
f"{event.kind} belongs to {required_phase.value}, "
|
|
315
|
+
f"not to {event.phase.value}",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
required_previous = _REQUIRED_PREVIOUS_PHASE.get(event.kind)
|
|
319
|
+
if required_previous is not None and event.previous_phase is not required_previous:
|
|
320
|
+
raise _kind_mismatch(
|
|
321
|
+
event,
|
|
322
|
+
f"{event.kind} follows {required_previous.value}",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# The same-phase kinds report something true of a run while it stays where
|
|
326
|
+
# it is; every other kind exists to move it. Neither may pretend to be the
|
|
327
|
+
# other, so the two are checked as one equivalence.
|
|
328
|
+
same_phase = event.phase is event.previous_phase
|
|
329
|
+
if same_phase and event.kind not in SAME_PHASE_EVENT_KINDS:
|
|
330
|
+
raise _kind_mismatch(
|
|
331
|
+
event,
|
|
332
|
+
f"{event.kind} moves a run on, but this one stays in {event.phase.value}",
|
|
333
|
+
)
|
|
334
|
+
if not same_phase and event.kind in SAME_PHASE_EVENT_KINDS:
|
|
335
|
+
raise _kind_mismatch(
|
|
336
|
+
event,
|
|
337
|
+
f"{event.kind} does not move a run out of {event.phase.value}",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _check_created_event(event: RunEvent) -> None:
|
|
342
|
+
"""Refuse a created event anywhere but at the opening of a log."""
|
|
343
|
+
if event.sequence != 0:
|
|
344
|
+
raise _kind_mismatch(
|
|
345
|
+
event,
|
|
346
|
+
f"{RUN_CREATED} opens a log at sequence 0, not {event.sequence}",
|
|
347
|
+
)
|
|
348
|
+
if event.previous_phase is not None:
|
|
349
|
+
raise _kind_mismatch(
|
|
350
|
+
event,
|
|
351
|
+
"a run's first event comes from no earlier phase, but this one "
|
|
352
|
+
f"claims to leave {event.previous_phase.value}",
|
|
353
|
+
)
|
|
354
|
+
if event.phase is not RunPhase.CREATED:
|
|
355
|
+
raise _kind_mismatch(
|
|
356
|
+
event,
|
|
357
|
+
f"{RUN_CREATED} opens a run in {RunPhase.CREATED.value}",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _check_required_details(event: RunEvent) -> None:
|
|
362
|
+
"""Refuse an event that omits a detail its kind is defined to carry."""
|
|
363
|
+
for key in _REQUIRED_DETAILS.get(event.kind, ()):
|
|
364
|
+
if key not in event.details:
|
|
365
|
+
raise _kind_mismatch(
|
|
366
|
+
event,
|
|
367
|
+
f"a {event.kind} event carries details.{key}",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _kind_mismatch(event: RunEvent, reason: str) -> ValidationError:
|
|
372
|
+
"""Return the one failure every kind and phase disagreement raises."""
|
|
373
|
+
previous = None if event.previous_phase is None else event.previous_phase.value
|
|
374
|
+
return ValidationError(
|
|
375
|
+
reason,
|
|
376
|
+
code="run_event_kind_invalid",
|
|
377
|
+
details={
|
|
378
|
+
"run_id": event.run_id,
|
|
379
|
+
"sequence": event.sequence,
|
|
380
|
+
"kind": event.kind,
|
|
381
|
+
"phase": event.phase.value,
|
|
382
|
+
"event_previous_phase": previous,
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# Bytes
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def append_event(path: Path, event: RunEvent) -> None:
|
|
393
|
+
"""Append compact JSONL and fsync."""
|
|
394
|
+
line = canonical_json_bytes(event) + _LINE_SEPARATOR
|
|
395
|
+
|
|
396
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
|
|
397
|
+
descriptor = os.open(path, flags, _FILE_MODE)
|
|
398
|
+
try:
|
|
399
|
+
written = 0
|
|
400
|
+
while written < len(line):
|
|
401
|
+
written += os.write(descriptor, line[written:])
|
|
402
|
+
os.fsync(descriptor)
|
|
403
|
+
finally:
|
|
404
|
+
os.close(descriptor)
|
|
405
|
+
|
|
406
|
+
# The first append also creates the directory entry, and a durable file
|
|
407
|
+
# whose name is not durable is not much use after a crash.
|
|
408
|
+
fsync_directory(path.parent)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def read_events(path: Path) -> list[RunEvent]:
|
|
412
|
+
"""Read events and reject sequence discontinuity."""
|
|
413
|
+
try:
|
|
414
|
+
raw = path.read_bytes()
|
|
415
|
+
except FileNotFoundError as error:
|
|
416
|
+
raise NotFoundError(
|
|
417
|
+
f"no run event log at {path}",
|
|
418
|
+
details={"path": str(path)},
|
|
419
|
+
) from error
|
|
420
|
+
|
|
421
|
+
lines = raw.split(_LINE_SEPARATOR)
|
|
422
|
+
# A well-formed log ends with a separator, which leaves one empty trailing
|
|
423
|
+
# element. Any other empty line is damage.
|
|
424
|
+
if lines and lines[-1] == b"":
|
|
425
|
+
lines.pop()
|
|
426
|
+
|
|
427
|
+
events: list[RunEvent] = []
|
|
428
|
+
for number, line in enumerate(lines, start=1):
|
|
429
|
+
if not line.strip():
|
|
430
|
+
raise ValidationError(
|
|
431
|
+
f"run event log line {number} is empty: {path}",
|
|
432
|
+
code="run_event_log_corrupt",
|
|
433
|
+
details={"path": str(path), "line": number},
|
|
434
|
+
)
|
|
435
|
+
try:
|
|
436
|
+
event = RunEvent.model_validate_json(line)
|
|
437
|
+
except PydanticValidationError as error:
|
|
438
|
+
raise ValidationError(
|
|
439
|
+
f"run event log line {number} is not a run event: {path} "
|
|
440
|
+
f"({error.errors()[0]['msg']})",
|
|
441
|
+
code="run_event_log_corrupt",
|
|
442
|
+
details={"path": str(path), "line": number},
|
|
443
|
+
) from error
|
|
444
|
+
|
|
445
|
+
expected = len(events)
|
|
446
|
+
if event.sequence != expected:
|
|
447
|
+
raise ValidationError(
|
|
448
|
+
f"run event log skips from sequence {expected - 1} to "
|
|
449
|
+
f"{event.sequence}: {path}",
|
|
450
|
+
code="run_event_sequence_invalid",
|
|
451
|
+
details={
|
|
452
|
+
"path": str(path),
|
|
453
|
+
"line": number,
|
|
454
|
+
"expected_sequence": expected,
|
|
455
|
+
"sequence": event.sequence,
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
events.append(event)
|
|
459
|
+
|
|
460
|
+
return events
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def next_sequence(events: list[RunEvent]) -> int:
|
|
464
|
+
"""Return next sequence number."""
|
|
465
|
+
if not events:
|
|
466
|
+
return 0
|
|
467
|
+
return events[-1].sequence + 1
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def event_digest(path: Path) -> Digest:
|
|
471
|
+
"""Digest exact event-log bytes."""
|
|
472
|
+
try:
|
|
473
|
+
return sha256_digest_bytes(path.read_bytes())
|
|
474
|
+
except FileNotFoundError as error:
|
|
475
|
+
raise NotFoundError(
|
|
476
|
+
f"no run event log at {path}",
|
|
477
|
+
details={"path": str(path)},
|
|
478
|
+
) from error
|