specromancy-engine 0.1.3__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.
- specromancy_engine/__init__.py +32 -0
- specromancy_engine/__main__.py +8 -0
- specromancy_engine/_adapters/__init__.py +1 -0
- specromancy_engine/_adapters/adapter_contracts.py +81 -0
- specromancy_engine/_adapters/adapter_ownership.py +302 -0
- specromancy_engine/_adapters/adapter_rendering.py +218 -0
- specromancy_engine/_adapters/adapter_sources.py +140 -0
- specromancy_engine/_cli/__init__.py +1 -0
- specromancy_engine/_cli/cli_commands.py +136 -0
- specromancy_engine/_cli/pipeline_selection.py +28 -0
- specromancy_engine/_config/__init__.py +1 -0
- specromancy_engine/_config/config_errors.py +57 -0
- specromancy_engine/_config/config_fields.py +228 -0
- specromancy_engine/_config/config_loader.py +246 -0
- specromancy_engine/_config/config_models.py +97 -0
- specromancy_engine/_config/config_phase_parser.py +497 -0
- specromancy_engine/_config/config_serialization.py +76 -0
- specromancy_engine/_engine/__init__.py +1 -0
- specromancy_engine/_engine/approval_service.py +278 -0
- specromancy_engine/_engine/approvals.py +248 -0
- specromancy_engine/_engine/command_decisions.py +81 -0
- specromancy_engine/_engine/engine_errors.py +22 -0
- specromancy_engine/_engine/provenance.py +58 -0
- specromancy_engine/_engine/responses.py +129 -0
- specromancy_engine/_engine/validation_service.py +112 -0
- specromancy_engine/_runs/__init__.py +1 -0
- specromancy_engine/_runs/run_errors.py +51 -0
- specromancy_engine/_runs/run_identity.py +66 -0
- specromancy_engine/_runs/run_persistence.py +303 -0
- specromancy_engine/_runs/run_records.py +159 -0
- specromancy_engine/_runs/run_validation.py +241 -0
- specromancy_engine/_runs/visit_preparation.py +61 -0
- specromancy_engine/_runs/visit_transitions.py +311 -0
- specromancy_engine/_version.py +18 -0
- specromancy_engine/actions.py +118 -0
- specromancy_engine/adapters.py +97 -0
- specromancy_engine/artifacts.py +303 -0
- specromancy_engine/cli.py +338 -0
- specromancy_engine/commands.py +140 -0
- specromancy_engine/config.py +63 -0
- specromancy_engine/contracts.py +20 -0
- specromancy_engine/engine.py +423 -0
- specromancy_engine/errors.py +42 -0
- specromancy_engine/exit_codes.py +53 -0
- specromancy_engine/git.py +264 -0
- specromancy_engine/graph.py +321 -0
- specromancy_engine/hashing.py +95 -0
- specromancy_engine/locking.py +107 -0
- specromancy_engine/registry.py +134 -0
- specromancy_engine/run_store.py +643 -0
- specromancy_engine/schema_validation.py +178 -0
- specromancy_engine/schemas/event.schema.json +34 -0
- specromancy_engine/schemas/pipeline.schema.json +154 -0
- specromancy_engine/schemas/pipelines.schema.json +38 -0
- specromancy_engine/schemas/run.schema.json +152 -0
- specromancy_engine/status.py +106 -0
- specromancy_engine/validation.py +241 -0
- specromancy_engine-0.1.3.dist-info/METADATA +122 -0
- specromancy_engine-0.1.3.dist-info/RECORD +63 -0
- specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
- specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
- specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
- specromancy_engine-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""Recoverable, disk-backed run manifests and append-only audit events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .artifacts import (
|
|
14
|
+
ArtifactError,
|
|
15
|
+
artifact_record,
|
|
16
|
+
render_output_path,
|
|
17
|
+
resolve_input_reference,
|
|
18
|
+
verify_manifest_artifacts,
|
|
19
|
+
write_artifact_atomic,
|
|
20
|
+
)
|
|
21
|
+
from ._config.config_models import PipelineConfig
|
|
22
|
+
from .exit_codes import ExitCode
|
|
23
|
+
from .hashing import (
|
|
24
|
+
relative_path,
|
|
25
|
+
resolve_relative_path,
|
|
26
|
+
sha256_file,
|
|
27
|
+
)
|
|
28
|
+
from .locking import RunLock
|
|
29
|
+
from ._runs.run_persistence import RunPersistence
|
|
30
|
+
from ._engine import approvals
|
|
31
|
+
from ._runs import run_validation, visit_preparation, visit_transitions
|
|
32
|
+
from ._runs.run_errors import RunCorruptionError, RunNotFoundError, RunStoreError
|
|
33
|
+
from ._runs.run_identity import (
|
|
34
|
+
RUN_ID_PATTERN, format_timestamp, generate_run_id, is_valid_run_id,
|
|
35
|
+
utc_now, validate_run_id,
|
|
36
|
+
)
|
|
37
|
+
from ._runs.run_records import (
|
|
38
|
+
EVENT_SCHEMA_VERSION, EVENT_TYPE_PATTERN, RUN_SCHEMA_VERSION,
|
|
39
|
+
RUN_STATUSES, VISIT_STATUSES, EventRecord, RunRecord, VisitRecord, visit_by_number,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RunStore:
|
|
44
|
+
"""Own run directories and make each manifest mutation durable and audited."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
repository_root: str | os.PathLike[str],
|
|
49
|
+
*,
|
|
50
|
+
clock: Callable[[], datetime] = utc_now,
|
|
51
|
+
random_source: Callable[..., bytes | str] = secrets.token_bytes,
|
|
52
|
+
fault_injector: Callable[[str], None] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
root = Path(repository_root).expanduser().resolve(strict=True)
|
|
55
|
+
if not root.is_dir():
|
|
56
|
+
raise ValueError(f"repository root is not a directory: {root}")
|
|
57
|
+
self.repository_root = root
|
|
58
|
+
self.runs_root = root / ".specromancy" / "runs"
|
|
59
|
+
self._clock = clock
|
|
60
|
+
self._random_source = random_source
|
|
61
|
+
self._fault_injector = fault_injector
|
|
62
|
+
self._persistence = RunPersistence(
|
|
63
|
+
clock=lambda: self._clock(), timestamp=self._timestamp, fault=self._fault
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def new_run_id(self) -> str:
|
|
67
|
+
return generate_run_id(clock=self._clock, random_source=self._random_source)
|
|
68
|
+
|
|
69
|
+
def run_directory(self, run_id: str, *, must_exist: bool = True) -> Path:
|
|
70
|
+
validate_run_id(run_id)
|
|
71
|
+
candidate = self.runs_root / run_id
|
|
72
|
+
if must_exist and not candidate.is_dir():
|
|
73
|
+
raise RunNotFoundError(run_id)
|
|
74
|
+
if candidate.exists():
|
|
75
|
+
try:
|
|
76
|
+
resolved = candidate.resolve(strict=True)
|
|
77
|
+
runs_root = self.runs_root.resolve(strict=True)
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise RunCorruptionError(
|
|
80
|
+
"run directory cannot be resolved",
|
|
81
|
+
run_id=run_id,
|
|
82
|
+
details={"path": str(candidate)},
|
|
83
|
+
) from exc
|
|
84
|
+
if not resolved.is_relative_to(runs_root) or resolved != candidate.absolute():
|
|
85
|
+
raise RunCorruptionError(
|
|
86
|
+
"run directory is a symlink or escapes run storage",
|
|
87
|
+
run_id=run_id,
|
|
88
|
+
details={"path": str(candidate)},
|
|
89
|
+
)
|
|
90
|
+
return candidate
|
|
91
|
+
|
|
92
|
+
def lock(self, run_id: str) -> RunLock:
|
|
93
|
+
directory = self.run_directory(run_id)
|
|
94
|
+
return self._persistence.lock(directory)
|
|
95
|
+
|
|
96
|
+
def create(
|
|
97
|
+
self,
|
|
98
|
+
pipeline: PipelineConfig,
|
|
99
|
+
request: str,
|
|
100
|
+
*,
|
|
101
|
+
run_id: str | None = None,
|
|
102
|
+
git_base: Any = None,
|
|
103
|
+
git_head: Any = None,
|
|
104
|
+
) -> RunRecord:
|
|
105
|
+
"""Create a run, its immutable request, manifest, and first event."""
|
|
106
|
+
|
|
107
|
+
if pipeline.repository_root.resolve() != self.repository_root:
|
|
108
|
+
raise RunStoreError(
|
|
109
|
+
"pipeline and run store use different repository roots",
|
|
110
|
+
diagnostic_code="repository-root-mismatch",
|
|
111
|
+
details={
|
|
112
|
+
"pipeline_root": str(pipeline.repository_root),
|
|
113
|
+
"store_root": str(self.repository_root),
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
chosen_id = self.new_run_id() if run_id is None else validate_run_id(run_id)
|
|
117
|
+
self._prepare_runs_root()
|
|
118
|
+
directory = self.runs_root / chosen_id
|
|
119
|
+
try:
|
|
120
|
+
directory.mkdir(mode=0o700)
|
|
121
|
+
except FileExistsError as exc:
|
|
122
|
+
raise RunStoreError(
|
|
123
|
+
f"run already exists: {chosen_id}",
|
|
124
|
+
diagnostic_code="run-already-exists",
|
|
125
|
+
details={"run_id": chosen_id},
|
|
126
|
+
) from exc
|
|
127
|
+
|
|
128
|
+
with self._persistence.lock(directory):
|
|
129
|
+
(directory / "artifacts").mkdir(mode=0o700)
|
|
130
|
+
(directory / "commands").mkdir(mode=0o700)
|
|
131
|
+
(directory / "events.jsonl").touch(mode=0o600)
|
|
132
|
+
request_path = "artifacts/000-request.md"
|
|
133
|
+
request_content = request if request.endswith("\n") else request + "\n"
|
|
134
|
+
request_hash = write_artifact_atomic(
|
|
135
|
+
directory, request_path, request_content, replace=False
|
|
136
|
+
)
|
|
137
|
+
timestamp = self._timestamp()
|
|
138
|
+
manifest: RunRecord = {
|
|
139
|
+
"schema_version": RUN_SCHEMA_VERSION,
|
|
140
|
+
"revision": 1,
|
|
141
|
+
"run_id": chosen_id,
|
|
142
|
+
"pipeline": {
|
|
143
|
+
"id": pipeline.id,
|
|
144
|
+
"version": pipeline.version,
|
|
145
|
+
"path": relative_path(pipeline.path, self.repository_root),
|
|
146
|
+
"sha256": pipeline.config_hash,
|
|
147
|
+
},
|
|
148
|
+
"status": "awaiting-agent",
|
|
149
|
+
"current_visit": None,
|
|
150
|
+
"request": {"path": request_path, "sha256": request_hash},
|
|
151
|
+
"git": {"base": git_base, "head": git_head},
|
|
152
|
+
"created_at": timestamp,
|
|
153
|
+
"updated_at": timestamp,
|
|
154
|
+
"visits": [],
|
|
155
|
+
"approvals": [],
|
|
156
|
+
"terminal_result": None,
|
|
157
|
+
"block_reason": None,
|
|
158
|
+
}
|
|
159
|
+
self._persistence.commit_locked(
|
|
160
|
+
directory, manifest, [], "run-created", None, {"pipeline_id": pipeline.id}
|
|
161
|
+
)
|
|
162
|
+
return copy.deepcopy(manifest)
|
|
163
|
+
|
|
164
|
+
create_run = create
|
|
165
|
+
|
|
166
|
+
def load(
|
|
167
|
+
self,
|
|
168
|
+
run_id: str,
|
|
169
|
+
*,
|
|
170
|
+
recover: bool = True,
|
|
171
|
+
verify_artifacts: bool = True,
|
|
172
|
+
) -> RunRecord:
|
|
173
|
+
"""Reconstruct current state from disk, optionally repairing an event gap."""
|
|
174
|
+
|
|
175
|
+
directory = self.run_directory(run_id)
|
|
176
|
+
with self._persistence.locked(directory, run_id, recover=recover) as (
|
|
177
|
+
manifest, _events
|
|
178
|
+
):
|
|
179
|
+
if verify_artifacts:
|
|
180
|
+
try:
|
|
181
|
+
verify_manifest_artifacts(manifest, directory)
|
|
182
|
+
except ArtifactError:
|
|
183
|
+
raise
|
|
184
|
+
return copy.deepcopy(manifest)
|
|
185
|
+
|
|
186
|
+
load_run = load
|
|
187
|
+
|
|
188
|
+
def read_events(self, run_id: str, *, recover: bool = True) -> list[EventRecord]:
|
|
189
|
+
directory = self.run_directory(run_id)
|
|
190
|
+
with self._persistence.locked(directory, run_id, recover=recover):
|
|
191
|
+
return copy.deepcopy(self._persistence._read_events(directory, run_id))
|
|
192
|
+
|
|
193
|
+
def start_visit(
|
|
194
|
+
self,
|
|
195
|
+
run_id: str,
|
|
196
|
+
pipeline: PipelineConfig,
|
|
197
|
+
phase_id: str,
|
|
198
|
+
*,
|
|
199
|
+
mutation_baseline: Any = None,
|
|
200
|
+
deviations: list[Any] | None = None,
|
|
201
|
+
) -> VisitRecord:
|
|
202
|
+
"""Resolve visit inputs now and persist their literal immutable records."""
|
|
203
|
+
|
|
204
|
+
directory = self.run_directory(run_id)
|
|
205
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
206
|
+
self._require_pipeline(manifest, pipeline)
|
|
207
|
+
visit = self._new_visit(
|
|
208
|
+
directory,
|
|
209
|
+
manifest,
|
|
210
|
+
pipeline,
|
|
211
|
+
phase_id,
|
|
212
|
+
status="active",
|
|
213
|
+
mutation_baseline=mutation_baseline,
|
|
214
|
+
deviations=deviations,
|
|
215
|
+
)
|
|
216
|
+
self._apply_decision_locked(
|
|
217
|
+
directory, manifest, events, visit_transitions.append_visit(manifest, visit)
|
|
218
|
+
)
|
|
219
|
+
return copy.deepcopy(visit)
|
|
220
|
+
|
|
221
|
+
create_visit = start_visit
|
|
222
|
+
|
|
223
|
+
def prepare_visit(
|
|
224
|
+
self,
|
|
225
|
+
run_id: str,
|
|
226
|
+
pipeline: PipelineConfig,
|
|
227
|
+
phase_id: str,
|
|
228
|
+
*,
|
|
229
|
+
deviations: list[Any] | None = None,
|
|
230
|
+
) -> VisitRecord:
|
|
231
|
+
"""Create the next pending visit and resolve its immutable inputs."""
|
|
232
|
+
|
|
233
|
+
directory = self.run_directory(run_id)
|
|
234
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
235
|
+
self._require_pipeline(manifest, pipeline)
|
|
236
|
+
visit = self._new_visit(
|
|
237
|
+
directory,
|
|
238
|
+
manifest,
|
|
239
|
+
pipeline,
|
|
240
|
+
phase_id,
|
|
241
|
+
status="pending",
|
|
242
|
+
mutation_baseline=None,
|
|
243
|
+
deviations=deviations,
|
|
244
|
+
)
|
|
245
|
+
self._apply_decision_locked(
|
|
246
|
+
directory, manifest, events, visit_transitions.append_visit(manifest, visit)
|
|
247
|
+
)
|
|
248
|
+
return copy.deepcopy(visit)
|
|
249
|
+
|
|
250
|
+
def activate_visit(
|
|
251
|
+
self,
|
|
252
|
+
run_id: str,
|
|
253
|
+
pipeline: PipelineConfig,
|
|
254
|
+
visit_number: int,
|
|
255
|
+
*,
|
|
256
|
+
mutation_baseline: Any = None,
|
|
257
|
+
) -> VisitRecord:
|
|
258
|
+
"""Activate a pending visit, returning an active visit unchanged on retry."""
|
|
259
|
+
|
|
260
|
+
directory = self.run_directory(run_id)
|
|
261
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
262
|
+
self._require_pipeline(manifest, pipeline)
|
|
263
|
+
needed = visit_transitions.activation_needed(manifest, visit_number)
|
|
264
|
+
decision = visit_transitions.activate_visit(
|
|
265
|
+
manifest, visit_number, mutation_baseline=mutation_baseline,
|
|
266
|
+
started_at=self._timestamp() if needed else None,
|
|
267
|
+
)
|
|
268
|
+
updated = self._apply_decision_locked(directory, manifest, events, decision)
|
|
269
|
+
return copy.deepcopy(self._visit(updated, visit_number))
|
|
270
|
+
|
|
271
|
+
def write_visit_output(
|
|
272
|
+
self, run_id: str, visit_number: int, content: str | bytes
|
|
273
|
+
) -> str:
|
|
274
|
+
"""Write an active visit output; completed outputs are immutable."""
|
|
275
|
+
|
|
276
|
+
directory = self.run_directory(run_id)
|
|
277
|
+
with self._persistence.locked(directory, run_id) as (manifest, _events):
|
|
278
|
+
visit = self._visit(manifest, visit_number)
|
|
279
|
+
if visit["status"] == "completed":
|
|
280
|
+
raise ArtifactError(
|
|
281
|
+
"completed visit artifacts are immutable",
|
|
282
|
+
diagnostic_code="immutable-artifact",
|
|
283
|
+
details={"visit_number": visit_number},
|
|
284
|
+
)
|
|
285
|
+
return write_artifact_atomic(
|
|
286
|
+
directory,
|
|
287
|
+
visit["output"]["path"],
|
|
288
|
+
content,
|
|
289
|
+
replace=(directory / visit["output"]["path"]).exists(),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def record_validation_attempt(
|
|
293
|
+
self,
|
|
294
|
+
run_id: str,
|
|
295
|
+
visit_number: int,
|
|
296
|
+
*,
|
|
297
|
+
validation_checks: list[Any] | None = None,
|
|
298
|
+
command_results: list[Any] | None = None,
|
|
299
|
+
mutation_result: Any = None,
|
|
300
|
+
diagnostic: Mapping[str, Any] | None = None,
|
|
301
|
+
) -> RunRecord:
|
|
302
|
+
"""Persist resumable validation evidence without completing a visit."""
|
|
303
|
+
|
|
304
|
+
def change(manifest: RunRecord) -> None:
|
|
305
|
+
visit = self._visit(manifest, visit_number)
|
|
306
|
+
if visit["status"] not in {"active", "awaiting-approval"}:
|
|
307
|
+
raise RunStoreError(
|
|
308
|
+
"validation evidence can be recorded only for an active visit",
|
|
309
|
+
diagnostic_code="illegal-visit-status",
|
|
310
|
+
details={"visit_number": visit_number, "status": visit["status"]},
|
|
311
|
+
code=ExitCode.ILLEGAL_TRANSITION,
|
|
312
|
+
)
|
|
313
|
+
visit["validation_checks"] = list(validation_checks or [])
|
|
314
|
+
visit["command_results"] = list(command_results or [])
|
|
315
|
+
visit["mutation_result"] = mutation_result
|
|
316
|
+
|
|
317
|
+
return self.mutate(
|
|
318
|
+
run_id,
|
|
319
|
+
"validation-failed",
|
|
320
|
+
change,
|
|
321
|
+
visit_number=visit_number,
|
|
322
|
+
payload=dict(diagnostic or {}),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def complete_visit(
|
|
326
|
+
self,
|
|
327
|
+
run_id: str,
|
|
328
|
+
visit_number: int,
|
|
329
|
+
*,
|
|
330
|
+
outcome: str | None = None,
|
|
331
|
+
transition_target: str | None = None,
|
|
332
|
+
mutation_result: Any = None,
|
|
333
|
+
validation_checks: list[Any] | None = None,
|
|
334
|
+
command_results: list[Any] | None = None,
|
|
335
|
+
deviations: list[Any] | None = None,
|
|
336
|
+
) -> VisitRecord:
|
|
337
|
+
"""Seal an output hash and mark a visit completed without overwriting it."""
|
|
338
|
+
|
|
339
|
+
directory = self.run_directory(run_id)
|
|
340
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
341
|
+
visit_transitions.require_incomplete(manifest, visit_number)
|
|
342
|
+
existing = self._visit(manifest, visit_number)
|
|
343
|
+
output = artifact_record(directory, existing["output"]["path"])
|
|
344
|
+
sealed = visit_transitions.seal_visit(
|
|
345
|
+
manifest, visit_number, output_sha256=output["sha256"],
|
|
346
|
+
outcome=outcome, transition_target=transition_target,
|
|
347
|
+
completed_at=self._timestamp(), mutation_result=mutation_result,
|
|
348
|
+
validation_checks=validation_checks, command_results=command_results,
|
|
349
|
+
deviations=deviations,
|
|
350
|
+
)
|
|
351
|
+
updated = self._apply_decision_locked(
|
|
352
|
+
directory, manifest, events,
|
|
353
|
+
visit_transitions.completion_decision(sealed, visit_number),
|
|
354
|
+
)
|
|
355
|
+
return copy.deepcopy(self._visit(updated, visit_number))
|
|
356
|
+
|
|
357
|
+
def transition_visit(
|
|
358
|
+
self,
|
|
359
|
+
run_id: str,
|
|
360
|
+
pipeline: PipelineConfig,
|
|
361
|
+
visit_number: int,
|
|
362
|
+
*,
|
|
363
|
+
outcome: str,
|
|
364
|
+
transition_target: str | None,
|
|
365
|
+
terminal_result: Any = None,
|
|
366
|
+
validation_checks: list[Any] | None = None,
|
|
367
|
+
command_results: list[Any] | None = None,
|
|
368
|
+
mutation_result: Any = None,
|
|
369
|
+
) -> RunRecord:
|
|
370
|
+
"""Complete a visit and atomically prepare its configured successor."""
|
|
371
|
+
|
|
372
|
+
directory = self.run_directory(run_id)
|
|
373
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
374
|
+
self._require_pipeline(manifest, pipeline)
|
|
375
|
+
if not visit_transitions.transition_needed(
|
|
376
|
+
manifest, visit_number, outcome, transition_target
|
|
377
|
+
):
|
|
378
|
+
return self._apply_decision_locked(
|
|
379
|
+
directory, manifest, events,
|
|
380
|
+
visit_transitions.Decision(copy.deepcopy(manifest)),
|
|
381
|
+
)
|
|
382
|
+
existing = self._visit(manifest, visit_number)
|
|
383
|
+
output = artifact_record(directory, existing["output"]["path"])
|
|
384
|
+
transition = next(
|
|
385
|
+
item
|
|
386
|
+
for item in pipeline.phase(existing["phase_id"]).transitions
|
|
387
|
+
if item.outcome == outcome
|
|
388
|
+
)
|
|
389
|
+
limit_block = self._transition_limit_block(
|
|
390
|
+
manifest, pipeline, existing, outcome, transition_target
|
|
391
|
+
)
|
|
392
|
+
if limit_block is not None:
|
|
393
|
+
decision = visit_transitions.blocked_transition(
|
|
394
|
+
manifest, visit_number, limit_block,
|
|
395
|
+
mutation_result=mutation_result, validation_checks=validation_checks,
|
|
396
|
+
command_results=command_results,
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
sealed = visit_transitions.seal_visit(
|
|
400
|
+
manifest, visit_number, output_sha256=output["sha256"],
|
|
401
|
+
outcome=outcome, transition_target=transition_target,
|
|
402
|
+
completed_at=self._timestamp(), mutation_result=mutation_result,
|
|
403
|
+
validation_checks=validation_checks, command_results=command_results,
|
|
404
|
+
)
|
|
405
|
+
# Resolve latest/visit inputs against the sealed proposal while
|
|
406
|
+
# retaining the lock. Completion and successor commit only once.
|
|
407
|
+
next_visit = None
|
|
408
|
+
if transition_target is not None:
|
|
409
|
+
next_visit = self._new_visit(
|
|
410
|
+
directory, sealed, pipeline, transition_target,
|
|
411
|
+
status="pending", mutation_baseline=None,
|
|
412
|
+
)
|
|
413
|
+
decision = visit_transitions.finish_transition(
|
|
414
|
+
sealed, visit_number, next_visit=next_visit,
|
|
415
|
+
pause=transition.pause, terminal_result=terminal_result,
|
|
416
|
+
)
|
|
417
|
+
return self._apply_decision_locked(directory, manifest, events, decision)
|
|
418
|
+
|
|
419
|
+
def resume_paused(self, run_id: str, pipeline: PipelineConfig) -> RunRecord:
|
|
420
|
+
"""Release a durable checkpoint without starting its pending visit."""
|
|
421
|
+
|
|
422
|
+
directory = self.run_directory(run_id)
|
|
423
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
424
|
+
self._require_pipeline(manifest, pipeline)
|
|
425
|
+
return self._apply_decision_locked(
|
|
426
|
+
directory, manifest, events, visit_transitions.resume_paused(manifest)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def decide_approval(
|
|
430
|
+
self, run_id: str,
|
|
431
|
+
decide: Callable[[RunRecord], approvals.ApprovalDecision],
|
|
432
|
+
) -> approvals.ApprovalDecision:
|
|
433
|
+
"""Resolve a bound approval against current state under one run lock."""
|
|
434
|
+
|
|
435
|
+
directory = self.run_directory(run_id)
|
|
436
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
437
|
+
decision = decide(copy.deepcopy(manifest))
|
|
438
|
+
decision.manifest = self._apply_decision_locked(
|
|
439
|
+
directory, manifest, events, decision,
|
|
440
|
+
)
|
|
441
|
+
return copy.deepcopy(decision)
|
|
442
|
+
|
|
443
|
+
def _apply_decision_locked(
|
|
444
|
+
self, directory: Path, previous: RunRecord, events: list[EventRecord],
|
|
445
|
+
decision: visit_transitions.Decision | approvals.ApprovalDecision,
|
|
446
|
+
) -> RunRecord:
|
|
447
|
+
"""Stamp and commit a proposal under the caller's existing run lock."""
|
|
448
|
+
|
|
449
|
+
updated = decision.manifest
|
|
450
|
+
if decision.event_type is not None:
|
|
451
|
+
self._persistence.advance_revision(updated, previous["revision"])
|
|
452
|
+
self._persistence.commit_locked(
|
|
453
|
+
directory, updated, events, decision.event_type,
|
|
454
|
+
decision.visit_number, decision.payload,
|
|
455
|
+
)
|
|
456
|
+
return copy.deepcopy(updated)
|
|
457
|
+
|
|
458
|
+
def _transition_limit_block(
|
|
459
|
+
self,
|
|
460
|
+
manifest: RunRecord,
|
|
461
|
+
pipeline: PipelineConfig,
|
|
462
|
+
visit: VisitRecord,
|
|
463
|
+
outcome: str,
|
|
464
|
+
transition_target: str | None,
|
|
465
|
+
) -> dict[str, Any] | None:
|
|
466
|
+
return visit_transitions.transition_limit_block(
|
|
467
|
+
manifest, pipeline, visit, outcome, transition_target,
|
|
468
|
+
timestamp=self._timestamp(),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def mutate(
|
|
472
|
+
self,
|
|
473
|
+
run_id: str,
|
|
474
|
+
event_type: str,
|
|
475
|
+
mutator: Callable[[RunRecord], None],
|
|
476
|
+
*,
|
|
477
|
+
visit_number: int | None = None,
|
|
478
|
+
payload: Mapping[str, Any] | None = None,
|
|
479
|
+
) -> RunRecord:
|
|
480
|
+
"""Apply a validated in-memory mutation using the store's commit protocol."""
|
|
481
|
+
|
|
482
|
+
if not EVENT_TYPE_PATTERN.fullmatch(event_type):
|
|
483
|
+
raise ValueError(f"invalid event type: {event_type!r}")
|
|
484
|
+
directory = self.run_directory(run_id)
|
|
485
|
+
with self._persistence.locked(directory, run_id) as (manifest, events):
|
|
486
|
+
updated = copy.deepcopy(manifest)
|
|
487
|
+
mutator(updated)
|
|
488
|
+
self._persistence.advance_revision(updated, manifest["revision"])
|
|
489
|
+
self._persistence.commit_locked(
|
|
490
|
+
directory,
|
|
491
|
+
updated,
|
|
492
|
+
events,
|
|
493
|
+
event_type,
|
|
494
|
+
visit_number,
|
|
495
|
+
dict(payload or {}),
|
|
496
|
+
)
|
|
497
|
+
return copy.deepcopy(updated)
|
|
498
|
+
|
|
499
|
+
update = mutate
|
|
500
|
+
|
|
501
|
+
def block(self, run_id: str, reason: Any) -> RunRecord:
|
|
502
|
+
def change(manifest: RunRecord) -> None:
|
|
503
|
+
manifest["status"] = "blocked"
|
|
504
|
+
manifest["block_reason"] = reason
|
|
505
|
+
|
|
506
|
+
return self.mutate(
|
|
507
|
+
run_id, "run-blocked", change, payload={"reason": reason}
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def complete(self, run_id: str, result: Any) -> RunRecord:
|
|
511
|
+
def change(manifest: RunRecord) -> None:
|
|
512
|
+
manifest["status"] = "completed"
|
|
513
|
+
manifest["terminal_result"] = result
|
|
514
|
+
|
|
515
|
+
return self.mutate(
|
|
516
|
+
run_id, "run-completed", change, payload={"result": result}
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def _new_visit(
|
|
520
|
+
self,
|
|
521
|
+
directory: Path,
|
|
522
|
+
manifest: RunRecord,
|
|
523
|
+
pipeline: PipelineConfig,
|
|
524
|
+
phase_id: str,
|
|
525
|
+
*,
|
|
526
|
+
status: str,
|
|
527
|
+
mutation_baseline: Any,
|
|
528
|
+
deviations: list[Any] | None = None,
|
|
529
|
+
) -> VisitRecord:
|
|
530
|
+
if status not in {"pending", "active"}:
|
|
531
|
+
raise ValueError(f"invalid initial visit status: {status!r}")
|
|
532
|
+
try:
|
|
533
|
+
phase = pipeline.phase(phase_id)
|
|
534
|
+
except KeyError as exc:
|
|
535
|
+
raise RunStoreError(
|
|
536
|
+
f"pipeline has no phase {phase_id!r}",
|
|
537
|
+
diagnostic_code="phase-not-found",
|
|
538
|
+
details={"phase": phase_id},
|
|
539
|
+
code=ExitCode.NOT_FOUND,
|
|
540
|
+
) from exc
|
|
541
|
+
ordinal = len(manifest["visits"]) + 1
|
|
542
|
+
attempt = sum(
|
|
543
|
+
visit["phase_id"] == phase_id for visit in manifest["visits"]
|
|
544
|
+
) + 1
|
|
545
|
+
resources = visit_preparation.collect_resources(
|
|
546
|
+
directory, manifest, pipeline, phase,
|
|
547
|
+
repository_root=self.repository_root, ordinal=ordinal,
|
|
548
|
+
)
|
|
549
|
+
return visit_transitions.new_visit(
|
|
550
|
+
phase_id=phase_id, ordinal=ordinal, attempt=attempt, status=status,
|
|
551
|
+
inputs=resources.inputs, output_path=resources.output_path,
|
|
552
|
+
mutation_policy=phase.mutation, mutation_baseline=mutation_baseline,
|
|
553
|
+
skill=resources.skill, template=resources.template,
|
|
554
|
+
started_at=self._timestamp() if status == "active" else None,
|
|
555
|
+
deviations=deviations,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def _prepare_runs_root(self) -> None:
|
|
559
|
+
self.runs_root.mkdir(parents=True, exist_ok=True)
|
|
560
|
+
resolved = self.runs_root.resolve(strict=True)
|
|
561
|
+
if not resolved.is_relative_to(self.repository_root) or resolved != self.runs_root.absolute():
|
|
562
|
+
raise RunStoreError(
|
|
563
|
+
"run storage is a symlink or escapes the repository",
|
|
564
|
+
diagnostic_code="unsafe-run-storage",
|
|
565
|
+
details={"path": str(self.runs_root)},
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def _timestamp(self) -> str:
|
|
569
|
+
return format_timestamp(self._clock())
|
|
570
|
+
|
|
571
|
+
def _fault(self, point: str) -> None:
|
|
572
|
+
if self._fault_injector is not None:
|
|
573
|
+
self._fault_injector(point)
|
|
574
|
+
|
|
575
|
+
def _require_pipeline(
|
|
576
|
+
self, manifest: RunRecord, pipeline: PipelineConfig
|
|
577
|
+
) -> None:
|
|
578
|
+
expected = manifest["pipeline"]
|
|
579
|
+
actual_path = relative_path(pipeline.path, self.repository_root)
|
|
580
|
+
if (
|
|
581
|
+
expected["id"] != pipeline.id
|
|
582
|
+
or expected["version"] != pipeline.version
|
|
583
|
+
or expected["path"] != actual_path
|
|
584
|
+
or expected["sha256"] != pipeline.config_hash
|
|
585
|
+
):
|
|
586
|
+
raise RunStoreError(
|
|
587
|
+
"pipeline configuration changed after the run was created",
|
|
588
|
+
diagnostic_code="pipeline-hash-mismatch",
|
|
589
|
+
details={
|
|
590
|
+
"expected": expected,
|
|
591
|
+
"actual": {
|
|
592
|
+
"id": pipeline.id,
|
|
593
|
+
"version": pipeline.version,
|
|
594
|
+
"path": actual_path,
|
|
595
|
+
"sha256": pipeline.config_hash,
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
code=ExitCode.INVALID_PIPELINE,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
_visit = staticmethod(visit_by_number)
|
|
602
|
+
|
|
603
|
+
def _validate_manifest(self, value: dict[str, Any], run_id: str) -> None:
|
|
604
|
+
run_validation.validate_manifest(value, run_id)
|
|
605
|
+
|
|
606
|
+
def _validate_visit(
|
|
607
|
+
self, visit: dict[str, Any], run_id: str, expected_ordinal: int
|
|
608
|
+
) -> None:
|
|
609
|
+
run_validation.validate_visit(visit, run_id, expected_ordinal)
|
|
610
|
+
|
|
611
|
+
def _validate_artifact_record(
|
|
612
|
+
self,
|
|
613
|
+
value: Any,
|
|
614
|
+
run_id: str,
|
|
615
|
+
*,
|
|
616
|
+
hash_required: bool,
|
|
617
|
+
reference: bool = False,
|
|
618
|
+
) -> None:
|
|
619
|
+
run_validation.validate_artifact_record(
|
|
620
|
+
value, run_id, hash_required=hash_required, reference=reference
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def _validate_provenance(self, value: dict[str, Any], run_id: str) -> None:
|
|
624
|
+
run_validation.validate_provenance(value, run_id)
|
|
625
|
+
|
|
626
|
+
def _validate_event(self, event: dict[str, Any], run_id: str) -> None:
|
|
627
|
+
run_validation.validate_event(event, run_id)
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def _validate_timestamp(value: Any, run_id: str) -> None:
|
|
631
|
+
run_validation.validate_timestamp(value, run_id)
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def _validate_path(value: Any, run_id: str) -> None:
|
|
635
|
+
run_validation.validate_path(value, run_id)
|
|
636
|
+
|
|
637
|
+
@staticmethod
|
|
638
|
+
def _invalid_manifest(run_id: str, message: str) -> None:
|
|
639
|
+
run_validation.invalid_manifest(run_id, message)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
new_run_id = generate_run_id
|
|
643
|
+
CorruptRunError = RunCorruptionError
|