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,159 @@
1
+ """Version-one dictionary vocabulary and side-effect-free record selectors.
2
+
3
+ TypedDicts describe stored fields, not runtime validation or constructors with
4
+ defaults. Open payloads stay open. Approval fields describe engine-produced
5
+ records, but are optional because persisted approvals accept arbitrary objects
6
+ (including extra keys). See run_validation for the actual acceptance boundary.
7
+
8
+ Selectors borrow records from their argument; they neither copy nor mutate.
9
+ RunStore remains responsible for defensive copies at its public boundaries.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Any, TypedDict
16
+
17
+ from ..exit_codes import ExitCode
18
+ from .run_errors import RunStoreError
19
+
20
+
21
+ RUN_SCHEMA_VERSION = 1
22
+ EVENT_SCHEMA_VERSION = 1
23
+ EVENT_TYPE_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
24
+ RUN_STATUSES = frozenset(
25
+ {"active", "awaiting-agent", "awaiting-approval", "paused", "completed", "failed", "blocked"}
26
+ )
27
+ VISIT_STATUSES = frozenset(
28
+ {"pending", "active", "awaiting-approval", "completed", "failed", "blocked"}
29
+ )
30
+
31
+
32
+ class ArtifactRecord(TypedDict):
33
+ path: str
34
+ sha256: str | None
35
+
36
+
37
+ class SealedArtifactRecord(TypedDict):
38
+ path: str
39
+ sha256: str
40
+
41
+
42
+ class InputArtifactRecord(SealedArtifactRecord):
43
+ reference: str
44
+
45
+
46
+ class PipelineRecord(SealedArtifactRecord):
47
+ id: str
48
+ version: int
49
+
50
+
51
+ class GitRecord(TypedDict):
52
+ base: Any
53
+ head: Any
54
+
55
+
56
+ class ApprovalRecord(TypedDict, total=False):
57
+ run_id: str
58
+ phase_id: str
59
+ visit_number: int
60
+ reason: str
61
+ details: str | None
62
+ artifact_sha256: str
63
+ pipeline_sha256: str
64
+ outcome: str
65
+ status: str
66
+ decision: str | None
67
+ actor: str | None
68
+ requested_at: str
69
+ decided_at: str | None
70
+
71
+
72
+ class VisitRecord(TypedDict):
73
+ phase_id: str
74
+ ordinal: int
75
+ attempt: int
76
+ status: str
77
+ inputs: list[InputArtifactRecord]
78
+ output: ArtifactRecord
79
+ mutation_policy: str
80
+ mutation_baseline: Any
81
+ mutation_result: Any
82
+ validation_checks: list[Any]
83
+ command_results: list[Any]
84
+ chosen_outcome: str | None
85
+ transition_target: str | None
86
+ skill: SealedArtifactRecord
87
+ template: SealedArtifactRecord | None
88
+ started_at: str | None
89
+ completed_at: str | None
90
+ deviations: list[Any]
91
+
92
+
93
+ class RunRecord(TypedDict):
94
+ schema_version: int
95
+ revision: int
96
+ run_id: str
97
+ pipeline: PipelineRecord
98
+ status: str
99
+ current_visit: int | None
100
+ request: SealedArtifactRecord
101
+ git: GitRecord
102
+ created_at: str
103
+ updated_at: str
104
+ visits: list[VisitRecord]
105
+ approvals: list[ApprovalRecord]
106
+ terminal_result: Any
107
+ block_reason: Any
108
+
109
+
110
+ class EventRecord(TypedDict):
111
+ schema_version: int
112
+ sequence: int
113
+ timestamp: str
114
+ run_id: str
115
+ visit_number: int | None
116
+ type: str
117
+ payload: dict[str, Any]
118
+ manifest_revision: int
119
+ manifest_hash: str
120
+
121
+
122
+ def current_visit(manifest: RunRecord) -> VisitRecord | None:
123
+ """Find the current ordinal, including completed visits; tolerate no match."""
124
+
125
+ ordinal = manifest["current_visit"]
126
+ if ordinal is None:
127
+ return None
128
+ return next(
129
+ (visit for visit in manifest["visits"] if visit["ordinal"] == ordinal),
130
+ None,
131
+ )
132
+
133
+
134
+ def latest_approval(
135
+ manifest: RunRecord, status: str, *, visit_number: int | None = None
136
+ ) -> ApprovalRecord | None:
137
+ """Find the last matching approval; omitted visit means any visit."""
138
+
139
+ return next(
140
+ (
141
+ approval
142
+ for approval in reversed(manifest["approvals"])
143
+ if (visit_number is None or approval.get("visit_number") == visit_number)
144
+ and approval.get("status") == status
145
+ ),
146
+ None,
147
+ )
148
+
149
+
150
+ def visit_by_number(manifest: RunRecord, visit_number: int) -> VisitRecord:
151
+ for visit in manifest["visits"]:
152
+ if visit["ordinal"] == visit_number:
153
+ return visit
154
+ raise RunStoreError(
155
+ f"visit not found: {visit_number}",
156
+ diagnostic_code="visit-not-found",
157
+ details={"visit_number": visit_number},
158
+ code=ExitCode.NOT_FOUND,
159
+ )
@@ -0,0 +1,241 @@
1
+ """Pure checks for persisted dictionaries, preserving version-one acceptance.
2
+
3
+ Annotations are not validators. These checks intentionally retain the legacy
4
+ weak nested-field checks; see docs/implementation-history/refactoring/03-persisted-records.md. Event
5
+ sequence/revision continuity and manifest hashes remain store responsibilities.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime
12
+ from typing import Any
13
+
14
+ from ..hashing import SHA256_PATTERN, normalize_relative_path
15
+ from .run_errors import RunCorruptionError
16
+ from .run_identity import is_valid_run_id
17
+ from .run_records import (
18
+ EVENT_SCHEMA_VERSION, EVENT_TYPE_PATTERN, RUN_SCHEMA_VERSION,
19
+ RUN_STATUSES, VISIT_STATUSES,
20
+ )
21
+
22
+
23
+ def validate_manifest(value: dict[str, Any], run_id: str) -> None:
24
+ required = {
25
+ "schema_version",
26
+ "revision",
27
+ "run_id",
28
+ "pipeline",
29
+ "status",
30
+ "current_visit",
31
+ "request",
32
+ "git",
33
+ "created_at",
34
+ "updated_at",
35
+ "visits",
36
+ "approvals",
37
+ "terminal_result",
38
+ "block_reason",
39
+ }
40
+ if set(value) != required:
41
+ invalid_manifest(run_id, "manifest fields do not match the schema")
42
+ if value["schema_version"] != RUN_SCHEMA_VERSION:
43
+ invalid_manifest(run_id, "unsupported run schema version")
44
+ if not isinstance(value["revision"], int) or value["revision"] < 1:
45
+ invalid_manifest(run_id, "manifest revision must be positive")
46
+ if value["run_id"] != run_id or not is_valid_run_id(value["run_id"]):
47
+ invalid_manifest(run_id, "manifest run ID is invalid")
48
+ if not isinstance(value["status"], str) or value["status"] not in RUN_STATUSES:
49
+ invalid_manifest(run_id, "manifest status is invalid")
50
+ validate_timestamp(value["created_at"], run_id)
51
+ validate_timestamp(value["updated_at"], run_id)
52
+ pipeline = value["pipeline"]
53
+ if not isinstance(pipeline, dict) or set(pipeline) != {
54
+ "id",
55
+ "version",
56
+ "path",
57
+ "sha256",
58
+ }:
59
+ invalid_manifest(run_id, "pipeline record is invalid")
60
+ if (
61
+ not isinstance(pipeline["id"], str)
62
+ or not isinstance(pipeline["version"], int)
63
+ or pipeline["version"] < 1
64
+ or not isinstance(pipeline["sha256"], str)
65
+ or not SHA256_PATTERN.fullmatch(pipeline["sha256"])
66
+ ):
67
+ invalid_manifest(run_id, "pipeline provenance is invalid")
68
+ validate_path(pipeline["path"], run_id)
69
+ validate_artifact_record(value["request"], run_id, hash_required=True)
70
+ if not isinstance(value["git"], dict) or set(value["git"]) != {"base", "head"}:
71
+ invalid_manifest(run_id, "git metadata is invalid")
72
+ if not isinstance(value["visits"], list):
73
+ invalid_manifest(run_id, "visits must be a list")
74
+ attempts: dict[str, int] = {}
75
+ for expected_ordinal, visit in enumerate(value["visits"], 1):
76
+ if not isinstance(visit, dict):
77
+ invalid_manifest(run_id, "visit record must be an object")
78
+ validate_visit(visit, run_id, expected_ordinal)
79
+ phase_id = visit["phase_id"]
80
+ attempts[phase_id] = attempts.get(phase_id, 0) + 1
81
+ if visit["attempt"] != attempts[phase_id]:
82
+ invalid_manifest(run_id, "visit attempt is not ordered")
83
+ current = value["current_visit"]
84
+ if current is not None and (
85
+ not isinstance(current, int) or current < 1 or current > len(value["visits"])
86
+ ):
87
+ invalid_manifest(run_id, "current visit is invalid")
88
+ if not isinstance(value["approvals"], list) or not all(
89
+ isinstance(item, dict) for item in value["approvals"]
90
+ ):
91
+ invalid_manifest(run_id, "approvals must be an ordered object list")
92
+ try:
93
+ json.dumps(value, allow_nan=False)
94
+ except (TypeError, ValueError) as exc:
95
+ invalid_manifest(run_id, f"manifest is not JSON serializable: {exc}")
96
+
97
+ def validate_visit(
98
+ visit: dict[str, Any], run_id: str, expected_ordinal: int
99
+ ) -> None:
100
+ required = {
101
+ "phase_id",
102
+ "ordinal",
103
+ "attempt",
104
+ "status",
105
+ "inputs",
106
+ "output",
107
+ "mutation_policy",
108
+ "mutation_baseline",
109
+ "mutation_result",
110
+ "validation_checks",
111
+ "command_results",
112
+ "chosen_outcome",
113
+ "transition_target",
114
+ "skill",
115
+ "template",
116
+ "started_at",
117
+ "completed_at",
118
+ "deviations",
119
+ }
120
+ if set(visit) != required:
121
+ invalid_manifest(run_id, "visit fields do not match the schema")
122
+ if (
123
+ not isinstance(visit["phase_id"], str)
124
+ or visit["ordinal"] != expected_ordinal
125
+ or not isinstance(visit["attempt"], int)
126
+ or visit["attempt"] < 1
127
+ or not isinstance(visit["status"], str)
128
+ or visit["status"] not in VISIT_STATUSES
129
+ ):
130
+ invalid_manifest(run_id, "visit identity or status is invalid")
131
+ if not isinstance(visit["inputs"], list):
132
+ invalid_manifest(run_id, "visit inputs must be a list")
133
+ for record in visit["inputs"]:
134
+ validate_artifact_record(record, run_id, hash_required=True, reference=True)
135
+ validate_artifact_record(
136
+ visit["output"], run_id, hash_required=visit["status"] == "completed"
137
+ )
138
+ if visit["status"] != "completed" and visit["output"]["sha256"] is not None:
139
+ invalid_manifest(run_id, "mutable visit output cannot have a sealed hash")
140
+ for name in ("validation_checks", "command_results", "deviations"):
141
+ if not isinstance(visit[name], list):
142
+ invalid_manifest(run_id, f"visit {name} must be a list")
143
+ if not isinstance(visit["skill"], dict):
144
+ invalid_manifest(run_id, "visit skill provenance is invalid")
145
+ validate_provenance(visit["skill"], run_id)
146
+ if visit["template"] is not None:
147
+ if not isinstance(visit["template"], dict):
148
+ invalid_manifest(run_id, "visit template provenance is invalid")
149
+ validate_provenance(visit["template"], run_id)
150
+ if visit["started_at"] is not None:
151
+ validate_timestamp(visit["started_at"], run_id)
152
+ if visit["status"] != "pending" and visit["started_at"] is None:
153
+ invalid_manifest(run_id, "started visit lacks a start timestamp")
154
+ if visit["completed_at"] is not None:
155
+ validate_timestamp(visit["completed_at"], run_id)
156
+ if visit["status"] == "completed" and visit["completed_at"] is None:
157
+ invalid_manifest(run_id, "completed visit lacks a completion timestamp")
158
+
159
+ def validate_artifact_record(
160
+ value: Any,
161
+ run_id: str,
162
+ *,
163
+ hash_required: bool,
164
+ reference: bool = False,
165
+ ) -> None:
166
+ fields = {"path", "sha256", "reference"} if reference else {"path", "sha256"}
167
+ if not isinstance(value, dict) or set(value) != fields:
168
+ invalid_manifest(run_id, "artifact record is invalid")
169
+ validate_path(value["path"], run_id)
170
+ digest = value["sha256"]
171
+ if hash_required and (
172
+ not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest)
173
+ ):
174
+ invalid_manifest(run_id, "artifact hash is invalid")
175
+ if not hash_required and digest is not None:
176
+ invalid_manifest(run_id, "artifact hash must be null until sealed")
177
+ if reference and not isinstance(value["reference"], str):
178
+ invalid_manifest(run_id, "artifact reference is invalid")
179
+
180
+ def validate_provenance(value: dict[str, Any], run_id: str) -> None:
181
+ if set(value) != {"path", "sha256"}:
182
+ invalid_manifest(run_id, "provenance record is invalid")
183
+ validate_path(value["path"], run_id)
184
+ if not isinstance(value["sha256"], str) or not SHA256_PATTERN.fullmatch(
185
+ value["sha256"]
186
+ ):
187
+ invalid_manifest(run_id, "provenance hash is invalid")
188
+
189
+ def validate_event(event: dict[str, Any], run_id: str) -> None:
190
+ required = {
191
+ "schema_version",
192
+ "sequence",
193
+ "timestamp",
194
+ "run_id",
195
+ "visit_number",
196
+ "type",
197
+ "payload",
198
+ "manifest_revision",
199
+ "manifest_hash",
200
+ }
201
+ if set(event) != required:
202
+ raise RunCorruptionError("event fields do not match the schema", run_id=run_id)
203
+ if (
204
+ event["schema_version"] != EVENT_SCHEMA_VERSION
205
+ or event["run_id"] != run_id
206
+ or not isinstance(event["sequence"], int)
207
+ or event["sequence"] < 1
208
+ or not isinstance(event["manifest_revision"], int)
209
+ or event["manifest_revision"] < 1
210
+ or not isinstance(event["type"], str)
211
+ or not EVENT_TYPE_PATTERN.fullmatch(event["type"])
212
+ or not isinstance(event["payload"], dict)
213
+ or not isinstance(event["manifest_hash"], str)
214
+ or not SHA256_PATTERN.fullmatch(event["manifest_hash"])
215
+ ):
216
+ raise RunCorruptionError("event record is invalid", run_id=run_id)
217
+ visit_number = event["visit_number"]
218
+ if visit_number is not None and (
219
+ not isinstance(visit_number, int) or visit_number < 1
220
+ ):
221
+ raise RunCorruptionError("event visit number is invalid", run_id=run_id)
222
+ validate_timestamp(event["timestamp"], run_id)
223
+
224
+ def validate_timestamp(value: Any, run_id: str) -> None:
225
+ if not isinstance(value, str) or not value.endswith("Z"):
226
+ raise RunCorruptionError("timestamp is invalid", run_id=run_id)
227
+ try:
228
+ datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
229
+ except ValueError as exc:
230
+ raise RunCorruptionError("timestamp is invalid", run_id=run_id) from exc
231
+
232
+ def validate_path(value: Any, run_id: str) -> None:
233
+ if not isinstance(value, str):
234
+ raise RunCorruptionError("persisted path is invalid", run_id=run_id)
235
+ try:
236
+ normalize_relative_path(value)
237
+ except ValueError as exc:
238
+ raise RunCorruptionError("persisted path is unsafe", run_id=run_id) from exc
239
+
240
+ def invalid_manifest(run_id: str, message: str) -> None:
241
+ raise RunCorruptionError(message, run_id=run_id)
@@ -0,0 +1,61 @@
1
+ """Read visit resources without locks, state mutation, or durable writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from ..artifacts import ArtifactError, render_output_path, resolve_input_reference
9
+ from .._config.config_models import PhaseConfig, PipelineConfig
10
+ from ..hashing import relative_path, sha256_file
11
+ from .run_records import InputArtifactRecord, RunRecord, SealedArtifactRecord
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class VisitResources:
16
+ """Detached evidence captured from borrowed state at preparation time."""
17
+
18
+ inputs: list[InputArtifactRecord]
19
+ output_path: str
20
+ skill: SealedArtifactRecord
21
+ template: SealedArtifactRecord | None
22
+
23
+
24
+ def collect_resources(
25
+ directory: Path,
26
+ manifest: RunRecord,
27
+ pipeline: PipelineConfig,
28
+ phase: PhaseConfig,
29
+ *,
30
+ repository_root: Path,
31
+ ordinal: int,
32
+ ) -> VisitResources:
33
+ """Resolve inputs, reject output collisions, then capture skill/template hashes.
34
+
35
+ The store supplies the current or sealed proposed manifest under its lock.
36
+ This helper neither changes that manifest nor coordinates external writers.
37
+ """
38
+
39
+ inputs = [
40
+ resolve_input_reference(reference, manifest, directory)
41
+ for reference in phase.inputs
42
+ ]
43
+ output_path = render_output_path(pipeline, phase, ordinal)
44
+ reserved_paths = {visit["output"]["path"] for visit in manifest["visits"]}
45
+ if output_path in reserved_paths or (directory / output_path).exists():
46
+ raise ArtifactError(
47
+ f"visit output path would overwrite an earlier artifact: {output_path}",
48
+ diagnostic_code="artifact-path-collision",
49
+ details={"path": output_path, "visit_number": ordinal},
50
+ )
51
+ skill = {
52
+ "path": relative_path(phase.skill_path, repository_root),
53
+ "sha256": sha256_file(phase.skill_path),
54
+ }
55
+ template = None
56
+ if phase.output_template_path is not None:
57
+ template = {
58
+ "path": relative_path(phase.output_template_path, repository_root),
59
+ "sha256": sha256_file(phase.output_template_path),
60
+ }
61
+ return VisitResources(inputs, output_path, skill, template)