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.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
techtree/runs/store.py ADDED
@@ -0,0 +1,555 @@
1
+ """Everything one run keeps on disk. Spec section 18.3.
2
+
3
+ A run directory holds three kinds of file and treats each differently.
4
+
5
+ *Written once.* ``request.json`` is what was asked for and ``report/uplift.json``
6
+ is what came back. Both are created with ``O_EXCL`` in canonical bytes, so a
7
+ second attempt is reported as a conflict rather than quietly replacing evidence,
8
+ and the file's digest is the object's digest.
9
+
10
+ *Appended to.* ``events.jsonl`` is the run's history and the only authority on
11
+ what has happened.
12
+
13
+ *Overwritten.* ``state.json`` is a projection of the log, ``heartbeat.json`` is
14
+ the worker's most recent sign of life, and ``pid`` names the process executing
15
+ the run. Each is replaced atomically, and none of them is ever the source of a
16
+ fact the log could not rebuild — except the heartbeat, which is deliberately not
17
+ event-sourced because a signal refreshed every two seconds would drown the
18
+ history it was appended to.
19
+
20
+ Every writer passes through ``runs/<run-id>/.lock``. The lock matters because
21
+ the writers are genuinely concurrent: a detached worker advances the run while
22
+ the CLI, in another process entirely, requests cancellation. Appending an event
23
+ and rewriting the projection happen inside one lock hold, so the two files can
24
+ never disagree about which event was last.
25
+
26
+ The projection is always recomputed from the log rather than patched in place.
27
+ It costs one small read and it means a stale or corrupted ``state.json`` heals
28
+ itself at the next append, heartbeat, or explicit rebuild instead of persisting
29
+ a wrong answer.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ from collections.abc import Iterator
36
+ from contextlib import contextmanager
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import Final
40
+
41
+ from filelock import FileLock, Timeout
42
+ from pydantic import ValidationError as PydanticValidationError
43
+
44
+ from techtree.canonical import canonical_json_bytes, digest_object
45
+ from techtree.errors import ConflictError, NotFoundError, RunError, ValidationError
46
+ from techtree.fs import (
47
+ atomic_write_json,
48
+ atomic_write_text,
49
+ ensure_private_directory,
50
+ fsync_directory,
51
+ open_exclusive,
52
+ read_json,
53
+ )
54
+ from techtree.models.base import JsonValue
55
+ from techtree.models.run import RunEvent, RunPhase, RunRequest, RunState
56
+ from techtree.models.uplift_report import UpliftReport
57
+ from techtree.paths import TechtreePaths
58
+ from techtree.runs.events import (
59
+ CANCEL_REQUESTED,
60
+ DETAIL_REQUEST_DIGEST,
61
+ DETAIL_REQUESTED_BY,
62
+ DETAIL_RESULT_DIGEST,
63
+ DETAIL_WORKER_PID,
64
+ PHASE_ENTERED,
65
+ RESULT_WRITTEN,
66
+ RUN_CREATED,
67
+ WORKER_STARTED,
68
+ append_event,
69
+ next_sequence,
70
+ read_events,
71
+ )
72
+ from techtree.runs.machine import apply_event, is_terminal, reduce_events
73
+
74
+ __all__ = [
75
+ "LOCK_TIMEOUT_SECONDS",
76
+ "RunStore",
77
+ ]
78
+
79
+ #: A lock hold is a few file operations, so waiting this long means the holder
80
+ #: died with the lock or something is badly wrong. Failing beats hanging.
81
+ LOCK_TIMEOUT_SECONDS: Final = 30.0
82
+
83
+ _LOCK_FILE_NAME: Final = ".lock"
84
+ _REQUEST_FILE_NAME: Final = "request.json"
85
+ _EVENTS_FILE_NAME: Final = "events.jsonl"
86
+ _STATE_FILE_NAME: Final = "state.json"
87
+ _HEARTBEAT_FILE_NAME: Final = "heartbeat.json"
88
+ _PID_FILE_NAME: Final = "pid"
89
+ _WORKER_LOG_FILE_NAME: Final = "worker.log"
90
+ _REPORT_DIRECTORY_NAME: Final = "report"
91
+ _RESULT_FILE_NAME: Final = "uplift.json"
92
+
93
+ _HEARTBEAT_PHASE_KEY: Final = "phase"
94
+ _HEARTBEAT_AT_KEY: Final = "at"
95
+
96
+ _FILE_MODE: Final = 0o600
97
+
98
+
99
+ def _now() -> datetime:
100
+ return datetime.now(UTC)
101
+
102
+
103
+ class RunStore:
104
+ """The run directory, and the only way anything writes to it."""
105
+
106
+ def __init__(self, paths: TechtreePaths) -> None:
107
+ self._paths = paths
108
+
109
+ # -- immutable request --------------------------------------------------
110
+
111
+ def create(self, request: RunRequest) -> RunState:
112
+ """Create run tree and initial event."""
113
+ run_id = request.run_id
114
+ run_dir = self._run_dir(run_id)
115
+ ensure_private_directory(self._paths.runs_dir)
116
+ ensure_private_directory(run_dir)
117
+
118
+ with self._lock(run_id):
119
+ try:
120
+ self._write_immutable(self._request_path(run_id), request)
121
+ except ConflictError as error:
122
+ raise ConflictError(
123
+ f"run {run_id} already exists",
124
+ code="run_already_exists",
125
+ details={"run_id": run_id},
126
+ ) from error
127
+ append_event(
128
+ self._events_path(run_id),
129
+ RunEvent(
130
+ sequence=0,
131
+ timestamp=_now(),
132
+ run_id=run_id,
133
+ previous_phase=None,
134
+ phase=RunPhase.CREATED,
135
+ kind=RUN_CREATED,
136
+ # Naming the request here ties the log to the document it
137
+ # executes, so a recovered run directory can be checked
138
+ # rather than assumed.
139
+ details={DETAIL_REQUEST_DIGEST: digest_object(request)},
140
+ ),
141
+ )
142
+ return self._write_projection(run_id)
143
+
144
+ def get_request(self, run_id: str) -> RunRequest:
145
+ """Load immutable request."""
146
+ path = self._request_path(run_id)
147
+ try:
148
+ raw = path.read_bytes()
149
+ except FileNotFoundError as error:
150
+ raise NotFoundError(
151
+ f"no such run: {run_id}",
152
+ code="run_not_found",
153
+ details={"run_id": run_id},
154
+ ) from error
155
+
156
+ try:
157
+ return RunRequest.model_validate_json(raw)
158
+ except PydanticValidationError as error:
159
+ # Nothing here inspects *why* validation failed, and nothing here
160
+ # tries to read the document another way. A stored request that
161
+ # this build cannot read is a run this build cannot operate on,
162
+ # and that is the whole of what the message says: it does not
163
+ # accuse the participant's files of being damaged, because they
164
+ # are not — they are exactly the bytes that were written, and the
165
+ # run's signed proof still checks against them.
166
+ raise ValidationError(
167
+ f"this run's records do not match what this version of techtree "
168
+ f"writes, so this build cannot operate on the run: {path}. Runs "
169
+ f"recorded by an earlier version of techtree read this way. "
170
+ f"Techtree has not changed the run's files — it never rewrites a "
171
+ f"run's records — and `techtree proof verify {run_id}` still "
172
+ f"checks the run's proof.",
173
+ code="run_request_unreadable",
174
+ details={"run_id": run_id, "path": str(path)},
175
+ ) from error
176
+
177
+ # -- events and projection ---------------------------------------------
178
+
179
+ def append(
180
+ self,
181
+ run_id: str,
182
+ *,
183
+ phase: RunPhase | None,
184
+ kind: str = PHASE_ENTERED,
185
+ details: dict[str, JsonValue] | None = None,
186
+ ) -> RunState:
187
+ """Validate event, append, and project.
188
+
189
+ ``phase=None`` records the event against whatever phase the run is in
190
+ at append time, under the lock. A caller reporting on work in progress
191
+ must use it rather than naming a phase, because between its last look
192
+ and this append the run may have been asked to cancel — and an event
193
+ that claims a phase the run has left is corruption, not progress.
194
+ """
195
+ self._require_run(run_id)
196
+ with self._lock(run_id):
197
+ return self._append_locked(run_id, phase=phase, kind=kind, details=details)
198
+
199
+ def request_cancel(self, run_id: str, *, requested_by: str) -> RunState:
200
+ """Ask a run to stop, idempotently.
201
+
202
+ A second request changes nothing: the log records when a run was first
203
+ asked to stop, not how many times. Asking is therefore safe to retry,
204
+ which matters because the CLI process that asked may not survive long
205
+ enough to learn whether it succeeded.
206
+ """
207
+ self._require_run(run_id)
208
+ with self._lock(run_id):
209
+ current = reduce_events(read_events(self._events_path(run_id)))
210
+ if current.phase is RunPhase.CANCEL_REQUESTED:
211
+ return self._with_heartbeat(run_id, current)
212
+ if is_terminal(current.phase):
213
+ raise RunError(
214
+ f"run has already ended in {current.phase.value}; it cannot "
215
+ "be cancelled",
216
+ code="run_not_cancellable",
217
+ details={"run_id": run_id, "phase": current.phase.value},
218
+ )
219
+ return self._append_locked(
220
+ run_id,
221
+ phase=RunPhase.CANCEL_REQUESTED,
222
+ kind=CANCEL_REQUESTED,
223
+ details={DETAIL_REQUESTED_BY: requested_by},
224
+ )
225
+
226
+ def state(self, run_id: str) -> RunState:
227
+ """Load projection or rebuild."""
228
+ try:
229
+ raw = self._state_path(run_id).read_bytes()
230
+ except FileNotFoundError:
231
+ return self.rebuild_state(run_id)
232
+
233
+ try:
234
+ cached = RunState.model_validate_json(raw)
235
+ except PydanticValidationError:
236
+ # The projection is a cache. A cache that will not parse is simply
237
+ # a cache miss.
238
+ return self.rebuild_state(run_id)
239
+
240
+ # The journal is the truth; the cache is only trusted when it matches
241
+ # the journal's head. A cache that lags (a crash between event append
242
+ # and projection write) rebuilds. A cache that is AHEAD of the journal
243
+ # claims events that never durably happened, which is corruption, not
244
+ # a cache miss.
245
+ journal = reduce_events(read_events(self._events_path(run_id)))
246
+ if cached.run_id != journal.run_id or cached.sequence > journal.sequence:
247
+ raise ConflictError(
248
+ "the cached run state is ahead of the run's event journal",
249
+ code="run_state_ahead_of_journal",
250
+ details={
251
+ "run_id": run_id,
252
+ "cached_sequence": cached.sequence,
253
+ "journal_sequence": journal.sequence,
254
+ },
255
+ )
256
+ if cached.sequence < journal.sequence:
257
+ return self.rebuild_state(run_id)
258
+ return cached
259
+
260
+ def rebuild_state(self, run_id: str) -> RunState:
261
+ """Recompute from events."""
262
+ self._require_run(run_id)
263
+ with self._lock(run_id):
264
+ return self._write_projection(run_id)
265
+
266
+ # -- worker process -----------------------------------------------------
267
+
268
+ def write_pid(self, run_id: str, pid: int) -> None:
269
+ """Persist worker PID."""
270
+ self._require_run(run_id)
271
+ if pid <= 0:
272
+ raise ValidationError(
273
+ f"a worker process id is positive, not {pid}",
274
+ details={"run_id": run_id, "pid": pid},
275
+ )
276
+
277
+ with self._lock(run_id):
278
+ self._require_open_locked(run_id, "take on a worker")
279
+ # The pid file is what a signal-sender reads; the event is what
280
+ # makes the worker's start part of the run's rebuildable history.
281
+ # The event is settled first so that a run which cannot record a
282
+ # worker does not end up with a pid file naming one.
283
+ event = self._prepare_event(
284
+ run_id,
285
+ phase=None,
286
+ kind=WORKER_STARTED,
287
+ details={DETAIL_WORKER_PID: pid},
288
+ )
289
+ atomic_write_text(self._pid_path(run_id), f"{pid}\n")
290
+ self._commit_event(run_id, event)
291
+
292
+ def read_pid(self, run_id: str) -> int | None:
293
+ """Read PID."""
294
+ self._require_run(run_id)
295
+ path = self._pid_path(run_id)
296
+ try:
297
+ raw = path.read_text(encoding="utf-8")
298
+ except FileNotFoundError:
299
+ return None
300
+
301
+ try:
302
+ return int(raw.strip())
303
+ except ValueError as error:
304
+ raise ValidationError(
305
+ f"run pid file does not hold a process id: {path}",
306
+ details={"run_id": run_id, "path": str(path)},
307
+ ) from error
308
+
309
+ def write_heartbeat(self, run_id: str, phase: RunPhase) -> None:
310
+ """Refresh heartbeat."""
311
+ self._require_run(run_id)
312
+ with self._lock(run_id):
313
+ atomic_write_json(
314
+ self._heartbeat_path(run_id),
315
+ {_HEARTBEAT_PHASE_KEY: phase, _HEARTBEAT_AT_KEY: _now()},
316
+ )
317
+ self._write_projection(run_id)
318
+
319
+ # -- result -------------------------------------------------------------
320
+
321
+ def result_path(self, run_id: str) -> Path:
322
+ """Return UpliftReport path."""
323
+ return self._run_dir(run_id) / _REPORT_DIRECTORY_NAME / _RESULT_FILE_NAME
324
+
325
+ def write_result(self, run_id: str, report: UpliftReport) -> None:
326
+ """Write immutable result."""
327
+ self._require_run(run_id)
328
+ if report.run_id != run_id:
329
+ raise ValidationError(
330
+ f"report belongs to run {report.run_id}, not to {run_id}",
331
+ details={"run_id": run_id, "report_run_id": report.run_id},
332
+ )
333
+
334
+ path = self.result_path(run_id)
335
+ with self._lock(run_id):
336
+ self._require_open_locked(run_id, "record a result")
337
+ # A report the log cannot announce would sit in the run directory
338
+ # unmentioned and block the write that could be announced, so the
339
+ # event is settled before the file exists.
340
+ event = self._prepare_event(
341
+ run_id,
342
+ phase=None,
343
+ kind=RESULT_WRITTEN,
344
+ details={DETAIL_RESULT_DIGEST: digest_object(report)},
345
+ )
346
+ ensure_private_directory(path.parent)
347
+ self._write_immutable(path, report)
348
+ self._commit_event(run_id, event)
349
+
350
+ def get_result(self, run_id: str) -> UpliftReport:
351
+ """Load result."""
352
+ self._require_run(run_id)
353
+ path = self.result_path(run_id)
354
+ try:
355
+ raw = path.read_bytes()
356
+ except FileNotFoundError as error:
357
+ raise NotFoundError(
358
+ f"run {run_id} has not produced a result",
359
+ details={"run_id": run_id},
360
+ ) from error
361
+
362
+ try:
363
+ return UpliftReport.model_validate_json(raw)
364
+ except PydanticValidationError as error:
365
+ raise ValidationError(
366
+ f"run result is not a valid report: {path} "
367
+ f"({error.errors()[0]['msg']})",
368
+ details={"run_id": run_id, "path": str(path)},
369
+ ) from error
370
+
371
+ def worker_log_path(self, run_id: str) -> Path:
372
+ """Return log path."""
373
+ return self._run_dir(run_id) / _WORKER_LOG_FILE_NAME
374
+
375
+ # -- internals ----------------------------------------------------------
376
+
377
+ def _append_locked(
378
+ self,
379
+ run_id: str,
380
+ *,
381
+ phase: RunPhase | None,
382
+ kind: str,
383
+ details: dict[str, JsonValue] | None = None,
384
+ ) -> RunState:
385
+ """Append one event and refresh the projection. The lock is held."""
386
+ event = self._prepare_event(run_id, phase=phase, kind=kind, details=details)
387
+ return self._commit_event(run_id, event)
388
+
389
+ def _prepare_event(
390
+ self,
391
+ run_id: str,
392
+ *,
393
+ phase: RunPhase | None,
394
+ kind: str,
395
+ details: dict[str, JsonValue] | None = None,
396
+ ) -> RunEvent:
397
+ """Build the next event, or raise if the machine would refuse it.
398
+
399
+ Separate from committing it so that a method which also writes a file —
400
+ the pid, the report — can find out that the run cannot record what it is
401
+ about to do before it does it, and leave nothing behind.
402
+
403
+ ``phase`` of ``None`` means "whatever phase the run is already in",
404
+ which is how a store-level fact such as the worker's process id is
405
+ recorded without pretending to advance the run. The lock is held.
406
+ """
407
+ events = read_events(self._events_path(run_id))
408
+ current = reduce_events(events)
409
+
410
+ try:
411
+ event = RunEvent(
412
+ sequence=next_sequence(events),
413
+ timestamp=_now(),
414
+ run_id=run_id,
415
+ previous_phase=current.phase,
416
+ phase=current.phase if phase is None else phase,
417
+ kind=kind,
418
+ details=dict(details or {}),
419
+ )
420
+ except PydanticValidationError as error:
421
+ raise ValidationError(
422
+ f"run event is not valid ({error.errors()[0]['msg']})",
423
+ code="run_event_kind_invalid",
424
+ details={"run_id": run_id, "kind": kind},
425
+ ) from error
426
+
427
+ # Projected against the current state here rather than trusted later, so
428
+ # an event the machine would refuse never reaches the log.
429
+ apply_event(current, event)
430
+ return event
431
+
432
+ def _commit_event(self, run_id: str, event: RunEvent) -> RunState:
433
+ """Append a prepared event and recompute the projection.
434
+
435
+ The projection comes from the log rather than from the event in hand,
436
+ because the log is what a later reader will have. The lock is held.
437
+ """
438
+ append_event(self._events_path(run_id), event)
439
+ return self._write_projection(run_id)
440
+
441
+ def _write_projection(self, run_id: str) -> RunState:
442
+ """Recompute the projection from the log and replace ``state.json``."""
443
+ state = self._with_heartbeat(
444
+ run_id, reduce_events(read_events(self._events_path(run_id)))
445
+ )
446
+ atomic_write_json(self._state_path(run_id), state)
447
+ return state
448
+
449
+ def _with_heartbeat(self, run_id: str, state: RunState) -> RunState:
450
+ """Merge in the one fact the log cannot rebuild."""
451
+ state.heartbeat_at = self._read_heartbeat(run_id)
452
+ return state
453
+
454
+ def _read_heartbeat(self, run_id: str) -> datetime | None:
455
+ """Return the worker's last sign of life, if it has given one."""
456
+ path = self._heartbeat_path(run_id)
457
+ if not path.exists():
458
+ return None
459
+
460
+ document = read_json(path)
461
+ raw = document.get(_HEARTBEAT_AT_KEY) if isinstance(document, dict) else None
462
+ if not isinstance(raw, str):
463
+ raise ValidationError(
464
+ f"run heartbeat does not record when it was written: {path}",
465
+ details={"run_id": run_id, "path": str(path)},
466
+ )
467
+
468
+ try:
469
+ moment = datetime.fromisoformat(raw)
470
+ except ValueError as error:
471
+ raise ValidationError(
472
+ f"run heartbeat time is not a timestamp: {path}",
473
+ details={"run_id": run_id, "path": str(path)},
474
+ ) from error
475
+ if moment.tzinfo is None:
476
+ raise ValidationError(
477
+ f"run heartbeat time names no time zone: {path}",
478
+ details={"run_id": run_id, "path": str(path)},
479
+ )
480
+ return moment
481
+
482
+ def _write_immutable(self, path: Path, value: object) -> None:
483
+ """Create a write-once file holding one object's canonical bytes."""
484
+ # Serialized before the file is created: a value that cannot be
485
+ # canonicalized must not leave an empty file that blocks the retry.
486
+ data = canonical_json_bytes(value)
487
+ with open_exclusive(path, _FILE_MODE) as handle:
488
+ handle.write(data)
489
+ handle.flush()
490
+ os.fsync(handle.fileno())
491
+ fsync_directory(path.parent)
492
+
493
+ def _require_open_locked(self, run_id: str, doing: str) -> RunState:
494
+ """Return the current state, refusing a run that has already ended.
495
+
496
+ Checked before anything is written, so a run that cannot record what
497
+ happened is not left holding a file it never announced. The lock is
498
+ held.
499
+ """
500
+ state = reduce_events(read_events(self._events_path(run_id)))
501
+ if is_terminal(state.phase):
502
+ raise RunError(
503
+ f"run has already ended in {state.phase.value}; it cannot {doing}",
504
+ code="run_transition_invalid",
505
+ details={"run_id": run_id, "phase": state.phase.value},
506
+ )
507
+ return state
508
+
509
+ def _require_run(self, run_id: str) -> None:
510
+ """Raise unless the run exists, which its request is the proof of."""
511
+ if not self._request_path(run_id).exists():
512
+ raise NotFoundError(
513
+ f"no such run: {run_id}",
514
+ code="run_not_found",
515
+ details={"run_id": run_id},
516
+ )
517
+
518
+ @contextmanager
519
+ def _lock(self, run_id: str) -> Iterator[None]:
520
+ """Hold the per-run lock, or report the wait as a conflict."""
521
+ lock = FileLock(
522
+ self._run_dir(run_id) / _LOCK_FILE_NAME,
523
+ timeout=LOCK_TIMEOUT_SECONDS,
524
+ mode=_FILE_MODE,
525
+ )
526
+ try:
527
+ lock.acquire()
528
+ except Timeout as error:
529
+ raise ConflictError(
530
+ f"another process is holding the lock on run {run_id}",
531
+ code="run_lock_timeout",
532
+ details={"run_id": run_id, "waited_seconds": LOCK_TIMEOUT_SECONDS},
533
+ ) from error
534
+ try:
535
+ yield
536
+ finally:
537
+ lock.release()
538
+
539
+ def _run_dir(self, run_id: str) -> Path:
540
+ return self._paths.run_dir(run_id)
541
+
542
+ def _request_path(self, run_id: str) -> Path:
543
+ return self._run_dir(run_id) / _REQUEST_FILE_NAME
544
+
545
+ def _events_path(self, run_id: str) -> Path:
546
+ return self._run_dir(run_id) / _EVENTS_FILE_NAME
547
+
548
+ def _state_path(self, run_id: str) -> Path:
549
+ return self._run_dir(run_id) / _STATE_FILE_NAME
550
+
551
+ def _heartbeat_path(self, run_id: str) -> Path:
552
+ return self._run_dir(run_id) / _HEARTBEAT_FILE_NAME
553
+
554
+ def _pid_path(self, run_id: str) -> Path:
555
+ return self._run_dir(run_id) / _PID_FILE_NAME