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.
Files changed (63) hide show
  1. specromancy_engine/__init__.py +32 -0
  2. specromancy_engine/__main__.py +8 -0
  3. specromancy_engine/_adapters/__init__.py +1 -0
  4. specromancy_engine/_adapters/adapter_contracts.py +81 -0
  5. specromancy_engine/_adapters/adapter_ownership.py +302 -0
  6. specromancy_engine/_adapters/adapter_rendering.py +218 -0
  7. specromancy_engine/_adapters/adapter_sources.py +140 -0
  8. specromancy_engine/_cli/__init__.py +1 -0
  9. specromancy_engine/_cli/cli_commands.py +136 -0
  10. specromancy_engine/_cli/pipeline_selection.py +28 -0
  11. specromancy_engine/_config/__init__.py +1 -0
  12. specromancy_engine/_config/config_errors.py +57 -0
  13. specromancy_engine/_config/config_fields.py +228 -0
  14. specromancy_engine/_config/config_loader.py +246 -0
  15. specromancy_engine/_config/config_models.py +97 -0
  16. specromancy_engine/_config/config_phase_parser.py +497 -0
  17. specromancy_engine/_config/config_serialization.py +76 -0
  18. specromancy_engine/_engine/__init__.py +1 -0
  19. specromancy_engine/_engine/approval_service.py +278 -0
  20. specromancy_engine/_engine/approvals.py +248 -0
  21. specromancy_engine/_engine/command_decisions.py +81 -0
  22. specromancy_engine/_engine/engine_errors.py +22 -0
  23. specromancy_engine/_engine/provenance.py +58 -0
  24. specromancy_engine/_engine/responses.py +129 -0
  25. specromancy_engine/_engine/validation_service.py +112 -0
  26. specromancy_engine/_runs/__init__.py +1 -0
  27. specromancy_engine/_runs/run_errors.py +51 -0
  28. specromancy_engine/_runs/run_identity.py +66 -0
  29. specromancy_engine/_runs/run_persistence.py +303 -0
  30. specromancy_engine/_runs/run_records.py +159 -0
  31. specromancy_engine/_runs/run_validation.py +241 -0
  32. specromancy_engine/_runs/visit_preparation.py +61 -0
  33. specromancy_engine/_runs/visit_transitions.py +311 -0
  34. specromancy_engine/_version.py +18 -0
  35. specromancy_engine/actions.py +118 -0
  36. specromancy_engine/adapters.py +97 -0
  37. specromancy_engine/artifacts.py +303 -0
  38. specromancy_engine/cli.py +338 -0
  39. specromancy_engine/commands.py +140 -0
  40. specromancy_engine/config.py +63 -0
  41. specromancy_engine/contracts.py +20 -0
  42. specromancy_engine/engine.py +423 -0
  43. specromancy_engine/errors.py +42 -0
  44. specromancy_engine/exit_codes.py +53 -0
  45. specromancy_engine/git.py +264 -0
  46. specromancy_engine/graph.py +321 -0
  47. specromancy_engine/hashing.py +95 -0
  48. specromancy_engine/locking.py +107 -0
  49. specromancy_engine/registry.py +134 -0
  50. specromancy_engine/run_store.py +643 -0
  51. specromancy_engine/schema_validation.py +178 -0
  52. specromancy_engine/schemas/event.schema.json +34 -0
  53. specromancy_engine/schemas/pipeline.schema.json +154 -0
  54. specromancy_engine/schemas/pipelines.schema.json +38 -0
  55. specromancy_engine/schemas/run.schema.json +152 -0
  56. specromancy_engine/status.py +106 -0
  57. specromancy_engine/validation.py +241 -0
  58. specromancy_engine-0.1.3.dist-info/METADATA +122 -0
  59. specromancy_engine-0.1.3.dist-info/RECORD +63 -0
  60. specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
  61. specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
  62. specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
  63. 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