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