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