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,129 @@
1
+ """Versioned command envelopes over the existing action and status payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..actions import build_action_packet
8
+ from .approvals import pending_approval
9
+ from .._config.config_models import PipelineConfig
10
+ from ..exit_codes import ExitCode
11
+ from .._runs.run_records import ApprovalRecord, RunRecord, VisitRecord, current_visit
12
+ from ..status import build_status
13
+
14
+
15
+ RESPONSE_SCHEMA_VERSION = 1
16
+
17
+
18
+ def after_transition(
19
+ pipeline: PipelineConfig, manifest: RunRecord, outcome: str
20
+ ) -> dict[str, Any]:
21
+ if manifest["status"] == "blocked":
22
+ return blocked_response(
23
+ pipeline,
24
+ manifest, "run blocked because a configured loop limit was reached"
25
+ )
26
+ if manifest["status"] == "completed":
27
+ return status_response(
28
+ pipeline,
29
+ manifest, f"run completed with outcome {outcome!r}"
30
+ )
31
+ if manifest["status"] == "paused":
32
+ return paused_response(
33
+ pipeline,
34
+ manifest, f"outcome {outcome!r} recorded; run paused at checkpoint"
35
+ )
36
+ visit = current_visit(manifest)
37
+ assert visit is not None
38
+ return action_response(
39
+ pipeline,
40
+ manifest,
41
+ visit,
42
+ f"outcome {outcome!r} recorded; next phase requires agent action",
43
+ )
44
+
45
+
46
+ def response_for_state(
47
+ pipeline: PipelineConfig, manifest: RunRecord, message: str
48
+ ) -> dict[str, Any]:
49
+ visit = current_visit(manifest)
50
+ if manifest["status"] in {"awaiting-agent", "active"} and visit is not None:
51
+ return action_response(pipeline, manifest, visit, message)
52
+ if manifest["status"] == "awaiting-approval":
53
+ approval = pending_approval(
54
+ manifest, visit["ordinal"] if visit is not None else -1
55
+ )
56
+ return approval_response(pipeline, manifest, approval, "approval is required")
57
+ if manifest["status"] == "paused":
58
+ return paused_response(pipeline, manifest, message)
59
+ if manifest["status"] == "blocked":
60
+ return blocked_response(pipeline, manifest, message)
61
+ return status_response(pipeline, manifest, message)
62
+
63
+
64
+ def action_response(
65
+ pipeline: PipelineConfig, manifest: RunRecord, visit: VisitRecord, message: str
66
+ ) -> dict[str, Any]:
67
+ return {
68
+ "schema_version": RESPONSE_SCHEMA_VERSION,
69
+ "kind": "action",
70
+ "code": int(ExitCode.AGENT_ACTION_REQUIRED),
71
+ "message": message,
72
+ "action": build_action_packet(pipeline, manifest, visit),
73
+ }
74
+
75
+
76
+ def approval_response(
77
+ pipeline: PipelineConfig,
78
+ manifest: RunRecord,
79
+ approval: ApprovalRecord | None,
80
+ message: str,
81
+ ) -> dict[str, Any]:
82
+ return {
83
+ "schema_version": RESPONSE_SCHEMA_VERSION,
84
+ "kind": "approval",
85
+ "code": int(ExitCode.APPROVAL_REQUIRED),
86
+ "message": message,
87
+ "approval": approval,
88
+ "status": build_status(manifest, pipeline),
89
+ }
90
+
91
+
92
+ def blocked_response(
93
+ pipeline: PipelineConfig, manifest: RunRecord, message: str
94
+ ) -> dict[str, Any]:
95
+ return {
96
+ "schema_version": RESPONSE_SCHEMA_VERSION,
97
+ "kind": "status",
98
+ "code": int(ExitCode.RUN_BLOCKED),
99
+ "message": message,
100
+ "status": build_status(manifest, pipeline),
101
+ }
102
+
103
+
104
+ def paused_response(
105
+ pipeline: PipelineConfig, manifest: RunRecord, message: str
106
+ ) -> dict[str, Any]:
107
+ return {
108
+ "schema_version": RESPONSE_SCHEMA_VERSION,
109
+ "kind": "status",
110
+ "code": int(ExitCode.RUN_PAUSED),
111
+ "message": message,
112
+ "status": build_status(manifest, pipeline),
113
+ }
114
+
115
+
116
+ def status_response(
117
+ pipeline: PipelineConfig,
118
+ manifest: RunRecord,
119
+ message: str,
120
+ *,
121
+ warnings: list[dict[str, Any]] | None = None,
122
+ ) -> dict[str, Any]:
123
+ return {
124
+ "schema_version": RESPONSE_SCHEMA_VERSION,
125
+ "kind": "status",
126
+ "code": int(ExitCode.SUCCESS),
127
+ "message": message,
128
+ "status": build_status(manifest, pipeline, warnings=warnings),
129
+ }
@@ -0,0 +1,112 @@
1
+ """Collect ordered validation evidence without committing run state.
2
+
3
+ Command logs remain the responsibility of commands.py. Expected failures are
4
+ returned with their evidence so orchestration can persist one attempt before
5
+ raising; unexpected failures (including injected interruptions) propagate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..commands import execute_validation_commands, first_required_failure
16
+ from .._config.config_models import PhaseConfig, PipelineConfig
17
+ from .engine_errors import EngineError
18
+ from ..exit_codes import ExitCode
19
+ from ..git import GitError, capture_repository_snapshot, enforce_mutation_policy
20
+ from .._runs.run_records import VisitRecord
21
+ from ..validation import ValidationFailure, validate_artifact
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ValidationResult:
26
+ checks: list[dict[str, Any]]
27
+ command_results: list[dict[str, Any]]
28
+ mutation_result: dict[str, Any] | None
29
+ failure: EngineError | None = None
30
+ cause: Exception | None = None
31
+
32
+
33
+ def perform_validation(
34
+ pipeline: PipelineConfig,
35
+ run_directory: Path,
36
+ visit: VisitRecord,
37
+ phase: PhaseConfig,
38
+ *,
39
+ command_fault_injector: Callable[[str], None] | None = None,
40
+ ) -> ValidationResult:
41
+ """Validate the artifact, execute commands, then enforce mutation policy.
42
+
43
+ Mutation failure takes precedence over a required command failure, including
44
+ mutations made by that failing command.
45
+ """
46
+
47
+ try:
48
+ check = validate_artifact(
49
+ run_directory,
50
+ visit["output"]["path"],
51
+ phase.validator,
52
+ template_path=phase.output_template_path,
53
+ )
54
+ except ValidationFailure as exc:
55
+ failed = {
56
+ "type": phase.validator.type,
57
+ "status": "failed",
58
+ "error_code": exc.diagnostic_code,
59
+ **exc.details,
60
+ }
61
+ return ValidationResult(
62
+ [failed], [], None,
63
+ EngineError(
64
+ ExitCode.VALIDATION_FAILED, exc.message, exc.diagnostic_code,
65
+ **exc.details,
66
+ ),
67
+ exc,
68
+ )
69
+
70
+ checks = [check]
71
+ command_results = execute_validation_commands(
72
+ phase.validation_commands,
73
+ pipeline.repository_root,
74
+ run_directory,
75
+ visit["ordinal"],
76
+ fault_injector=command_fault_injector,
77
+ )
78
+ command_failure = first_required_failure(command_results)
79
+ try:
80
+ baseline = visit.get("mutation_baseline")
81
+ if not isinstance(baseline, dict):
82
+ raise GitError(
83
+ "missing-mutation-baseline",
84
+ "the visit has no repository mutation baseline",
85
+ )
86
+ current = capture_repository_snapshot(
87
+ pipeline.repository_root,
88
+ allow_non_git=pipeline.allow_non_git,
89
+ )
90
+ mutation_result = enforce_mutation_policy(
91
+ baseline, current, phase.mutation, phase.allowlist
92
+ )
93
+ except GitError as exc:
94
+ return ValidationResult(
95
+ checks, command_results, exc.details.get("mutation_result"),
96
+ EngineError(
97
+ ExitCode.VALIDATION_FAILED, exc.message, exc.diagnostic_code,
98
+ **exc.details,
99
+ ),
100
+ exc,
101
+ )
102
+ if command_failure is not None:
103
+ return ValidationResult(
104
+ checks, command_results, mutation_result,
105
+ EngineError(
106
+ ExitCode.VALIDATION_FAILED,
107
+ "a required validation command failed",
108
+ "validation-command-failed",
109
+ command=command_failure,
110
+ ),
111
+ )
112
+ return ValidationResult(checks, command_results, mutation_result)
@@ -0,0 +1 @@
1
+ """Private runs implementation components; use the public facades."""
@@ -0,0 +1,51 @@
1
+ """Shared run persistence errors; re-exported by run_store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from ..errors import SpecromancyError
9
+ from ..exit_codes import ExitCode
10
+
11
+
12
+ class RunStoreError(SpecromancyError):
13
+ """Base error for expected run-store failures."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ diagnostic_code: str,
20
+ details: Mapping[str, Any] | None = None,
21
+ code: ExitCode = ExitCode.INTERNAL_ERROR,
22
+ ) -> None:
23
+ values = {"error_code": diagnostic_code}
24
+ if details:
25
+ values.update(details)
26
+ super().__init__(code, message, values)
27
+ self.diagnostic_code = diagnostic_code
28
+
29
+
30
+ class RunNotFoundError(RunStoreError):
31
+ def __init__(self, run_id: str) -> None:
32
+ super().__init__(
33
+ f"run not found: {run_id}",
34
+ diagnostic_code="run-not-found",
35
+ details={"run_id": run_id},
36
+ code=ExitCode.NOT_FOUND,
37
+ )
38
+
39
+
40
+ class RunCorruptionError(RunStoreError):
41
+ def __init__(
42
+ self, message: str, *, run_id: str, details: Mapping[str, Any] | None = None
43
+ ) -> None:
44
+ values: dict[str, Any] = {"run_id": run_id}
45
+ if details:
46
+ values.update(details)
47
+ super().__init__(
48
+ message,
49
+ diagnostic_code="corrupt-run",
50
+ details=values,
51
+ )
@@ -0,0 +1,66 @@
1
+ """Path-safe run identities and injectable UTC time/random helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ import secrets
8
+ from collections.abc import Callable
9
+ from datetime import datetime, timezone
10
+
11
+
12
+ RUN_ID_PATTERN = re.compile(r"^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$")
13
+
14
+
15
+ def utc_now() -> datetime:
16
+ return datetime.now(timezone.utc)
17
+
18
+
19
+ def format_timestamp(value: datetime) -> str:
20
+ if value.tzinfo is None:
21
+ value = value.replace(tzinfo=timezone.utc)
22
+ return value.astimezone(timezone.utc).isoformat(timespec="microseconds").replace(
23
+ "+00:00", "Z"
24
+ )
25
+
26
+
27
+ def validate_run_id(run_id: str) -> str:
28
+ """Return a valid path-safe run ID or raise ``ValueError``."""
29
+
30
+ if not isinstance(run_id, str) or not RUN_ID_PATTERN.fullmatch(run_id):
31
+ raise ValueError(f"invalid run ID: {run_id!r}")
32
+ try:
33
+ datetime.strptime(run_id[:16], "%Y%m%dT%H%M%SZ")
34
+ except ValueError as exc:
35
+ raise ValueError(f"invalid run ID timestamp: {run_id!r}") from exc
36
+ return run_id
37
+
38
+
39
+ def is_valid_run_id(run_id: str) -> bool:
40
+ try:
41
+ validate_run_id(run_id)
42
+ except (TypeError, ValueError):
43
+ return False
44
+ return True
45
+
46
+
47
+ def generate_run_id(
48
+ *,
49
+ clock: Callable[[], datetime] = utc_now,
50
+ random_source: Callable[..., bytes | str] = secrets.token_bytes,
51
+ ) -> str:
52
+ """Generate a UTC timestamp plus four cryptographically random bytes."""
53
+
54
+ now = clock()
55
+ if now.tzinfo is None:
56
+ now = now.replace(tzinfo=timezone.utc)
57
+ timestamp = now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
58
+ try:
59
+ inspect.signature(random_source).bind(4)
60
+ except (TypeError, ValueError):
61
+ random_value = random_source()
62
+ else:
63
+ random_value = random_source(4)
64
+ suffix = random_value.hex() if isinstance(random_value, bytes) else random_value
65
+ run_id = f"{timestamp}-{suffix}"
66
+ return validate_run_id(run_id)
@@ -0,0 +1,303 @@
1
+ """Internal manifest/event durability protocol for already authorized run paths.
2
+
3
+ The run store owns path authorization and domain decisions. This component owns
4
+ serialization, event reconciliation, and ordered manifest replacement/event append.
5
+ Locked helpers never acquire a lock; their caller must hold the run lock.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from collections.abc import Callable, Iterator
14
+ from contextlib import contextmanager
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from ..hashing import sha256_json
20
+ from ..locking import RunLock
21
+ from .run_errors import RunCorruptionError
22
+ from .run_records import EVENT_SCHEMA_VERSION, EventRecord, RunRecord
23
+ from . import run_validation
24
+
25
+
26
+ class RunPersistence:
27
+ """Implement schema-version-1 IO without workflow or artifact policies."""
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ clock: Callable[[], datetime],
33
+ timestamp: Callable[[], str],
34
+ fault: Callable[[str], None],
35
+ ) -> None:
36
+ self._clock = clock
37
+ self._timestamp = timestamp
38
+ self._fault = fault
39
+
40
+ def lock(self, directory: Path) -> RunLock:
41
+ """Return a lock for a path already authorized by the run store."""
42
+ return RunLock(directory / ".lock", clock=self._clock)
43
+
44
+ @contextmanager
45
+ def locked(
46
+ self, directory: Path, run_id: str, *, recover: bool = True
47
+ ) -> Iterator[tuple[RunRecord, list[EventRecord]]]:
48
+ """Hold one lock across loading, reconciliation, and the caller's changes.
49
+
50
+ Returning from this context does not commit implicitly. The caller must
51
+ explicitly advance a revision and commit a mutation, so idempotent returns
52
+ and artifact-only writes do not emit extra revisions or events. Creation
53
+ uses lock() separately because no manifest exists yet.
54
+ """
55
+ with self.lock(directory):
56
+ manifest = self._read_manifest(directory, run_id)
57
+ if recover:
58
+ events = self._ensure_event_consistency(directory, manifest)
59
+ else:
60
+ events = self._assert_event_consistency(directory, manifest)
61
+ yield manifest, events
62
+
63
+ def advance_revision(self, manifest: RunRecord, previous_revision: int) -> None:
64
+ """Stamp a mutation at the caller's established clock boundary."""
65
+ manifest["revision"] = previous_revision + 1
66
+ manifest["updated_at"] = self._timestamp()
67
+
68
+ def _read_manifest(self, directory: Path, run_id: str) -> RunRecord:
69
+ path = directory / "run.json"
70
+ try:
71
+ raw = path.read_text(encoding="utf-8")
72
+ value = json.loads(raw)
73
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
74
+ raise RunCorruptionError(
75
+ "run manifest is missing, unreadable, or malformed",
76
+ run_id=run_id,
77
+ details={"path": str(path), "error": str(exc)},
78
+ ) from exc
79
+ if not isinstance(value, dict):
80
+ raise RunCorruptionError(
81
+ "run manifest must be a JSON object", run_id=run_id
82
+ )
83
+ run_validation.validate_manifest(value, run_id)
84
+ return value
85
+
86
+ def _write_manifest_atomic(
87
+ self, directory: Path, manifest: RunRecord
88
+ ) -> None:
89
+ target = directory / "run.json"
90
+ self._fault("before-manifest-temporary-write")
91
+ descriptor, temporary_name = tempfile.mkstemp(
92
+ dir=directory, prefix=".run.json.", suffix=".tmp"
93
+ )
94
+ temporary = Path(temporary_name)
95
+ try:
96
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
97
+ json.dump(
98
+ manifest,
99
+ stream,
100
+ ensure_ascii=False,
101
+ allow_nan=False,
102
+ sort_keys=True,
103
+ indent=2,
104
+ )
105
+ stream.write("\n")
106
+ stream.flush()
107
+ os.fsync(stream.fileno())
108
+ self._fault("after-manifest-temporary-write")
109
+ self._fault("before-manifest-replace")
110
+ os.replace(temporary, target)
111
+ self._fsync_directory(directory)
112
+ self._fault("after-manifest-replace")
113
+ finally:
114
+ temporary.unlink(missing_ok=True)
115
+
116
+ def _append_event(self, directory: Path, event: EventRecord) -> None:
117
+ run_validation.validate_event(event, event["run_id"])
118
+ path = directory / "events.jsonl"
119
+ payload = (
120
+ json.dumps(
121
+ event,
122
+ ensure_ascii=False,
123
+ allow_nan=False,
124
+ sort_keys=True,
125
+ separators=(",", ":"),
126
+ )
127
+ + "\n"
128
+ ).encode("utf-8")
129
+ self._fault("before-event-append")
130
+ try:
131
+ with path.open("ab") as stream:
132
+ stream.write(payload)
133
+ stream.flush()
134
+ os.fsync(stream.fileno())
135
+ except OSError as exc:
136
+ raise RunCorruptionError(
137
+ "could not append the run audit event",
138
+ run_id=event["run_id"],
139
+ details={"path": str(path), "error": str(exc)},
140
+ ) from exc
141
+ self._fault("after-event-append")
142
+
143
+ def _read_events(self, directory: Path, run_id: str) -> list[EventRecord]:
144
+ path = directory / "events.jsonl"
145
+ try:
146
+ lines = path.read_text(encoding="utf-8").splitlines()
147
+ except (OSError, UnicodeError) as exc:
148
+ raise RunCorruptionError(
149
+ "run event log is missing or unreadable",
150
+ run_id=run_id,
151
+ details={"path": str(path), "error": str(exc)},
152
+ ) from exc
153
+ events: list[EventRecord] = []
154
+ for index, line in enumerate(lines, 1):
155
+ if not line:
156
+ raise RunCorruptionError(
157
+ "run event log contains an empty record",
158
+ run_id=run_id,
159
+ details={"line": index},
160
+ )
161
+ try:
162
+ event = json.loads(line)
163
+ except json.JSONDecodeError as exc:
164
+ raise RunCorruptionError(
165
+ "run event log contains malformed JSON",
166
+ run_id=run_id,
167
+ details={"line": index, "error": str(exc)},
168
+ ) from exc
169
+ if not isinstance(event, dict):
170
+ raise RunCorruptionError(
171
+ "run event must be a JSON object",
172
+ run_id=run_id,
173
+ details={"line": index},
174
+ )
175
+ run_validation.validate_event(event, run_id)
176
+ if event["sequence"] != index:
177
+ raise RunCorruptionError(
178
+ "run event sequence is not contiguous",
179
+ run_id=run_id,
180
+ details={"line": index, "sequence": event["sequence"]},
181
+ )
182
+ if event["manifest_revision"] != index:
183
+ raise RunCorruptionError(
184
+ "run event revisions are not contiguous",
185
+ run_id=run_id,
186
+ details={
187
+ "line": index,
188
+ "manifest_revision": event["manifest_revision"],
189
+ },
190
+ )
191
+ events.append(event)
192
+ return events
193
+
194
+ def _ensure_event_consistency(
195
+ self, directory: Path, manifest: RunRecord
196
+ ) -> list[EventRecord]:
197
+ events = self._read_events(directory, manifest["run_id"])
198
+ if self._events_match_manifest(events, manifest):
199
+ return events
200
+ last_revision = events[-1]["manifest_revision"] if events else 0
201
+ if last_revision == manifest["revision"] - 1:
202
+ event = self._event(
203
+ manifest,
204
+ sequence=len(events) + 1,
205
+ event_type="recovery",
206
+ visit_number=manifest["current_visit"],
207
+ payload={
208
+ "reason": "manifest-event-gap",
209
+ "previous_sequence": len(events),
210
+ "previous_manifest_revision": last_revision,
211
+ },
212
+ )
213
+ self._append_event(directory, event)
214
+ events.append(event)
215
+ return events
216
+ raise RunCorruptionError(
217
+ "manifest and event log disagree in a way that cannot be recovered",
218
+ run_id=manifest["run_id"],
219
+ details={
220
+ "manifest_revision": manifest["revision"],
221
+ "last_event_revision": last_revision,
222
+ },
223
+ )
224
+
225
+ def _assert_event_consistency(
226
+ self, directory: Path, manifest: RunRecord
227
+ ) -> list[EventRecord]:
228
+ events = self._read_events(directory, manifest["run_id"])
229
+ if not self._events_match_manifest(events, manifest):
230
+ raise RunCorruptionError(
231
+ "manifest is newer than its final audit event; recovery is required",
232
+ run_id=manifest["run_id"],
233
+ details={"recoverable": True},
234
+ )
235
+ return events
236
+
237
+ @staticmethod
238
+ def _events_match_manifest(
239
+ events: list[EventRecord], manifest: RunRecord
240
+ ) -> bool:
241
+ if not events:
242
+ return False
243
+ final = events[-1]
244
+ return (
245
+ final["manifest_revision"] == manifest["revision"]
246
+ and final["manifest_hash"] == sha256_json(manifest)
247
+ )
248
+
249
+ def commit_locked(
250
+ self,
251
+ directory: Path,
252
+ manifest: RunRecord,
253
+ events: list[EventRecord],
254
+ event_type: str,
255
+ visit_number: int | None,
256
+ payload: dict[str, Any],
257
+ ) -> None:
258
+ """Replace the manifest then append its event under the caller's lock.
259
+
260
+ There is deliberately no lock acquisition or revision stamping here;
261
+ creation commits revision 1, while mutations stamp at their existing
262
+ clock boundary. A failure after replacement leaves the permitted gap.
263
+ """
264
+ run_id = manifest["run_id"]
265
+ run_validation.validate_manifest(manifest, run_id)
266
+ self._write_manifest_atomic(directory, manifest)
267
+ event = self._event(
268
+ manifest,
269
+ sequence=len(events) + 1,
270
+ event_type=event_type,
271
+ visit_number=visit_number,
272
+ payload=payload,
273
+ )
274
+ self._append_event(directory, event)
275
+
276
+ def _event(
277
+ self,
278
+ manifest: RunRecord,
279
+ *,
280
+ sequence: int,
281
+ event_type: str,
282
+ visit_number: int | None,
283
+ payload: dict[str, Any],
284
+ ) -> EventRecord:
285
+ return {
286
+ "schema_version": EVENT_SCHEMA_VERSION,
287
+ "sequence": sequence,
288
+ "timestamp": self._timestamp(),
289
+ "run_id": manifest["run_id"],
290
+ "visit_number": visit_number,
291
+ "type": event_type,
292
+ "payload": payload,
293
+ "manifest_revision": manifest["revision"],
294
+ "manifest_hash": sha256_json(manifest),
295
+ }
296
+
297
+ @staticmethod
298
+ def _fsync_directory(path: Path) -> None:
299
+ descriptor = os.open(path, os.O_RDONLY)
300
+ try:
301
+ os.fsync(descriptor)
302
+ finally:
303
+ os.close(descriptor)