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,278 @@
|
|
|
1
|
+
"""Coordinate approval requests, grants, invalidation, and continuation.
|
|
2
|
+
|
|
3
|
+
The caller supplies a strictly loaded manifest for requests. Grants deliberately
|
|
4
|
+
load persisted state here before checking bindings, so drift can be audited.
|
|
5
|
+
Validation collects evidence and records expected failures through an explicit
|
|
6
|
+
collaborator; successful evidence is committed with the request or transition.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import responses
|
|
15
|
+
from .approvals import (
|
|
16
|
+
approval_integrity_errors, approved_for_phase, approved_record,
|
|
17
|
+
build_approval_record, conflict, grant_decision, invalidate_decision,
|
|
18
|
+
pending_approval, request_decision, require_binding,
|
|
19
|
+
)
|
|
20
|
+
from ..artifacts import ArtifactError, artifact_record
|
|
21
|
+
from .command_decisions import declared_reason, illegal_transition, select_outcome
|
|
22
|
+
from .._config.config_models import PhaseConfig, PipelineConfig
|
|
23
|
+
from .engine_errors import EngineError
|
|
24
|
+
from ..exit_codes import ExitCode
|
|
25
|
+
from .provenance import observe_provenance
|
|
26
|
+
from .._runs.run_records import ApprovalRecord, RunRecord, VisitRecord, current_visit
|
|
27
|
+
from ..run_store import RunStore
|
|
28
|
+
from .validation_service import ValidationResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApprovalService:
|
|
32
|
+
"""Own approval ordering with explicit validation and timestamp seams."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
pipeline: PipelineConfig,
|
|
37
|
+
store: RunStore,
|
|
38
|
+
*,
|
|
39
|
+
validate: Callable[[RunRecord, VisitRecord, PhaseConfig], ValidationResult],
|
|
40
|
+
timestamp: Callable[[], str],
|
|
41
|
+
) -> None:
|
|
42
|
+
self.pipeline = pipeline
|
|
43
|
+
self.store = store
|
|
44
|
+
self._perform_validation = validate
|
|
45
|
+
self._timestamp = timestamp
|
|
46
|
+
|
|
47
|
+
def invalidate_approval(
|
|
48
|
+
self,
|
|
49
|
+
manifest: RunRecord,
|
|
50
|
+
visit: VisitRecord,
|
|
51
|
+
approval: ApprovalRecord,
|
|
52
|
+
mismatches: list[str],
|
|
53
|
+
) -> None:
|
|
54
|
+
decided_at = self._timestamp()
|
|
55
|
+
|
|
56
|
+
self.store.decide_approval(
|
|
57
|
+
manifest["run_id"],
|
|
58
|
+
lambda value: invalidate_decision(
|
|
59
|
+
value, approval, decided_at=decided_at, mismatches=mismatches,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def advance_approved(
|
|
64
|
+
self,
|
|
65
|
+
manifest: RunRecord,
|
|
66
|
+
visit: VisitRecord,
|
|
67
|
+
approval: ApprovalRecord,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
require_binding(manifest, approval)
|
|
70
|
+
transition = next((
|
|
71
|
+
item for item in self.pipeline.phase(visit["phase_id"]).transitions
|
|
72
|
+
if item.outcome == approval["outcome"]
|
|
73
|
+
), None)
|
|
74
|
+
decision = self.store.decide_approval(
|
|
75
|
+
manifest["run_id"], lambda value: grant_decision(
|
|
76
|
+
value, approval, transition_target=transition.target if transition else None,
|
|
77
|
+
decided_at=None,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
manifest, approval = decision.manifest, decision.approval
|
|
81
|
+
if decision.disposition == "completed":
|
|
82
|
+
return responses.response_for_state(
|
|
83
|
+
self.pipeline, manifest, "approval was already recorded"
|
|
84
|
+
)
|
|
85
|
+
visit = current_visit(manifest)
|
|
86
|
+
try:
|
|
87
|
+
current_hash = self.artifact_hash(manifest, visit)
|
|
88
|
+
except EngineError:
|
|
89
|
+
current_hash = ""
|
|
90
|
+
mismatches = approval_integrity_errors(
|
|
91
|
+
approval,
|
|
92
|
+
artifact_sha256=current_hash,
|
|
93
|
+
pipeline_sha256=self.pipeline.config_hash,
|
|
94
|
+
)
|
|
95
|
+
if mismatches:
|
|
96
|
+
self.invalidate_approval(manifest, visit, approval, mismatches)
|
|
97
|
+
raise EngineError(
|
|
98
|
+
ExitCode.APPROVAL_REQUIRED,
|
|
99
|
+
"approval is stale because its artifact or pipeline changed",
|
|
100
|
+
"stale-approval",
|
|
101
|
+
phase=visit["phase_id"],
|
|
102
|
+
visit_number=visit["ordinal"],
|
|
103
|
+
mismatches=mismatches,
|
|
104
|
+
)
|
|
105
|
+
if transition is None:
|
|
106
|
+
raise conflict(approval)
|
|
107
|
+
phase = self.pipeline.phase(visit["phase_id"])
|
|
108
|
+
evidence = self._perform_validation(manifest, visit, phase)
|
|
109
|
+
updated = self.store.transition_visit(
|
|
110
|
+
manifest["run_id"],
|
|
111
|
+
self.pipeline,
|
|
112
|
+
visit["ordinal"],
|
|
113
|
+
outcome=approval["outcome"],
|
|
114
|
+
transition_target=transition.target,
|
|
115
|
+
terminal_result={
|
|
116
|
+
"outcome": approval["outcome"],
|
|
117
|
+
"phase": phase.id,
|
|
118
|
+
"visit_number": visit["ordinal"],
|
|
119
|
+
"approval_reason": approval["reason"],
|
|
120
|
+
},
|
|
121
|
+
validation_checks=evidence.checks,
|
|
122
|
+
mutation_result=evidence.mutation_result,
|
|
123
|
+
command_results=evidence.command_results,
|
|
124
|
+
)
|
|
125
|
+
return responses.after_transition(self.pipeline, updated, approval["outcome"])
|
|
126
|
+
|
|
127
|
+
def request_approval(
|
|
128
|
+
self,
|
|
129
|
+
manifest: RunRecord,
|
|
130
|
+
*,
|
|
131
|
+
reason: str | None,
|
|
132
|
+
details: str | None = None,
|
|
133
|
+
outcome: str | None = None,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
run_id = manifest["run_id"]
|
|
136
|
+
visit = current_visit(manifest)
|
|
137
|
+
if visit is None or visit["status"] not in {"active", "awaiting-approval"}:
|
|
138
|
+
raise illegal_transition("only an active visit can request approval", manifest)
|
|
139
|
+
phase = self.pipeline.phase(visit["phase_id"])
|
|
140
|
+
chosen_reason = declared_reason(reason, phase.approval_conditions, "approval")
|
|
141
|
+
selected_outcome = select_outcome(phase, outcome)
|
|
142
|
+
evidence = self._perform_validation(manifest, visit, phase)
|
|
143
|
+
check = evidence.checks[0]
|
|
144
|
+
existing = pending_approval(manifest, visit["ordinal"])
|
|
145
|
+
if existing is not None:
|
|
146
|
+
require_binding(manifest, existing)
|
|
147
|
+
if not (
|
|
148
|
+
existing["reason"] == chosen_reason
|
|
149
|
+
and existing["details"] == details
|
|
150
|
+
and existing["artifact_sha256"] == check["sha256"]
|
|
151
|
+
and existing["outcome"] == selected_outcome
|
|
152
|
+
):
|
|
153
|
+
raise illegal_transition("a different approval is already pending", manifest)
|
|
154
|
+
approved = approved_record(manifest, visit["ordinal"])
|
|
155
|
+
if approved is not None:
|
|
156
|
+
return self.advance_approved(manifest, visit, approved)
|
|
157
|
+
|
|
158
|
+
requested_at = existing["requested_at"] if existing else self._timestamp()
|
|
159
|
+
approval = build_approval_record(
|
|
160
|
+
run_id=run_id,
|
|
161
|
+
phase_id=phase.id,
|
|
162
|
+
visit_number=visit["ordinal"],
|
|
163
|
+
reason=chosen_reason,
|
|
164
|
+
details=details,
|
|
165
|
+
artifact_sha256=check["sha256"],
|
|
166
|
+
pipeline_sha256=self.pipeline.config_hash,
|
|
167
|
+
outcome=selected_outcome,
|
|
168
|
+
requested_at=requested_at,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
decision = self.store.decide_approval(
|
|
172
|
+
run_id, lambda value: request_decision(
|
|
173
|
+
value, approval, validation_checks=evidence.checks,
|
|
174
|
+
mutation_result=evidence.mutation_result,
|
|
175
|
+
command_results=evidence.command_results,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
return responses.approval_response(
|
|
179
|
+
self.pipeline, decision.manifest, decision.approval,
|
|
180
|
+
"approval is required" if decision.disposition == "requested"
|
|
181
|
+
else "approval is already pending",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def approve(self, run_id: str, phase_id: str) -> dict[str, Any]:
|
|
185
|
+
# Approval verification intentionally loads persisted state before
|
|
186
|
+
# enforcing the current pipeline hash so drift can be recorded as a
|
|
187
|
+
# stale approval instead of becoming an unaudited early error.
|
|
188
|
+
manifest = self.store.load(run_id)
|
|
189
|
+
visit = current_visit(manifest)
|
|
190
|
+
if visit is None:
|
|
191
|
+
return self.idempotent_approval_result(manifest, phase_id)
|
|
192
|
+
if visit["phase_id"] != phase_id or visit["status"] != "awaiting-approval":
|
|
193
|
+
return self.idempotent_approval_result(manifest, phase_id)
|
|
194
|
+
approval = pending_approval(manifest, visit["ordinal"])
|
|
195
|
+
if approval is None:
|
|
196
|
+
approved = approved_record(manifest, visit["ordinal"])
|
|
197
|
+
if approved is not None:
|
|
198
|
+
return self.advance_approved(manifest, visit, approved)
|
|
199
|
+
raise illegal_transition("visit has no pending approval request", manifest)
|
|
200
|
+
require_binding(manifest, approval)
|
|
201
|
+
try:
|
|
202
|
+
current_hash = self.artifact_hash(manifest, visit)
|
|
203
|
+
except EngineError:
|
|
204
|
+
current_hash = ""
|
|
205
|
+
mismatches = approval_integrity_errors(
|
|
206
|
+
approval,
|
|
207
|
+
artifact_sha256=current_hash,
|
|
208
|
+
pipeline_sha256=self.pipeline.config_hash,
|
|
209
|
+
)
|
|
210
|
+
if mismatches:
|
|
211
|
+
self.invalidate_approval(manifest, visit, approval, mismatches)
|
|
212
|
+
raise EngineError(
|
|
213
|
+
ExitCode.APPROVAL_REQUIRED,
|
|
214
|
+
"approval request is stale because its artifact or pipeline changed",
|
|
215
|
+
"stale-approval",
|
|
216
|
+
phase=phase_id,
|
|
217
|
+
visit_number=visit["ordinal"],
|
|
218
|
+
mismatches=mismatches,
|
|
219
|
+
)
|
|
220
|
+
provenance_warnings = observe_provenance(self.pipeline, manifest)
|
|
221
|
+
if provenance_warnings:
|
|
222
|
+
warning = provenance_warnings[0]
|
|
223
|
+
raise EngineError(
|
|
224
|
+
ExitCode.INTERNAL_ERROR,
|
|
225
|
+
warning["message"],
|
|
226
|
+
warning["code"],
|
|
227
|
+
**warning.get("details", {}),
|
|
228
|
+
)
|
|
229
|
+
decided_at = self._timestamp()
|
|
230
|
+
|
|
231
|
+
transition = next((
|
|
232
|
+
item for item in self.pipeline.phase(phase_id).transitions
|
|
233
|
+
if item.outcome == approval["outcome"]
|
|
234
|
+
), None)
|
|
235
|
+
if transition is None:
|
|
236
|
+
raise conflict(approval)
|
|
237
|
+
decision = self.store.decide_approval(
|
|
238
|
+
run_id, lambda value: grant_decision(
|
|
239
|
+
value, approval, decided_at=decided_at, transition_target=transition.target,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
if decision.disposition == "completed":
|
|
243
|
+
return responses.response_for_state(
|
|
244
|
+
self.pipeline, decision.manifest, "approval was already recorded"
|
|
245
|
+
)
|
|
246
|
+
return self.advance_approved(
|
|
247
|
+
decision.manifest, current_visit(decision.manifest), decision.approval,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def artifact_hash(
|
|
251
|
+
self, manifest: RunRecord, visit: VisitRecord
|
|
252
|
+
) -> str:
|
|
253
|
+
try:
|
|
254
|
+
return artifact_record(
|
|
255
|
+
self.store.run_directory(manifest["run_id"]),
|
|
256
|
+
visit["output"]["path"],
|
|
257
|
+
)["sha256"]
|
|
258
|
+
except (ArtifactError, OSError) as exc:
|
|
259
|
+
details = dict(getattr(exc, "details", None) or {"error": str(exc)})
|
|
260
|
+
details.pop("error_code", None)
|
|
261
|
+
raise EngineError(
|
|
262
|
+
ExitCode.VALIDATION_FAILED,
|
|
263
|
+
"output artifact is missing or unreadable",
|
|
264
|
+
"invalid-output",
|
|
265
|
+
**details,
|
|
266
|
+
) from exc
|
|
267
|
+
|
|
268
|
+
def idempotent_approval_result(
|
|
269
|
+
self, manifest: RunRecord, phase_id: str
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
approved = approved_for_phase(manifest, phase_id)
|
|
272
|
+
if approved is None:
|
|
273
|
+
raise illegal_transition(
|
|
274
|
+
f"phase {phase_id!r} has no pending approval", manifest
|
|
275
|
+
)
|
|
276
|
+
return responses.response_for_state(
|
|
277
|
+
self.pipeline, manifest, "approval was already recorded"
|
|
278
|
+
)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Pure approval selectors, integrity checks, and detached state changes.
|
|
2
|
+
|
|
3
|
+
Callers supply timestamps and observations; decisions run against locked state.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .engine_errors import EngineError
|
|
13
|
+
from ..exit_codes import ExitCode
|
|
14
|
+
from .._runs.run_records import ApprovalRecord, RunRecord, VisitRecord, current_visit, latest_approval
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_approval_record(
|
|
18
|
+
*,
|
|
19
|
+
run_id: str,
|
|
20
|
+
phase_id: str,
|
|
21
|
+
visit_number: int,
|
|
22
|
+
reason: str,
|
|
23
|
+
details: str | None,
|
|
24
|
+
artifact_sha256: str,
|
|
25
|
+
pipeline_sha256: str,
|
|
26
|
+
outcome: str,
|
|
27
|
+
requested_at: str,
|
|
28
|
+
) -> ApprovalRecord:
|
|
29
|
+
return {
|
|
30
|
+
"run_id": run_id,
|
|
31
|
+
"phase_id": phase_id,
|
|
32
|
+
"visit_number": visit_number,
|
|
33
|
+
"reason": reason,
|
|
34
|
+
"details": details,
|
|
35
|
+
"artifact_sha256": artifact_sha256,
|
|
36
|
+
"pipeline_sha256": pipeline_sha256,
|
|
37
|
+
"outcome": outcome,
|
|
38
|
+
"status": "pending",
|
|
39
|
+
"decision": None,
|
|
40
|
+
"actor": None,
|
|
41
|
+
"requested_at": requested_at,
|
|
42
|
+
"decided_at": None,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def approval_integrity_errors(
|
|
47
|
+
approval: ApprovalRecord, *, artifact_sha256: str, pipeline_sha256: str
|
|
48
|
+
) -> list[str]:
|
|
49
|
+
"""Return stable mismatch labels for an approval's bound inputs."""
|
|
50
|
+
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
if approval.get("artifact_sha256") != artifact_sha256:
|
|
53
|
+
errors.append("artifact")
|
|
54
|
+
if approval.get("pipeline_sha256") != pipeline_sha256:
|
|
55
|
+
errors.append("pipeline")
|
|
56
|
+
return errors
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pending_approval(manifest: RunRecord, visit_number: int) -> ApprovalRecord | None:
|
|
60
|
+
return latest_approval(manifest, "pending", visit_number=visit_number)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def approved_record(manifest: RunRecord, visit_number: int) -> ApprovalRecord | None:
|
|
64
|
+
return latest_approval(manifest, "approved", visit_number=visit_number)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def approved_for_phase(manifest: RunRecord, phase_id: str) -> ApprovalRecord | None:
|
|
68
|
+
return next(
|
|
69
|
+
(
|
|
70
|
+
item
|
|
71
|
+
for item in reversed(manifest["approvals"])
|
|
72
|
+
if item.get("phase_id") == phase_id and item.get("status") == "approved"
|
|
73
|
+
),
|
|
74
|
+
None,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def requested_state(
|
|
79
|
+
manifest: RunRecord,
|
|
80
|
+
approval: ApprovalRecord,
|
|
81
|
+
*,
|
|
82
|
+
validation_checks: list[dict[str, Any]],
|
|
83
|
+
command_results: list[dict[str, Any]],
|
|
84
|
+
mutation_result: dict[str, Any] | None,
|
|
85
|
+
) -> RunRecord:
|
|
86
|
+
"""Return a detached pending state using already collected evidence."""
|
|
87
|
+
|
|
88
|
+
updated = copy.deepcopy(manifest)
|
|
89
|
+
current = current_visit(updated)
|
|
90
|
+
assert current is not None
|
|
91
|
+
current["status"] = "awaiting-approval"
|
|
92
|
+
current["validation_checks"] = copy.deepcopy(validation_checks)
|
|
93
|
+
current["mutation_result"] = copy.deepcopy(mutation_result)
|
|
94
|
+
current["command_results"] = copy.deepcopy(command_results)
|
|
95
|
+
updated["status"] = "awaiting-approval"
|
|
96
|
+
updated["approvals"].append(copy.deepcopy(approval))
|
|
97
|
+
return updated
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
IDENTITY_FIELDS = (
|
|
101
|
+
"run_id", "visit_number", "phase_id", "reason", "details", "requested_at",
|
|
102
|
+
"outcome", "pipeline_sha256", "artifact_sha256",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ApprovalDecision:
|
|
108
|
+
"""A detached approval result; absent event means no durable change."""
|
|
109
|
+
|
|
110
|
+
manifest: RunRecord
|
|
111
|
+
approval: ApprovalRecord
|
|
112
|
+
disposition: str
|
|
113
|
+
event_type: str | None = None
|
|
114
|
+
visit_number: int | None = None
|
|
115
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def conflict(observed: ApprovalRecord) -> EngineError:
|
|
119
|
+
return EngineError(
|
|
120
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
121
|
+
"approval state changed before the command could be applied",
|
|
122
|
+
"approval-state-changed",
|
|
123
|
+
run_id=observed.get("run_id"), phase=observed.get("phase_id"),
|
|
124
|
+
visit_number=observed.get("visit_number"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def require_binding(manifest: RunRecord, observed: ApprovalRecord) -> VisitRecord:
|
|
129
|
+
"""Reject incomplete identities or a mismatched containing run/visit."""
|
|
130
|
+
|
|
131
|
+
if any(key not in observed for key in IDENTITY_FIELDS):
|
|
132
|
+
raise conflict(observed)
|
|
133
|
+
visits = [v for v in manifest["visits"]
|
|
134
|
+
if v["ordinal"] == observed["visit_number"]]
|
|
135
|
+
if (manifest["run_id"] != observed["run_id"] or len(visits) != 1
|
|
136
|
+
or visits[0]["phase_id"] != observed["phase_id"]):
|
|
137
|
+
raise conflict(observed)
|
|
138
|
+
return visits[0]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _matches(
|
|
142
|
+
record: ApprovalRecord, observed: ApprovalRecord, *, creation: bool = False,
|
|
143
|
+
) -> bool:
|
|
144
|
+
return all(key in record and record[key] == observed[key]
|
|
145
|
+
for key in IDENTITY_FIELDS if not (creation and key == "requested_at"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _eligible(
|
|
149
|
+
manifest: RunRecord, visit: VisitRecord, *, creation: bool = False,
|
|
150
|
+
) -> bool:
|
|
151
|
+
statuses = ("active", "awaiting-approval") if creation else ("awaiting-approval",)
|
|
152
|
+
return (manifest["current_visit"] == visit["ordinal"]
|
|
153
|
+
and manifest["status"] in statuses and visit["status"] in statuses)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def request_decision(
|
|
157
|
+
manifest: RunRecord, approval: ApprovalRecord, *,
|
|
158
|
+
validation_checks: list[dict[str, Any]], command_results: list[dict[str, Any]],
|
|
159
|
+
mutation_result: dict[str, Any] | None,
|
|
160
|
+
) -> ApprovalDecision:
|
|
161
|
+
"""Create once, or reuse the winning pending request without new evidence."""
|
|
162
|
+
|
|
163
|
+
visit = require_binding(manifest, approval)
|
|
164
|
+
if not _eligible(manifest, visit, creation=True):
|
|
165
|
+
raise conflict(approval)
|
|
166
|
+
records = [a for a in manifest["approvals"]
|
|
167
|
+
if a.get("visit_number") == visit["ordinal"]
|
|
168
|
+
and a.get("status") in ("pending", "approved")]
|
|
169
|
+
if records:
|
|
170
|
+
if (len(records) != 1 or records[0].get("status") != "pending"
|
|
171
|
+
or any(key not in records[0] for key in IDENTITY_FIELDS)
|
|
172
|
+
or not _matches(records[0], approval, creation=True)):
|
|
173
|
+
raise conflict(approval)
|
|
174
|
+
return ApprovalDecision(copy.deepcopy(manifest), copy.deepcopy(records[0]), "pending")
|
|
175
|
+
updated = requested_state(
|
|
176
|
+
manifest, approval, validation_checks=validation_checks,
|
|
177
|
+
command_results=command_results, mutation_result=mutation_result,
|
|
178
|
+
)
|
|
179
|
+
return ApprovalDecision(
|
|
180
|
+
updated, copy.deepcopy(approval), "requested", "approval-requested",
|
|
181
|
+
visit["ordinal"], {"reason": approval["reason"], "outcome": approval["outcome"]},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _identified(
|
|
186
|
+
manifest: RunRecord, observed: ApprovalRecord,
|
|
187
|
+
) -> tuple[VisitRecord, ApprovalRecord]:
|
|
188
|
+
visit = require_binding(manifest, observed)
|
|
189
|
+
records = [a for a in manifest["approvals"] if _matches(a, observed)]
|
|
190
|
+
# A replacement on the same visit must not inherit an earlier decision.
|
|
191
|
+
competing = [a for a in manifest["approvals"]
|
|
192
|
+
if a.get("visit_number") == visit["ordinal"]
|
|
193
|
+
and a.get("status") in ("pending", "approved")
|
|
194
|
+
and not _matches(a, observed)]
|
|
195
|
+
if len(records) != 1 or competing:
|
|
196
|
+
raise conflict(observed)
|
|
197
|
+
record = records[0]
|
|
198
|
+
if any(a.get("visit_number") == visit["ordinal"]
|
|
199
|
+
for a in manifest["approvals"][manifest["approvals"].index(record) + 1:]):
|
|
200
|
+
raise conflict(observed)
|
|
201
|
+
return visit, record
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def grant_decision(
|
|
205
|
+
manifest: RunRecord, observed: ApprovalRecord, *,
|
|
206
|
+
transition_target: str | None, decided_at: str | None,
|
|
207
|
+
) -> ApprovalDecision:
|
|
208
|
+
"""Grant exactly the observed request; None permits only existing grants."""
|
|
209
|
+
|
|
210
|
+
updated = copy.deepcopy(manifest)
|
|
211
|
+
visit, record = _identified(updated, observed)
|
|
212
|
+
if record.get("status") == "approved" and visit["status"] == "completed":
|
|
213
|
+
if (visit["chosen_outcome"] != record["outcome"]
|
|
214
|
+
or visit["transition_target"] != transition_target):
|
|
215
|
+
raise conflict(observed)
|
|
216
|
+
return ApprovalDecision(updated, copy.deepcopy(record), "completed")
|
|
217
|
+
if not _eligible(updated, visit):
|
|
218
|
+
raise conflict(observed)
|
|
219
|
+
if record.get("status") == "approved":
|
|
220
|
+
return ApprovalDecision(updated, copy.deepcopy(record), "granted")
|
|
221
|
+
if record.get("status") != "pending" or decided_at is None:
|
|
222
|
+
raise conflict(observed)
|
|
223
|
+
record.update(status="approved", decision="approved", actor="user", decided_at=decided_at)
|
|
224
|
+
return ApprovalDecision(
|
|
225
|
+
updated, copy.deepcopy(record), "granted", "approval-granted",
|
|
226
|
+
visit["ordinal"], {"phase_id": visit["phase_id"], "actor": "user"},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def invalidate_decision(
|
|
231
|
+
manifest: RunRecord, observed: ApprovalRecord, *, decided_at: str,
|
|
232
|
+
mismatches: list[str],
|
|
233
|
+
) -> ApprovalDecision:
|
|
234
|
+
"""Invalidate only the eligible observed request, including stale retries."""
|
|
235
|
+
|
|
236
|
+
updated = copy.deepcopy(manifest)
|
|
237
|
+
visit, record = _identified(updated, observed)
|
|
238
|
+
if not _eligible(updated, visit):
|
|
239
|
+
raise conflict(observed)
|
|
240
|
+
if record.get("status") == "stale" and record.get("decision") == "invalidated":
|
|
241
|
+
return ApprovalDecision(updated, copy.deepcopy(record), "stale")
|
|
242
|
+
if record.get("status") not in ("pending", "approved"):
|
|
243
|
+
raise conflict(observed)
|
|
244
|
+
record.update(status="stale", decision="invalidated", actor="system", decided_at=decided_at)
|
|
245
|
+
return ApprovalDecision(
|
|
246
|
+
updated, copy.deepcopy(record), "stale", "approval-invalidated",
|
|
247
|
+
visit["ordinal"], {"mismatches": list(mismatches)},
|
|
248
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Shared, pure command argument resolution and transition diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._config.config_models import PhaseConfig
|
|
8
|
+
from .engine_errors import EngineError
|
|
9
|
+
from ..errors import UsageError
|
|
10
|
+
from ..exit_codes import ExitCode
|
|
11
|
+
from .._runs.run_records import RunRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def select_outcome(phase: PhaseConfig, requested: str | None) -> str:
|
|
15
|
+
outcomes = [
|
|
16
|
+
transition.outcome
|
|
17
|
+
for transition in phase.transitions
|
|
18
|
+
if transition.outcome != "blocked"
|
|
19
|
+
]
|
|
20
|
+
if requested is not None:
|
|
21
|
+
if requested not in outcomes:
|
|
22
|
+
raise EngineError(
|
|
23
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
24
|
+
f"outcome {requested!r} is not declared by phase {phase.id!r}",
|
|
25
|
+
"undeclared-outcome",
|
|
26
|
+
phase=phase.id,
|
|
27
|
+
requested=requested,
|
|
28
|
+
declared=outcomes,
|
|
29
|
+
)
|
|
30
|
+
if len(outcomes) <= 1:
|
|
31
|
+
raise EngineError(
|
|
32
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
33
|
+
"--outcome is valid only when the phase has multiple successful outcomes",
|
|
34
|
+
"unnecessary-outcome",
|
|
35
|
+
phase=phase.id,
|
|
36
|
+
declared=outcomes,
|
|
37
|
+
)
|
|
38
|
+
return requested
|
|
39
|
+
if len(outcomes) != 1:
|
|
40
|
+
raise EngineError(
|
|
41
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
42
|
+
f"phase {phase.id!r} requires one of {outcomes!r}",
|
|
43
|
+
"outcome-required",
|
|
44
|
+
phase=phase.id,
|
|
45
|
+
declared=outcomes,
|
|
46
|
+
)
|
|
47
|
+
return outcomes[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def declared_reason(
|
|
51
|
+
requested: str | None, declared: tuple[str, ...], kind: str
|
|
52
|
+
) -> str:
|
|
53
|
+
if requested is None:
|
|
54
|
+
if len(declared) == 1:
|
|
55
|
+
return declared[0]
|
|
56
|
+
raise UsageError(
|
|
57
|
+
f"--reason is required; declared {kind} reasons: {', '.join(declared) or 'none'}",
|
|
58
|
+
{"declared": list(declared)},
|
|
59
|
+
)
|
|
60
|
+
if requested not in declared:
|
|
61
|
+
raise EngineError(
|
|
62
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
63
|
+
f"{kind} reason {requested!r} is not declared",
|
|
64
|
+
f"undeclared-{kind}-reason",
|
|
65
|
+
requested=requested,
|
|
66
|
+
declared=list(declared),
|
|
67
|
+
)
|
|
68
|
+
return requested
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def illegal_transition(
|
|
72
|
+
message: str, manifest: RunRecord, **details: Any
|
|
73
|
+
) -> EngineError:
|
|
74
|
+
return EngineError(
|
|
75
|
+
ExitCode.ILLEGAL_TRANSITION,
|
|
76
|
+
message,
|
|
77
|
+
"illegal-transition",
|
|
78
|
+
status=manifest["status"],
|
|
79
|
+
**details,
|
|
80
|
+
)
|
|
81
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Expected engine rejections, independent of command orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..errors import SpecromancyError
|
|
8
|
+
from ..exit_codes import ExitCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EngineError(SpecromancyError):
|
|
12
|
+
"""An expected state-machine rejection with a stable diagnostic."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
code: ExitCode,
|
|
17
|
+
message: str,
|
|
18
|
+
diagnostic_code: str,
|
|
19
|
+
**details: Any,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(code, message, {"error_code": diagnostic_code, **details})
|
|
22
|
+
self.diagnostic_code = diagnostic_code
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Observe pipeline and prepared resource identity without choosing a policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._config.config_models import PipelineConfig
|
|
8
|
+
from ..hashing import relative_path, sha256_file
|
|
9
|
+
from .._runs.run_records import RunRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def observe_provenance(
|
|
13
|
+
pipeline: PipelineConfig, manifest: RunRecord
|
|
14
|
+
) -> list[dict[str, Any]]:
|
|
15
|
+
warnings: list[dict[str, Any]] = []
|
|
16
|
+
for visit in manifest["visits"]:
|
|
17
|
+
for kind in ("skill", "template"):
|
|
18
|
+
record = visit[kind]
|
|
19
|
+
if record is None:
|
|
20
|
+
continue
|
|
21
|
+
path = pipeline.repository_root / record["path"]
|
|
22
|
+
try:
|
|
23
|
+
actual = sha256_file(path)
|
|
24
|
+
except OSError as exc:
|
|
25
|
+
warnings.append(
|
|
26
|
+
{
|
|
27
|
+
"code": "provenance-missing",
|
|
28
|
+
"message": f"visit {visit['ordinal']} {kind} provenance is missing",
|
|
29
|
+
"details": {"path": record["path"], "error": str(exc)},
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
continue
|
|
33
|
+
if actual != record["sha256"]:
|
|
34
|
+
warnings.append(
|
|
35
|
+
{
|
|
36
|
+
"code": "provenance-hash-mismatch",
|
|
37
|
+
"message": f"visit {visit['ordinal']} {kind} changed after preparation",
|
|
38
|
+
"details": {
|
|
39
|
+
"path": record["path"],
|
|
40
|
+
"expected": record["sha256"],
|
|
41
|
+
"actual": actual,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
return warnings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def pipeline_matches(pipeline: PipelineConfig, manifest: RunRecord) -> bool:
|
|
49
|
+
return manifest["pipeline"] == pipeline_identity(pipeline)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pipeline_identity(pipeline: PipelineConfig) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"id": pipeline.id,
|
|
55
|
+
"version": pipeline.version,
|
|
56
|
+
"path": relative_path(pipeline.path, pipeline.repository_root),
|
|
57
|
+
"sha256": pipeline.config_hash,
|
|
58
|
+
}
|