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,140 @@
|
|
|
1
|
+
"""Run trusted validation commands without a shell and capture bounded summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._config.config_models import ValidationCommand
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_SUMMARY_BYTES = 4096
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def execute_validation_commands(
|
|
20
|
+
commands: tuple[ValidationCommand, ...] | list[ValidationCommand],
|
|
21
|
+
repository_root: Path,
|
|
22
|
+
run_directory: Path,
|
|
23
|
+
visit_number: int,
|
|
24
|
+
*,
|
|
25
|
+
summary_bytes: int = DEFAULT_SUMMARY_BYTES,
|
|
26
|
+
fault_injector: Callable[[str], None] | None = None,
|
|
27
|
+
) -> list[dict[str, Any]]:
|
|
28
|
+
"""Execute commands sequentially, stopping at the first required failure."""
|
|
29
|
+
|
|
30
|
+
results: list[dict[str, Any]] = []
|
|
31
|
+
secret_values = _secret_environment_values()
|
|
32
|
+
command_directory = run_directory / "commands"
|
|
33
|
+
command_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
34
|
+
for index, command in enumerate(commands, 1):
|
|
35
|
+
stdout_path = command_directory / f"{visit_number:03}-{index:02}-stdout.txt"
|
|
36
|
+
stderr_path = command_directory / f"{visit_number:03}-{index:02}-stderr.txt"
|
|
37
|
+
started = time.monotonic()
|
|
38
|
+
timed_out = False
|
|
39
|
+
missing = False
|
|
40
|
+
try:
|
|
41
|
+
completed = subprocess.run(
|
|
42
|
+
list(command.argv),
|
|
43
|
+
cwd=repository_root,
|
|
44
|
+
check=False,
|
|
45
|
+
shell=False,
|
|
46
|
+
capture_output=True,
|
|
47
|
+
timeout=command.timeout_seconds,
|
|
48
|
+
env=os.environ.copy(),
|
|
49
|
+
)
|
|
50
|
+
stdout = completed.stdout
|
|
51
|
+
stderr = completed.stderr
|
|
52
|
+
exit_code: int | None = completed.returncode
|
|
53
|
+
except subprocess.TimeoutExpired as exc:
|
|
54
|
+
timed_out = True
|
|
55
|
+
stdout = _as_bytes(exc.stdout)
|
|
56
|
+
stderr = _as_bytes(exc.stderr)
|
|
57
|
+
exit_code = None
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
missing = isinstance(exc, FileNotFoundError)
|
|
60
|
+
stdout = b""
|
|
61
|
+
stderr = str(exc).encode("utf-8", "replace")
|
|
62
|
+
exit_code = None
|
|
63
|
+
duration = max(0.0, time.monotonic() - started)
|
|
64
|
+
if fault_injector is not None:
|
|
65
|
+
fault_injector("before-command-output-write")
|
|
66
|
+
stdout_path.write_bytes(stdout)
|
|
67
|
+
if fault_injector is not None:
|
|
68
|
+
fault_injector("after-command-stdout-write")
|
|
69
|
+
stderr_path.write_bytes(stderr)
|
|
70
|
+
if fault_injector is not None:
|
|
71
|
+
fault_injector("after-command-output-write")
|
|
72
|
+
failed = timed_out or missing or exit_code != 0
|
|
73
|
+
result = {
|
|
74
|
+
"executable": command.argv[0],
|
|
75
|
+
"arguments": list(command.argv[1:]),
|
|
76
|
+
"required": command.required,
|
|
77
|
+
"exit_code": exit_code,
|
|
78
|
+
"duration_seconds": round(duration, 6),
|
|
79
|
+
"timed_out": timed_out,
|
|
80
|
+
"missing_executable": missing,
|
|
81
|
+
"status": "failed" if failed else "passed",
|
|
82
|
+
"stdout_path": stdout_path.relative_to(run_directory).as_posix(),
|
|
83
|
+
"stderr_path": stderr_path.relative_to(run_directory).as_posix(),
|
|
84
|
+
"stdout_summary": _summary(stdout, summary_bytes, secret_values),
|
|
85
|
+
"stderr_summary": _summary(stderr, summary_bytes, secret_values),
|
|
86
|
+
}
|
|
87
|
+
results.append(result)
|
|
88
|
+
if failed and command.required:
|
|
89
|
+
break
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def first_required_failure(results: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
94
|
+
return next(
|
|
95
|
+
(
|
|
96
|
+
result
|
|
97
|
+
for result in results
|
|
98
|
+
if result.get("required") and result.get("status") == "failed"
|
|
99
|
+
),
|
|
100
|
+
None,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _summary(content: bytes, limit: int, secret_values: tuple[str, ...] = ()) -> str:
|
|
105
|
+
if limit < 0:
|
|
106
|
+
raise ValueError("summary byte limit must not be negative")
|
|
107
|
+
text = content.decode("utf-8", "replace")
|
|
108
|
+
for value in secret_values:
|
|
109
|
+
text = text.replace(value, "[REDACTED]")
|
|
110
|
+
redacted = text.encode("utf-8")
|
|
111
|
+
prefix = redacted[:limit].decode("utf-8", "replace")
|
|
112
|
+
if len(redacted) > limit:
|
|
113
|
+
prefix += f"\n...[truncated {len(redacted) - limit} bytes]"
|
|
114
|
+
return prefix
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _secret_environment_values() -> tuple[str, ...]:
|
|
118
|
+
sensitive = re.compile(
|
|
119
|
+
r"(?:SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|API[_-]?KEY|AUTH)",
|
|
120
|
+
re.IGNORECASE,
|
|
121
|
+
)
|
|
122
|
+
return tuple(
|
|
123
|
+
sorted(
|
|
124
|
+
{
|
|
125
|
+
value
|
|
126
|
+
for name, value in os.environ.items()
|
|
127
|
+
if value and sensitive.search(name)
|
|
128
|
+
},
|
|
129
|
+
key=len,
|
|
130
|
+
reverse=True,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _as_bytes(value: bytes | str | None) -> bytes:
|
|
136
|
+
if value is None:
|
|
137
|
+
return b""
|
|
138
|
+
if isinstance(value, bytes):
|
|
139
|
+
return value
|
|
140
|
+
return value.encode("utf-8", "replace")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Public compatibility facade for declarative pipeline configuration.
|
|
2
|
+
|
|
3
|
+
Parsing, immutable records, diagnostics, and canonical encoding live in separate
|
|
4
|
+
internal modules. Existing imports here continue to reference those same objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ._config.config_errors import PipelineConfigError, _MISSING, _json_value
|
|
12
|
+
from ._config.config_loader import (
|
|
13
|
+
IDENTIFIER_PATTERN,
|
|
14
|
+
MUTATION_POLICIES,
|
|
15
|
+
SUPPORTED_SCHEMA_VERSION,
|
|
16
|
+
VALIDATOR_TYPES,
|
|
17
|
+
_Loader,
|
|
18
|
+
_discover_root,
|
|
19
|
+
_safe_declared_path,
|
|
20
|
+
_validate_inputs,
|
|
21
|
+
_validate_output_pattern,
|
|
22
|
+
load_pipeline,
|
|
23
|
+
)
|
|
24
|
+
from ._config.config_models import (
|
|
25
|
+
PhaseConfig,
|
|
26
|
+
PipelineConfig,
|
|
27
|
+
TransitionConfig,
|
|
28
|
+
ValidationCommand,
|
|
29
|
+
ValidatorConfig,
|
|
30
|
+
)
|
|
31
|
+
from ._config.config_serialization import _canonical_document
|
|
32
|
+
from .contracts import RESERVED_COMMANDS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config(
|
|
36
|
+
path: str | Path,
|
|
37
|
+
repository_root: str | Path | None = None,
|
|
38
|
+
) -> PipelineConfig:
|
|
39
|
+
"""Backward-compatible descriptive alias for :func:`load_pipeline`."""
|
|
40
|
+
|
|
41
|
+
return load_pipeline(path, repository_root)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def canonicalize_pipeline(pipeline: PipelineConfig) -> str:
|
|
45
|
+
return pipeline.canonical_json
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hash_pipeline(pipeline: PipelineConfig) -> str:
|
|
49
|
+
return pipeline.config_hash
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_pipeline_hash(pipeline: PipelineConfig, expected_hash: str) -> None:
|
|
53
|
+
"""Reject configuration drift for a persisted in-progress run."""
|
|
54
|
+
|
|
55
|
+
if pipeline.config_hash != expected_hash:
|
|
56
|
+
raise PipelineConfigError(
|
|
57
|
+
"pipeline-hash-mismatch",
|
|
58
|
+
"pipeline configuration changed after the run was created",
|
|
59
|
+
path=pipeline.path,
|
|
60
|
+
field="config_hash",
|
|
61
|
+
value={"expected": expected_hash, "actual": pipeline.config_hash},
|
|
62
|
+
remediation="restore the original pipeline or start a new run",
|
|
63
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Small public constants shared across configuration and CLI boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
RESERVED_COMMANDS = frozenset(
|
|
7
|
+
{
|
|
8
|
+
"init",
|
|
9
|
+
"phase",
|
|
10
|
+
"validate",
|
|
11
|
+
"approve",
|
|
12
|
+
"request-approval",
|
|
13
|
+
"block",
|
|
14
|
+
"status",
|
|
15
|
+
"resume",
|
|
16
|
+
"run",
|
|
17
|
+
"adapters",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Generic, deterministic state-machine execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._engine import responses
|
|
10
|
+
from ._engine.approval_service import ApprovalService
|
|
11
|
+
from ._engine.approvals import approved_record, pending_approval
|
|
12
|
+
from ._engine.command_decisions import (
|
|
13
|
+
declared_reason as _declared_reason, illegal_transition, select_outcome,
|
|
14
|
+
)
|
|
15
|
+
from .artifacts import ArtifactError, verify_manifest_artifacts
|
|
16
|
+
from ._config.config_models import PhaseConfig, PipelineConfig
|
|
17
|
+
from ._engine.engine_errors import EngineError
|
|
18
|
+
from .errors import UsageError
|
|
19
|
+
from .exit_codes import ExitCode
|
|
20
|
+
from .git import GitError, capture_repository_snapshot
|
|
21
|
+
from ._engine.provenance import observe_provenance, pipeline_identity, pipeline_matches
|
|
22
|
+
from ._engine.responses import RESPONSE_SCHEMA_VERSION # Backward-compatible re-export.
|
|
23
|
+
from .run_store import RunStore, format_timestamp, utc_now
|
|
24
|
+
from ._runs.run_records import (
|
|
25
|
+
ApprovalRecord, RunRecord, VisitRecord, current_visit,
|
|
26
|
+
)
|
|
27
|
+
from ._engine.validation_service import ValidationResult, perform_validation
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Engine:
|
|
31
|
+
"""Advance runs using only configured outcomes and persisted state."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
pipeline: PipelineConfig,
|
|
36
|
+
store: RunStore | None = None,
|
|
37
|
+
*,
|
|
38
|
+
command_fault_injector: Callable[[str], None] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.pipeline = pipeline
|
|
41
|
+
self.store = store or RunStore(pipeline.repository_root)
|
|
42
|
+
self._command_fault_injector = command_fault_injector
|
|
43
|
+
|
|
44
|
+
def initialize(self, description: str) -> dict[str, Any]:
|
|
45
|
+
if not description.strip():
|
|
46
|
+
raise UsageError("DESCRIPTION must not be empty")
|
|
47
|
+
try:
|
|
48
|
+
git = capture_repository_snapshot(
|
|
49
|
+
self.pipeline.repository_root,
|
|
50
|
+
allow_non_git=self.pipeline.allow_non_git,
|
|
51
|
+
)
|
|
52
|
+
except GitError as exc:
|
|
53
|
+
raise EngineError(
|
|
54
|
+
ExitCode.INVALID_PIPELINE,
|
|
55
|
+
exc.message,
|
|
56
|
+
exc.diagnostic_code,
|
|
57
|
+
**exc.details,
|
|
58
|
+
) from exc
|
|
59
|
+
manifest = self.store.create(
|
|
60
|
+
self.pipeline,
|
|
61
|
+
description,
|
|
62
|
+
git_base=git,
|
|
63
|
+
git_head=git,
|
|
64
|
+
)
|
|
65
|
+
visit = self.store.prepare_visit(
|
|
66
|
+
manifest["run_id"], self.pipeline, self.pipeline.start
|
|
67
|
+
)
|
|
68
|
+
manifest = self.store.load(manifest["run_id"])
|
|
69
|
+
return responses.action_response(
|
|
70
|
+
self.pipeline,
|
|
71
|
+
manifest,
|
|
72
|
+
visit,
|
|
73
|
+
f"run {manifest['run_id']} initialized; agent action is required",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def start_phase(self, run_id: str, phase_id: str) -> dict[str, Any]:
|
|
77
|
+
manifest = self._load(run_id)
|
|
78
|
+
if manifest["status"] == "paused":
|
|
79
|
+
return responses.paused_response(
|
|
80
|
+
self.pipeline, manifest, "run is paused; resume it first"
|
|
81
|
+
)
|
|
82
|
+
visit = self._current_visit(manifest)
|
|
83
|
+
if visit is None or visit["phase_id"] != phase_id:
|
|
84
|
+
expected = visit["phase_id"] if visit is not None else None
|
|
85
|
+
raise EngineError(
|
|
86
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
87
|
+
f"cannot start phase {phase_id!r}; expected {expected!r}",
|
|
88
|
+
"unexpected-phase",
|
|
89
|
+
requested=phase_id,
|
|
90
|
+
expected=expected,
|
|
91
|
+
status=manifest["status"],
|
|
92
|
+
)
|
|
93
|
+
if visit["status"] == "pending":
|
|
94
|
+
try:
|
|
95
|
+
baseline = capture_repository_snapshot(
|
|
96
|
+
self.pipeline.repository_root,
|
|
97
|
+
allow_non_git=self.pipeline.allow_non_git,
|
|
98
|
+
)
|
|
99
|
+
except GitError as exc:
|
|
100
|
+
raise EngineError(
|
|
101
|
+
ExitCode.VALIDATION_FAILED,
|
|
102
|
+
exc.message,
|
|
103
|
+
exc.diagnostic_code,
|
|
104
|
+
**exc.details,
|
|
105
|
+
) from exc
|
|
106
|
+
visit = self.store.activate_visit(
|
|
107
|
+
run_id,
|
|
108
|
+
self.pipeline,
|
|
109
|
+
visit["ordinal"],
|
|
110
|
+
mutation_baseline=baseline,
|
|
111
|
+
)
|
|
112
|
+
manifest = self.store.load(run_id)
|
|
113
|
+
elif visit["status"] != "active":
|
|
114
|
+
raise EngineError(
|
|
115
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
116
|
+
f"phase {phase_id!r} cannot start from {visit['status']}",
|
|
117
|
+
"illegal-visit-status",
|
|
118
|
+
phase=phase_id,
|
|
119
|
+
status=visit["status"],
|
|
120
|
+
)
|
|
121
|
+
return responses.action_response(
|
|
122
|
+
self.pipeline,
|
|
123
|
+
manifest, visit, f"phase {phase_id} is active; agent action is required"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def validate(
|
|
127
|
+
self,
|
|
128
|
+
run_id: str,
|
|
129
|
+
phase_id: str | None = None,
|
|
130
|
+
*,
|
|
131
|
+
outcome: str | None = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
manifest = self._load(run_id)
|
|
134
|
+
if manifest["status"] == "completed":
|
|
135
|
+
return responses.status_response(
|
|
136
|
+
self.pipeline, manifest, "run is already completed"
|
|
137
|
+
)
|
|
138
|
+
if manifest["status"] == "paused":
|
|
139
|
+
return responses.paused_response(
|
|
140
|
+
self.pipeline, manifest, "run is paused; resume it first"
|
|
141
|
+
)
|
|
142
|
+
visit = self._current_visit(manifest)
|
|
143
|
+
if visit is None:
|
|
144
|
+
raise self._illegal("run has no current visit", manifest)
|
|
145
|
+
if visit["status"] == "pending":
|
|
146
|
+
previous = manifest["visits"][-2] if len(manifest["visits"]) > 1 else None
|
|
147
|
+
if previous is not None and previous["status"] == "completed" and (
|
|
148
|
+
phase_id is None or phase_id == previous["phase_id"]
|
|
149
|
+
):
|
|
150
|
+
return responses.action_response(
|
|
151
|
+
self.pipeline,
|
|
152
|
+
manifest,
|
|
153
|
+
visit,
|
|
154
|
+
"validation was already recorded; the next phase requires agent action",
|
|
155
|
+
)
|
|
156
|
+
raise self._illegal(
|
|
157
|
+
f"phase {visit['phase_id']!r} must be started before validation",
|
|
158
|
+
manifest,
|
|
159
|
+
expected_phase=visit["phase_id"],
|
|
160
|
+
)
|
|
161
|
+
if visit["status"] != "active":
|
|
162
|
+
raise self._illegal(
|
|
163
|
+
f"visit cannot be validated from {visit['status']}", manifest
|
|
164
|
+
)
|
|
165
|
+
self._require_phase(visit, phase_id)
|
|
166
|
+
phase = self.pipeline.phase(visit["phase_id"])
|
|
167
|
+
selected = self._select_outcome(phase, outcome)
|
|
168
|
+
transition = next(item for item in phase.transitions if item.outcome == selected)
|
|
169
|
+
evidence = self._perform_validation(manifest, visit, phase)
|
|
170
|
+
updated = self.store.transition_visit(
|
|
171
|
+
run_id,
|
|
172
|
+
self.pipeline,
|
|
173
|
+
visit["ordinal"],
|
|
174
|
+
outcome=selected,
|
|
175
|
+
transition_target=transition.target,
|
|
176
|
+
terminal_result={
|
|
177
|
+
"outcome": selected,
|
|
178
|
+
"phase": phase.id,
|
|
179
|
+
"visit_number": visit["ordinal"],
|
|
180
|
+
},
|
|
181
|
+
validation_checks=evidence.checks,
|
|
182
|
+
mutation_result=evidence.mutation_result,
|
|
183
|
+
command_results=evidence.command_results,
|
|
184
|
+
)
|
|
185
|
+
return responses.after_transition(self.pipeline, updated, selected)
|
|
186
|
+
|
|
187
|
+
def request_approval(
|
|
188
|
+
self,
|
|
189
|
+
run_id: str,
|
|
190
|
+
*,
|
|
191
|
+
reason: str | None,
|
|
192
|
+
details: str | None = None,
|
|
193
|
+
outcome: str | None = None,
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
manifest = self._load(run_id)
|
|
196
|
+
return self._approval_service().request_approval(
|
|
197
|
+
manifest, reason=reason, details=details, outcome=outcome
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def approve(self, run_id: str, phase_id: str) -> dict[str, Any]:
|
|
201
|
+
return self._approval_service().approve(run_id, phase_id)
|
|
202
|
+
|
|
203
|
+
def block(
|
|
204
|
+
self,
|
|
205
|
+
run_id: str,
|
|
206
|
+
*,
|
|
207
|
+
reason: str | None,
|
|
208
|
+
details: str | None = None,
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
manifest = self._load(run_id)
|
|
211
|
+
if manifest["status"] == "blocked":
|
|
212
|
+
recorded = manifest["block_reason"] or {}
|
|
213
|
+
if reason is not None and reason != recorded.get("reason"):
|
|
214
|
+
raise self._illegal(
|
|
215
|
+
"run is already blocked for a different reason", manifest
|
|
216
|
+
)
|
|
217
|
+
if details is not None and details != recorded.get("details"):
|
|
218
|
+
raise self._illegal(
|
|
219
|
+
"run is already blocked with different details", manifest
|
|
220
|
+
)
|
|
221
|
+
return responses.blocked_response(
|
|
222
|
+
self.pipeline, manifest, "run is already blocked"
|
|
223
|
+
)
|
|
224
|
+
visit = self._current_visit(manifest)
|
|
225
|
+
if visit is None or visit["status"] != "active":
|
|
226
|
+
raise self._illegal("only an active visit can be blocked", manifest)
|
|
227
|
+
phase = self.pipeline.phase(visit["phase_id"])
|
|
228
|
+
chosen_reason = _declared_reason(reason, phase.stop_conditions, "stop")
|
|
229
|
+
block = {
|
|
230
|
+
"phase_id": phase.id,
|
|
231
|
+
"visit_number": visit["ordinal"],
|
|
232
|
+
"reason": chosen_reason,
|
|
233
|
+
"details": details,
|
|
234
|
+
"recorded_at": format_timestamp(utc_now()),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def change(value: RunRecord) -> None:
|
|
238
|
+
current = self._current_visit(value)
|
|
239
|
+
assert current is not None
|
|
240
|
+
current["status"] = "blocked"
|
|
241
|
+
value["status"] = "blocked"
|
|
242
|
+
value["block_reason"] = block
|
|
243
|
+
|
|
244
|
+
manifest = self.store.mutate(
|
|
245
|
+
run_id,
|
|
246
|
+
"run-blocked",
|
|
247
|
+
change,
|
|
248
|
+
visit_number=visit["ordinal"],
|
|
249
|
+
payload={"reason": chosen_reason, "details": details},
|
|
250
|
+
)
|
|
251
|
+
return responses.blocked_response(self.pipeline, manifest, "run is blocked")
|
|
252
|
+
|
|
253
|
+
def status(self, run_id: str) -> dict[str, Any]:
|
|
254
|
+
manifest = self.store.load(run_id, verify_artifacts=False)
|
|
255
|
+
warnings: list[dict[str, Any]] = []
|
|
256
|
+
if not pipeline_matches(self.pipeline, manifest):
|
|
257
|
+
warnings.append(
|
|
258
|
+
{
|
|
259
|
+
"code": "pipeline-drift",
|
|
260
|
+
"message": "pipeline configuration differs from the run provenance",
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
try:
|
|
264
|
+
verify_manifest_artifacts(manifest, self.store.run_directory(run_id))
|
|
265
|
+
except ArtifactError as exc:
|
|
266
|
+
warnings.append(
|
|
267
|
+
{
|
|
268
|
+
"code": exc.diagnostic_code,
|
|
269
|
+
"message": exc.message,
|
|
270
|
+
"details": exc.details,
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
warnings.extend(observe_provenance(self.pipeline, manifest))
|
|
274
|
+
return responses.status_response(
|
|
275
|
+
self.pipeline, manifest, "run status", warnings=warnings
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def resume(self, run_id: str) -> dict[str, Any]:
|
|
279
|
+
manifest = self._load(run_id)
|
|
280
|
+
if manifest["status"] == "paused":
|
|
281
|
+
manifest = self.store.resume_paused(run_id, self.pipeline)
|
|
282
|
+
return responses.response_for_state(self.pipeline, manifest, "run resumed")
|
|
283
|
+
|
|
284
|
+
def run(self, run_id: str) -> dict[str, Any]:
|
|
285
|
+
manifest = self._load(run_id)
|
|
286
|
+
visit = self._current_visit(manifest)
|
|
287
|
+
if manifest["status"] == "awaiting-approval" and visit is not None:
|
|
288
|
+
approved = approved_record(manifest, visit["ordinal"])
|
|
289
|
+
if approved is not None:
|
|
290
|
+
return self._advance_approved(manifest, visit, approved)
|
|
291
|
+
if visit is not None and visit["status"] == "pending":
|
|
292
|
+
return self.start_phase(run_id, visit["phase_id"])
|
|
293
|
+
return responses.response_for_state(
|
|
294
|
+
self.pipeline, manifest, "run reached a boundary"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _approval_service(self) -> ApprovalService:
|
|
298
|
+
# Resolve validation and the engine clock at their original call sites,
|
|
299
|
+
# including hooks replaced after a grant has committed.
|
|
300
|
+
return ApprovalService(
|
|
301
|
+
self.pipeline,
|
|
302
|
+
self.store,
|
|
303
|
+
validate=lambda manifest, visit, phase: self._perform_validation(
|
|
304
|
+
manifest, visit, phase
|
|
305
|
+
),
|
|
306
|
+
timestamp=lambda: format_timestamp(utc_now()),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _advance_approved(
|
|
310
|
+
self,
|
|
311
|
+
manifest: RunRecord,
|
|
312
|
+
visit: VisitRecord,
|
|
313
|
+
approval: ApprovalRecord,
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
return self._approval_service().advance_approved(manifest, visit, approval)
|
|
316
|
+
|
|
317
|
+
def _load(self, run_id: str) -> RunRecord:
|
|
318
|
+
manifest = self.store.load(run_id)
|
|
319
|
+
if not pipeline_matches(self.pipeline, manifest):
|
|
320
|
+
raise EngineError(
|
|
321
|
+
ExitCode.INVALID_PIPELINE,
|
|
322
|
+
"pipeline configuration changed after the run was created",
|
|
323
|
+
"pipeline-hash-mismatch",
|
|
324
|
+
expected=manifest["pipeline"],
|
|
325
|
+
actual=pipeline_identity(self.pipeline),
|
|
326
|
+
)
|
|
327
|
+
provenance_warnings = observe_provenance(self.pipeline, manifest)
|
|
328
|
+
if provenance_warnings:
|
|
329
|
+
warning = provenance_warnings[0]
|
|
330
|
+
raise EngineError(
|
|
331
|
+
ExitCode.INTERNAL_ERROR,
|
|
332
|
+
warning["message"],
|
|
333
|
+
warning["code"],
|
|
334
|
+
**warning.get("details", {}),
|
|
335
|
+
)
|
|
336
|
+
return manifest
|
|
337
|
+
|
|
338
|
+
def _perform_validation(
|
|
339
|
+
self,
|
|
340
|
+
manifest: RunRecord,
|
|
341
|
+
visit: VisitRecord,
|
|
342
|
+
phase: PhaseConfig,
|
|
343
|
+
) -> ValidationResult:
|
|
344
|
+
# Keep this orchestration seam for overlapping-attempt fault injection.
|
|
345
|
+
result = perform_validation(
|
|
346
|
+
self.pipeline,
|
|
347
|
+
self.store.run_directory(manifest["run_id"]),
|
|
348
|
+
visit,
|
|
349
|
+
phase,
|
|
350
|
+
command_fault_injector=self._command_fault_injector,
|
|
351
|
+
)
|
|
352
|
+
if result.failure is not None:
|
|
353
|
+
self.store.record_validation_attempt(
|
|
354
|
+
manifest["run_id"],
|
|
355
|
+
visit["ordinal"],
|
|
356
|
+
validation_checks=result.checks,
|
|
357
|
+
command_results=result.command_results,
|
|
358
|
+
mutation_result=result.mutation_result,
|
|
359
|
+
diagnostic={"error_code": result.failure.diagnostic_code},
|
|
360
|
+
)
|
|
361
|
+
raise result.failure from result.cause
|
|
362
|
+
return result
|
|
363
|
+
|
|
364
|
+
def _artifact_hash(
|
|
365
|
+
self, manifest: RunRecord, visit: VisitRecord
|
|
366
|
+
) -> str:
|
|
367
|
+
return self._approval_service().artifact_hash(manifest, visit)
|
|
368
|
+
|
|
369
|
+
def _invalidate_approval(
|
|
370
|
+
self,
|
|
371
|
+
manifest: RunRecord,
|
|
372
|
+
visit: VisitRecord,
|
|
373
|
+
approval: ApprovalRecord,
|
|
374
|
+
mismatches: list[str],
|
|
375
|
+
) -> None:
|
|
376
|
+
self._approval_service().invalidate_approval(manifest, visit, approval, mismatches)
|
|
377
|
+
|
|
378
|
+
def _select_outcome(self, phase: PhaseConfig, requested: str | None) -> str:
|
|
379
|
+
return select_outcome(phase, requested)
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _current_visit(manifest: RunRecord) -> VisitRecord | None:
|
|
383
|
+
return current_visit(manifest)
|
|
384
|
+
|
|
385
|
+
# Preserve selector entry points used by existing callers and tests.
|
|
386
|
+
_pending_approval = staticmethod(pending_approval)
|
|
387
|
+
_approved_record = staticmethod(approved_record)
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _require_phase(visit: VisitRecord, requested: str | None) -> None:
|
|
391
|
+
if requested is not None and requested != visit["phase_id"]:
|
|
392
|
+
raise EngineError(
|
|
393
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
394
|
+
f"requested phase {requested!r}; expected {visit['phase_id']!r}",
|
|
395
|
+
"unexpected-phase",
|
|
396
|
+
requested=requested,
|
|
397
|
+
expected=visit["phase_id"],
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _illegal(
|
|
402
|
+
message: str, manifest: RunRecord, **details: Any
|
|
403
|
+
) -> EngineError:
|
|
404
|
+
return illegal_transition(message, manifest, **details)
|
|
405
|
+
|
|
406
|
+
def _idempotent_approval_result(
|
|
407
|
+
self, manifest: RunRecord, phase_id: str
|
|
408
|
+
) -> dict[str, Any]:
|
|
409
|
+
return self._approval_service().idempotent_approval_result(manifest, phase_id)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def capture_git_metadata(root: Path, *, include_status: bool = False) -> dict[str, Any]:
|
|
413
|
+
"""Backward-compatible alias for content-level repository capture."""
|
|
414
|
+
|
|
415
|
+
snapshot = capture_repository_snapshot(root, allow_non_git=True)
|
|
416
|
+
if include_status:
|
|
417
|
+
return {**snapshot, "status": []}
|
|
418
|
+
return {
|
|
419
|
+
"is_worktree": snapshot["is_worktree"],
|
|
420
|
+
"head": snapshot["head"],
|
|
421
|
+
"branch": snapshot["branch"],
|
|
422
|
+
"status": [],
|
|
423
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Expected, user-facing failures raised by the Specromancy engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .exit_codes import ExitCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpecromancyError(Exception):
|
|
12
|
+
"""A failure with a stable exit code and serializable diagnostics."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
code: ExitCode | int,
|
|
17
|
+
message: str,
|
|
18
|
+
details: Mapping[str, Any] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.code = ExitCode(code)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.details = dict(details) if details is not None else None
|
|
24
|
+
|
|
25
|
+
def as_dict(self) -> dict[str, Any]:
|
|
26
|
+
payload: dict[str, Any] = {
|
|
27
|
+
"code": int(self.code),
|
|
28
|
+
"message": self.message,
|
|
29
|
+
}
|
|
30
|
+
if self.details is not None:
|
|
31
|
+
payload["details"] = self.details
|
|
32
|
+
return payload
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UsageError(SpecromancyError):
|
|
36
|
+
"""Invalid command-line input."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self, message: str, details: Mapping[str, Any] | None = None
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(ExitCode.USAGE_ERROR, message, details)
|
|
42
|
+
|