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,246 @@
|
|
|
1
|
+
"""Load pipeline TOML and assemble configuration with graph validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .config_errors import PipelineConfigError, _MISSING
|
|
12
|
+
from .config_fields import FieldContext, IDENTIFIER_PATTERN, _safe_declared_path
|
|
13
|
+
from .config_models import (
|
|
14
|
+
PhaseConfig,
|
|
15
|
+
PipelineConfig,
|
|
16
|
+
TransitionConfig,
|
|
17
|
+
ValidationCommand,
|
|
18
|
+
ValidatorConfig,
|
|
19
|
+
)
|
|
20
|
+
from .config_phase_parser import (
|
|
21
|
+
MUTATION_POLICIES,
|
|
22
|
+
VALIDATOR_TYPES,
|
|
23
|
+
_validate_inputs,
|
|
24
|
+
_validate_output_pattern,
|
|
25
|
+
parse_commands,
|
|
26
|
+
parse_phase,
|
|
27
|
+
parse_transition,
|
|
28
|
+
parse_validator,
|
|
29
|
+
)
|
|
30
|
+
from .config_serialization import _canonical_document, _encode_canonical_document
|
|
31
|
+
from ..graph import validate_graph
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _discover_root(path: Path) -> Path | None:
|
|
38
|
+
for candidate in (path.parent, *path.parent.parents):
|
|
39
|
+
if (candidate / ".git").exists():
|
|
40
|
+
return candidate.resolve()
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Loader(FieldContext):
|
|
45
|
+
"""Pipeline assembly with inherited field helpers and legacy parser delegates."""
|
|
46
|
+
|
|
47
|
+
def parse(self, raw: Mapping[str, Any]) -> PipelineConfig:
|
|
48
|
+
allowed_top = {
|
|
49
|
+
"schema_version",
|
|
50
|
+
"id",
|
|
51
|
+
"version",
|
|
52
|
+
"start",
|
|
53
|
+
"terminal_outcomes",
|
|
54
|
+
"artifact_pattern",
|
|
55
|
+
"allow_non_git",
|
|
56
|
+
"phases",
|
|
57
|
+
}
|
|
58
|
+
self.check_unknown(raw, allowed_top)
|
|
59
|
+
schema_version = self.require_integer(
|
|
60
|
+
raw.get("schema_version"), "schema_version", minimum=1
|
|
61
|
+
)
|
|
62
|
+
if schema_version != SUPPORTED_SCHEMA_VERSION:
|
|
63
|
+
self.fail(
|
|
64
|
+
"unsupported-schema-version",
|
|
65
|
+
f"schema_version {schema_version} is not supported",
|
|
66
|
+
field="schema_version",
|
|
67
|
+
value=schema_version,
|
|
68
|
+
remediation=f"set schema_version to {SUPPORTED_SCHEMA_VERSION}",
|
|
69
|
+
)
|
|
70
|
+
pipeline_id = self.require_identifier(raw.get("id"), "id")
|
|
71
|
+
version = self.require_integer(raw.get("version"), "version", minimum=1)
|
|
72
|
+
start = self.require_identifier(raw.get("start"), "start")
|
|
73
|
+
terminal_outcomes = self.string_list(
|
|
74
|
+
raw.get("terminal_outcomes"),
|
|
75
|
+
"terminal_outcomes",
|
|
76
|
+
allow_empty=False,
|
|
77
|
+
identifiers=True,
|
|
78
|
+
)
|
|
79
|
+
artifact_pattern = self.require_string(
|
|
80
|
+
raw.get("artifact_pattern"), "artifact_pattern"
|
|
81
|
+
)
|
|
82
|
+
allow_non_git = raw.get("allow_non_git", False)
|
|
83
|
+
if not isinstance(allow_non_git, bool):
|
|
84
|
+
self.fail(
|
|
85
|
+
"invalid-field-type",
|
|
86
|
+
"allow_non_git must be a boolean",
|
|
87
|
+
field="allow_non_git",
|
|
88
|
+
value=allow_non_git,
|
|
89
|
+
remediation="use true or false",
|
|
90
|
+
)
|
|
91
|
+
_validate_output_pattern(
|
|
92
|
+
artifact_pattern, "artifact_pattern", self.fail, output_name="output.md"
|
|
93
|
+
)
|
|
94
|
+
raw_phases = raw.get("phases")
|
|
95
|
+
if not isinstance(raw_phases, list) or not raw_phases:
|
|
96
|
+
self.fail(
|
|
97
|
+
"invalid-field-type",
|
|
98
|
+
"phases must be a non-empty array of tables",
|
|
99
|
+
field="phases",
|
|
100
|
+
value=raw_phases,
|
|
101
|
+
remediation="declare one or more [[phases]] tables",
|
|
102
|
+
)
|
|
103
|
+
phases = tuple(self.parse_phase(item) for item in raw_phases)
|
|
104
|
+
for phase in phases:
|
|
105
|
+
if phase.id in terminal_outcomes:
|
|
106
|
+
self.fail(
|
|
107
|
+
"terminal-outcome-phase",
|
|
108
|
+
f"terminal outcome {phase.id!r} cannot also be a phase id",
|
|
109
|
+
phase=phase.id,
|
|
110
|
+
field="id",
|
|
111
|
+
value=phase.id,
|
|
112
|
+
remediation="rename the phase or terminal outcome",
|
|
113
|
+
)
|
|
114
|
+
validate_graph(phases, start, frozenset(terminal_outcomes), self.fail)
|
|
115
|
+
canonical = _canonical_document(
|
|
116
|
+
schema_version,
|
|
117
|
+
pipeline_id,
|
|
118
|
+
version,
|
|
119
|
+
start,
|
|
120
|
+
terminal_outcomes,
|
|
121
|
+
artifact_pattern,
|
|
122
|
+
allow_non_git,
|
|
123
|
+
phases,
|
|
124
|
+
)
|
|
125
|
+
canonical_json = _encode_canonical_document(canonical)
|
|
126
|
+
config_hash = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
|
|
127
|
+
return PipelineConfig(
|
|
128
|
+
schema_version=schema_version,
|
|
129
|
+
id=pipeline_id,
|
|
130
|
+
version=version,
|
|
131
|
+
start=start,
|
|
132
|
+
terminal_outcomes=terminal_outcomes,
|
|
133
|
+
artifact_pattern=artifact_pattern,
|
|
134
|
+
allow_non_git=allow_non_git,
|
|
135
|
+
phases=phases,
|
|
136
|
+
path=self.path,
|
|
137
|
+
repository_root=self.repository_root,
|
|
138
|
+
canonical_json=canonical_json,
|
|
139
|
+
config_hash=config_hash,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def parse_phase(self, value: Any) -> PhaseConfig:
|
|
143
|
+
return parse_phase(self, value)
|
|
144
|
+
|
|
145
|
+
def parse_validator(
|
|
146
|
+
self, phase_raw: Mapping[str, Any], phase_id: str
|
|
147
|
+
) -> ValidatorConfig:
|
|
148
|
+
return parse_validator(self, phase_raw, phase_id)
|
|
149
|
+
|
|
150
|
+
def parse_commands(
|
|
151
|
+
self, raw: Mapping[str, Any], phase_id: str
|
|
152
|
+
) -> tuple[ValidationCommand, ...]:
|
|
153
|
+
return parse_commands(self, raw, phase_id)
|
|
154
|
+
|
|
155
|
+
def parse_transition(self, value: Any, phase_id: str) -> TransitionConfig:
|
|
156
|
+
return parse_transition(self, value, phase_id)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_pipeline(
|
|
160
|
+
path: str | Path,
|
|
161
|
+
repository_root: str | Path | None = None,
|
|
162
|
+
*,
|
|
163
|
+
root: str | Path | None = None,
|
|
164
|
+
) -> PipelineConfig:
|
|
165
|
+
"""Load and fully validate a pipeline TOML file.
|
|
166
|
+
|
|
167
|
+
``root`` is accepted as a concise alias for ``repository_root``. Supplying
|
|
168
|
+
both is rejected to keep path authority unambiguous.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
source = Path(path).expanduser()
|
|
172
|
+
try:
|
|
173
|
+
source = source.resolve(strict=True)
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
unresolved = source.resolve()
|
|
176
|
+
raise PipelineConfigError(
|
|
177
|
+
"pipeline-not-found",
|
|
178
|
+
f"cannot read pipeline configuration: {unresolved}",
|
|
179
|
+
path=unresolved,
|
|
180
|
+
field="pipeline",
|
|
181
|
+
value=str(unresolved),
|
|
182
|
+
remediation="provide the path to an existing pipeline TOML file",
|
|
183
|
+
) from exc
|
|
184
|
+
if not source.is_file():
|
|
185
|
+
raise PipelineConfigError(
|
|
186
|
+
"pipeline-not-file",
|
|
187
|
+
f"pipeline configuration is not a file: {source}",
|
|
188
|
+
path=source,
|
|
189
|
+
field="pipeline",
|
|
190
|
+
value=str(source),
|
|
191
|
+
remediation="provide the path to a regular TOML file",
|
|
192
|
+
)
|
|
193
|
+
if repository_root is not None and root is not None:
|
|
194
|
+
raise TypeError("pass repository_root or root, not both")
|
|
195
|
+
requested_root = repository_root if repository_root is not None else root
|
|
196
|
+
if requested_root is None:
|
|
197
|
+
discovered = _discover_root(source)
|
|
198
|
+
if discovered is None:
|
|
199
|
+
raise PipelineConfigError(
|
|
200
|
+
"repository-root-not-found",
|
|
201
|
+
"could not discover a repository root for the pipeline",
|
|
202
|
+
path=source,
|
|
203
|
+
field="repository_root",
|
|
204
|
+
remediation="pass repository_root for non-Git fixture repositories",
|
|
205
|
+
)
|
|
206
|
+
resolved_root = discovered
|
|
207
|
+
else:
|
|
208
|
+
resolved_root = Path(requested_root).expanduser().resolve()
|
|
209
|
+
if not resolved_root.is_dir():
|
|
210
|
+
raise PipelineConfigError(
|
|
211
|
+
"invalid-repository-root",
|
|
212
|
+
f"repository root is not a directory: {resolved_root}",
|
|
213
|
+
path=source,
|
|
214
|
+
field="repository_root",
|
|
215
|
+
value=str(resolved_root),
|
|
216
|
+
remediation="pass an existing repository directory",
|
|
217
|
+
)
|
|
218
|
+
if not source.is_relative_to(resolved_root):
|
|
219
|
+
raise PipelineConfigError(
|
|
220
|
+
"pipeline-outside-repository",
|
|
221
|
+
"pipeline configuration resolves outside the repository",
|
|
222
|
+
path=source,
|
|
223
|
+
field="pipeline",
|
|
224
|
+
value=str(source),
|
|
225
|
+
remediation="place the pipeline inside the repository",
|
|
226
|
+
)
|
|
227
|
+
try:
|
|
228
|
+
with source.open("rb") as stream:
|
|
229
|
+
raw = tomllib.load(stream)
|
|
230
|
+
except tomllib.TOMLDecodeError as exc:
|
|
231
|
+
raise PipelineConfigError(
|
|
232
|
+
"invalid-toml",
|
|
233
|
+
f"pipeline configuration is not valid TOML: {exc}",
|
|
234
|
+
path=source,
|
|
235
|
+
field="pipeline",
|
|
236
|
+
remediation="correct the TOML syntax",
|
|
237
|
+
) from exc
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
raise PipelineConfigError(
|
|
240
|
+
"pipeline-unreadable",
|
|
241
|
+
f"cannot read pipeline configuration: {exc}",
|
|
242
|
+
path=source,
|
|
243
|
+
field="pipeline",
|
|
244
|
+
remediation="make the file readable and try again",
|
|
245
|
+
) from exc
|
|
246
|
+
return _Loader(source, resolved_root).parse(raw)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Immutable configuration records shared by runtime components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ValidationCommand:
|
|
13
|
+
"""A deterministic command invoked without a shell."""
|
|
14
|
+
|
|
15
|
+
argv: tuple[str, ...]
|
|
16
|
+
timeout_seconds: float = 300.0
|
|
17
|
+
required: bool = True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ValidatorConfig:
|
|
22
|
+
"""Rules for validating a phase's primary output."""
|
|
23
|
+
|
|
24
|
+
type: str
|
|
25
|
+
required_headings: tuple[str, ...] = ()
|
|
26
|
+
declared_path: str | None = None
|
|
27
|
+
resolved_path: Path | None = None
|
|
28
|
+
heading_occurrence: str = "exactly-once"
|
|
29
|
+
json_schema: Mapping[str, Any] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class TransitionConfig:
|
|
34
|
+
outcome: str
|
|
35
|
+
target: str | None = None
|
|
36
|
+
max_traversals: int | None = None
|
|
37
|
+
pause: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class PhaseConfig:
|
|
42
|
+
id: str
|
|
43
|
+
skill: str
|
|
44
|
+
skill_path: Path
|
|
45
|
+
inputs: tuple[str, ...]
|
|
46
|
+
output_name: str
|
|
47
|
+
output_template: str | None
|
|
48
|
+
output_template_path: Path | None
|
|
49
|
+
mutation: str
|
|
50
|
+
allowlist: tuple[str, ...]
|
|
51
|
+
completion_criteria: tuple[str, ...]
|
|
52
|
+
validator: ValidatorConfig
|
|
53
|
+
commands: tuple[ValidationCommand, ...]
|
|
54
|
+
approval_conditions: tuple[str, ...]
|
|
55
|
+
stop_conditions: tuple[str, ...]
|
|
56
|
+
transitions: tuple[TransitionConfig, ...]
|
|
57
|
+
max_visits: int | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def validation_commands(self) -> tuple[ValidationCommand, ...]:
|
|
61
|
+
"""Descriptive alias used by action-packet code in later stages."""
|
|
62
|
+
|
|
63
|
+
return self.commands
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class PipelineConfig:
|
|
68
|
+
schema_version: int
|
|
69
|
+
id: str
|
|
70
|
+
version: int
|
|
71
|
+
start: str
|
|
72
|
+
terminal_outcomes: tuple[str, ...]
|
|
73
|
+
artifact_pattern: str
|
|
74
|
+
allow_non_git: bool
|
|
75
|
+
phases: tuple[PhaseConfig, ...]
|
|
76
|
+
path: Path
|
|
77
|
+
repository_root: Path
|
|
78
|
+
canonical_json: str
|
|
79
|
+
config_hash: str
|
|
80
|
+
|
|
81
|
+
def phase(self, phase_id: str) -> PhaseConfig:
|
|
82
|
+
"""Return a configured phase by id."""
|
|
83
|
+
|
|
84
|
+
for phase in self.phases:
|
|
85
|
+
if phase.id == phase_id:
|
|
86
|
+
return phase
|
|
87
|
+
raise KeyError(phase_id)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def phase_ids(self) -> tuple[str, ...]:
|
|
91
|
+
return tuple(phase.id for phase in self.phases)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def hash(self) -> str:
|
|
95
|
+
"""Compatibility-friendly shorthand for the canonical config hash."""
|
|
96
|
+
|
|
97
|
+
return self.config_hash
|