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,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)
|