stepfork 0.1.0a2__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.
- stepfork/__init__.py +32 -0
- stepfork/__main__.py +6 -0
- stepfork/cli/__init__.py +1 -0
- stepfork/cli/diff.py +156 -0
- stepfork/cli/export.py +210 -0
- stepfork/cli/inspect.py +155 -0
- stepfork/cli/main.py +54 -0
- stepfork/cli/replay.py +221 -0
- stepfork/cli/validate.py +160 -0
- stepfork/diff/__init__.py +25 -0
- stepfork/diff/compare.py +144 -0
- stepfork/diff/engine.py +267 -0
- stepfork/diff/models.py +63 -0
- stepfork/export/__init__.py +20 -0
- stepfork/export/entrypoint.py +58 -0
- stepfork/export/generator.py +178 -0
- stepfork/export/runtime.py +101 -0
- stepfork/fork/__init__.py +4 -0
- stepfork/inspect/__init__.py +11 -0
- stepfork/inspect/inspector.py +143 -0
- stepfork/inspect/models.py +42 -0
- stepfork/minimize/__init__.py +4 -0
- stepfork/py.typed +0 -0
- stepfork/recorder/__init__.py +21 -0
- stepfork/recorder/llm.py +68 -0
- stepfork/recorder/session.py +500 -0
- stepfork/recorder/tooling.py +208 -0
- stepfork/replay/__init__.py +32 -0
- stepfork/replay/exceptions.py +37 -0
- stepfork/replay/plan.py +282 -0
- stepfork/replay/session.py +338 -0
- stepfork/trace/__init__.py +115 -0
- stepfork/trace/canonical.py +55 -0
- stepfork/trace/hashing.py +75 -0
- stepfork/trace/integrity.py +196 -0
- stepfork/trace/jsonable.py +97 -0
- stepfork/trace/manifest.py +85 -0
- stepfork/trace/models.py +271 -0
- stepfork/trace/redaction.py +294 -0
- stepfork/trace/replay_policy.py +18 -0
- stepfork/trace/schema.py +25 -0
- stepfork/trace/storage.py +250 -0
- stepfork/trace/validation.py +285 -0
- stepfork/version.py +5 -0
- stepfork-0.1.0a2.dist-info/METADATA +377 -0
- stepfork-0.1.0a2.dist-info/RECORD +49 -0
- stepfork-0.1.0a2.dist-info/WHEEL +4 -0
- stepfork-0.1.0a2.dist-info/entry_points.txt +2 -0
- stepfork-0.1.0a2.dist-info/licenses/LICENSE +184 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Filesystem persistence for `.sftrace` directory bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import TypeAdapter, ValidationError
|
|
11
|
+
|
|
12
|
+
from stepfork.trace.hashing import hash_event_payloads
|
|
13
|
+
from stepfork.trace.manifest import (
|
|
14
|
+
EnvironmentInfo,
|
|
15
|
+
FailureInfo,
|
|
16
|
+
RunStatus,
|
|
17
|
+
TraceManifest,
|
|
18
|
+
TraceTotals,
|
|
19
|
+
)
|
|
20
|
+
from stepfork.trace.models import Event, EventType, Trace
|
|
21
|
+
from stepfork.trace.redaction import RedactionManifest, prepare_trace_for_persistence
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = "0.1"
|
|
24
|
+
MANIFEST_FILE = "manifest.json"
|
|
25
|
+
EVENTS_FILE = "events.jsonl"
|
|
26
|
+
REDACTIONS_FILE = "redactions.json"
|
|
27
|
+
REQUIRED_FILES = (MANIFEST_FILE, EVENTS_FILE, REDACTIONS_FILE)
|
|
28
|
+
|
|
29
|
+
_event_adapter: TypeAdapter[Event] = TypeAdapter(Event)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TraceStorageError(ValueError):
|
|
33
|
+
"""Raised when a `.sftrace` bundle cannot be saved or loaded."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_manifest(
|
|
37
|
+
trace: Trace,
|
|
38
|
+
*,
|
|
39
|
+
status: RunStatus | str | None = None,
|
|
40
|
+
failure: FailureInfo | None = None,
|
|
41
|
+
environment: EnvironmentInfo | None = None,
|
|
42
|
+
) -> TraceManifest:
|
|
43
|
+
"""Construct a manifest from an in-memory trace."""
|
|
44
|
+
manifest_status = RunStatus(status) if status is not None else trace.status
|
|
45
|
+
manifest_failure = trace.failure if failure is None else failure
|
|
46
|
+
manifest_environment = trace.environment if environment is None else environment
|
|
47
|
+
totals = _computed_totals(trace)
|
|
48
|
+
|
|
49
|
+
return TraceManifest(
|
|
50
|
+
run_id=trace.run_id,
|
|
51
|
+
agent_name=trace.agent_name,
|
|
52
|
+
created_at=trace.created_at,
|
|
53
|
+
status=manifest_status,
|
|
54
|
+
failure=manifest_failure,
|
|
55
|
+
environment=manifest_environment,
|
|
56
|
+
totals=totals,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_trace(
|
|
61
|
+
trace: Trace,
|
|
62
|
+
path: Path,
|
|
63
|
+
*,
|
|
64
|
+
status: RunStatus | str | None = None,
|
|
65
|
+
failure: FailureInfo | None = None,
|
|
66
|
+
environment: EnvironmentInfo | None = None,
|
|
67
|
+
overwrite: bool = False,
|
|
68
|
+
) -> Path:
|
|
69
|
+
"""Persist a trace as a `.sftrace` directory and return its path."""
|
|
70
|
+
path = path.expanduser()
|
|
71
|
+
_require_sftrace_suffix(path)
|
|
72
|
+
manifest = build_manifest(
|
|
73
|
+
trace,
|
|
74
|
+
status=status,
|
|
75
|
+
failure=failure,
|
|
76
|
+
environment=environment,
|
|
77
|
+
)
|
|
78
|
+
prepared = prepare_trace_for_persistence(trace, manifest)
|
|
79
|
+
persisted_events = [hash_event_payloads(event) for event in prepared.trace.events]
|
|
80
|
+
|
|
81
|
+
if path.exists() and not path.is_dir():
|
|
82
|
+
raise TraceStorageError(f"{path} exists and is not a directory")
|
|
83
|
+
if path.exists() and any(path.iterdir()):
|
|
84
|
+
if not overwrite:
|
|
85
|
+
raise TraceStorageError(f"{path} already exists and is not empty")
|
|
86
|
+
if not _has_required_bundle_files(path):
|
|
87
|
+
raise TraceStorageError(f"{path} is not a valid .sftrace bundle")
|
|
88
|
+
|
|
89
|
+
parent = path.parent
|
|
90
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
temporary = parent / f".{path.name}.tmp-{uuid4().hex}"
|
|
92
|
+
temporary.mkdir()
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
_write_manifest(temporary / MANIFEST_FILE, prepared.manifest)
|
|
96
|
+
_write_events(temporary / EVENTS_FILE, persisted_events)
|
|
97
|
+
_write_redactions(temporary / REDACTIONS_FILE, prepared.redactions)
|
|
98
|
+
from stepfork.trace.integrity import write_bundle_integrity
|
|
99
|
+
|
|
100
|
+
write_bundle_integrity(temporary)
|
|
101
|
+
|
|
102
|
+
if path.exists():
|
|
103
|
+
if any(path.iterdir()):
|
|
104
|
+
backup = parent / f".{path.name}.bak-{uuid4().hex}"
|
|
105
|
+
path.rename(backup)
|
|
106
|
+
try:
|
|
107
|
+
temporary.rename(path)
|
|
108
|
+
except Exception:
|
|
109
|
+
backup.rename(path)
|
|
110
|
+
raise
|
|
111
|
+
shutil.rmtree(backup)
|
|
112
|
+
else:
|
|
113
|
+
path.rmdir()
|
|
114
|
+
temporary.rename(path)
|
|
115
|
+
else:
|
|
116
|
+
temporary.rename(path)
|
|
117
|
+
except Exception:
|
|
118
|
+
if temporary.exists():
|
|
119
|
+
shutil.rmtree(temporary)
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_trace(path: Path) -> Trace:
|
|
126
|
+
"""Load a `.sftrace` directory without repairing invalid data."""
|
|
127
|
+
path = path.expanduser()
|
|
128
|
+
_require_sftrace_suffix(path)
|
|
129
|
+
if not path.exists():
|
|
130
|
+
raise TraceStorageError(f"{path} does not exist")
|
|
131
|
+
if not path.is_dir():
|
|
132
|
+
raise TraceStorageError(f"{path} is not a directory")
|
|
133
|
+
|
|
134
|
+
for filename in REQUIRED_FILES:
|
|
135
|
+
file_path = path / filename
|
|
136
|
+
if not file_path.exists():
|
|
137
|
+
raise TraceStorageError(f"missing required file: {file_path}")
|
|
138
|
+
if not file_path.is_file():
|
|
139
|
+
raise TraceStorageError(f"required path is not a file: {file_path}")
|
|
140
|
+
|
|
141
|
+
manifest = _read_manifest(path / MANIFEST_FILE)
|
|
142
|
+
events = _read_events(path / EVENTS_FILE)
|
|
143
|
+
_read_redactions(path / REDACTIONS_FILE)
|
|
144
|
+
|
|
145
|
+
return Trace(
|
|
146
|
+
run_id=manifest.run_id,
|
|
147
|
+
agent_name=manifest.agent_name,
|
|
148
|
+
created_at=manifest.created_at,
|
|
149
|
+
status=manifest.status,
|
|
150
|
+
failure=manifest.failure,
|
|
151
|
+
environment=manifest.environment,
|
|
152
|
+
totals=manifest.totals,
|
|
153
|
+
events=events,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _computed_totals(trace: Trace) -> TraceTotals:
|
|
158
|
+
return TraceTotals(
|
|
159
|
+
events=len(trace.events),
|
|
160
|
+
llm_calls=sum(event.type is EventType.LLM_REQUEST for event in trace.events),
|
|
161
|
+
tool_calls=sum(event.type is EventType.TOOL_CALL for event in trace.events),
|
|
162
|
+
cost_usd=trace.totals.cost_usd,
|
|
163
|
+
duration_ms=trace.totals.duration_ms,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _require_sftrace_suffix(path: Path) -> None:
|
|
168
|
+
if path.suffix != ".sftrace":
|
|
169
|
+
raise TraceStorageError("trace bundle path must end with .sftrace")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _has_required_bundle_files(path: Path) -> bool:
|
|
173
|
+
return all((path / filename).is_file() for filename in REQUIRED_FILES)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _write_manifest(path: Path, manifest: TraceManifest) -> None:
|
|
177
|
+
path.write_text(
|
|
178
|
+
manifest.model_dump_json(indent=2) + "\n",
|
|
179
|
+
encoding="utf-8",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _write_events(path: Path, events: list[Event]) -> None:
|
|
184
|
+
with path.open("w", encoding="utf-8") as file:
|
|
185
|
+
for event in events:
|
|
186
|
+
file.write(_event_adapter.dump_json(event).decode("utf-8"))
|
|
187
|
+
file.write("\n")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _write_redactions(path: Path, redactions: RedactionManifest) -> None:
|
|
191
|
+
path.write_text(
|
|
192
|
+
redactions.model_dump_json(indent=2) + "\n",
|
|
193
|
+
encoding="utf-8",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _read_manifest(path: Path) -> TraceManifest:
|
|
198
|
+
try:
|
|
199
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
200
|
+
except json.JSONDecodeError as exc:
|
|
201
|
+
raise TraceStorageError(f"{path}: invalid JSON: {exc.msg}") from exc
|
|
202
|
+
|
|
203
|
+
if not isinstance(payload, dict):
|
|
204
|
+
raise TraceStorageError(f"{path}: invalid manifest: expected object")
|
|
205
|
+
if payload.get("schema_version") != SCHEMA_VERSION:
|
|
206
|
+
raise TraceStorageError(
|
|
207
|
+
f"{path}: unsupported schema version {payload.get('schema_version')!r}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
return TraceManifest.model_validate(payload)
|
|
212
|
+
except ValidationError as exc:
|
|
213
|
+
raise TraceStorageError(f"{path}: invalid manifest") from exc
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _read_events(path: Path) -> list[Event]:
|
|
217
|
+
events: list[Event] = []
|
|
218
|
+
with path.open(encoding="utf-8") as file:
|
|
219
|
+
for line_number, line in enumerate(file, start=1):
|
|
220
|
+
if line.strip() == "":
|
|
221
|
+
raise TraceStorageError(f"{path}:{line_number}: blank JSONL line")
|
|
222
|
+
try:
|
|
223
|
+
payload = json.loads(line)
|
|
224
|
+
except json.JSONDecodeError as exc:
|
|
225
|
+
raise TraceStorageError(
|
|
226
|
+
f"{path}:{line_number}: invalid JSON: {exc.msg}"
|
|
227
|
+
) from exc
|
|
228
|
+
try:
|
|
229
|
+
events.append(_event_adapter.validate_python(payload))
|
|
230
|
+
except ValidationError as exc:
|
|
231
|
+
raise TraceStorageError(f"{path}:{line_number}: invalid event") from exc
|
|
232
|
+
return events
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _read_redactions(path: Path) -> None:
|
|
236
|
+
try:
|
|
237
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
238
|
+
except json.JSONDecodeError as exc:
|
|
239
|
+
raise TraceStorageError(f"{path}: invalid JSON: {exc.msg}") from exc
|
|
240
|
+
if not isinstance(payload, dict):
|
|
241
|
+
raise TraceStorageError(f"{path}: invalid redactions object")
|
|
242
|
+
if payload.get("schema_version") != SCHEMA_VERSION:
|
|
243
|
+
raise TraceStorageError(
|
|
244
|
+
f"{path}: unsupported redactions schema version "
|
|
245
|
+
f"{payload.get('schema_version')!r}"
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
RedactionManifest.model_validate(payload)
|
|
249
|
+
except ValidationError as exc:
|
|
250
|
+
raise TraceStorageError(f"{path}: invalid redactions metadata") from exc
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Structured validation for Stepfork traces and `.sftrace` bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
9
|
+
|
|
10
|
+
from stepfork.trace.hashing import (
|
|
11
|
+
event_payload_hash_fields,
|
|
12
|
+
verify_event_payload_hashes,
|
|
13
|
+
)
|
|
14
|
+
from stepfork.trace.integrity import IntegrityStatus, verify_bundle_integrity
|
|
15
|
+
from stepfork.trace.manifest import RunStatus
|
|
16
|
+
from stepfork.trace.models import EventType, Trace
|
|
17
|
+
from stepfork.trace.storage import REQUIRED_FILES, TraceStorageError, load_trace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationIssue(BaseModel):
|
|
21
|
+
"""Machine-readable validation issue."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
code: str
|
|
26
|
+
message: str
|
|
27
|
+
event_id: str | None = None
|
|
28
|
+
step: int | None = None
|
|
29
|
+
location: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ValidationResult(BaseModel):
|
|
33
|
+
"""Structured validation result."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
valid: bool = True
|
|
38
|
+
issues: list[ValidationIssue]
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def valid_matches_issues(self) -> Self:
|
|
42
|
+
"""Keep `valid` derived from the issue list."""
|
|
43
|
+
self.valid = not self.issues
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_trace(
|
|
48
|
+
trace: Trace,
|
|
49
|
+
*,
|
|
50
|
+
strict: bool = True,
|
|
51
|
+
verify_payload_hashes: bool = False,
|
|
52
|
+
) -> ValidationResult:
|
|
53
|
+
"""Validate cross-event structure for an in-memory trace."""
|
|
54
|
+
issues: list[ValidationIssue] = []
|
|
55
|
+
|
|
56
|
+
if trace.totals.events != len(trace.events):
|
|
57
|
+
issues.append(
|
|
58
|
+
ValidationIssue(
|
|
59
|
+
code="event_count_mismatch",
|
|
60
|
+
message=(
|
|
61
|
+
"Manifest event count "
|
|
62
|
+
f"{trace.totals.events} does not match {len(trace.events)}."
|
|
63
|
+
),
|
|
64
|
+
location="manifest.json:totals.events",
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
seen: dict[str, int] = {}
|
|
69
|
+
first_positions: dict[str, int] = {}
|
|
70
|
+
for index, event in enumerate(trace.events):
|
|
71
|
+
first_positions.setdefault(event.id, index)
|
|
72
|
+
previous_step: int | None = None
|
|
73
|
+
run_start_count = 0
|
|
74
|
+
has_error_event = False
|
|
75
|
+
|
|
76
|
+
for index, event in enumerate(trace.events):
|
|
77
|
+
if event.id in seen:
|
|
78
|
+
issues.append(
|
|
79
|
+
ValidationIssue(
|
|
80
|
+
code="duplicate_event_id",
|
|
81
|
+
message=f"Event {event.id} appears more than once.",
|
|
82
|
+
event_id=event.id,
|
|
83
|
+
step=event.step,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
seen[event.id] = index
|
|
88
|
+
|
|
89
|
+
if event.run_id != trace.run_id:
|
|
90
|
+
issues.append(
|
|
91
|
+
ValidationIssue(
|
|
92
|
+
code="run_id_mismatch",
|
|
93
|
+
message=(
|
|
94
|
+
f"Event {event.id} run_id {event.run_id!r} does not "
|
|
95
|
+
f"match manifest run_id {trace.run_id!r}."
|
|
96
|
+
),
|
|
97
|
+
event_id=event.id,
|
|
98
|
+
step=event.step,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if previous_step is not None and event.step < previous_step:
|
|
103
|
+
issues.append(
|
|
104
|
+
ValidationIssue(
|
|
105
|
+
code="step_order",
|
|
106
|
+
message=(
|
|
107
|
+
f"Event {event.id} step {event.step} is less than "
|
|
108
|
+
f"the previous step {previous_step}."
|
|
109
|
+
),
|
|
110
|
+
event_id=event.id,
|
|
111
|
+
step=event.step,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
previous_step = event.step
|
|
115
|
+
|
|
116
|
+
if event.parent_id is not None:
|
|
117
|
+
parent_index = first_positions.get(event.parent_id)
|
|
118
|
+
if parent_index is None:
|
|
119
|
+
issues.append(
|
|
120
|
+
ValidationIssue(
|
|
121
|
+
code="invalid_parent",
|
|
122
|
+
message=(
|
|
123
|
+
f"Event {event.id} references missing parent "
|
|
124
|
+
f"{event.parent_id}."
|
|
125
|
+
),
|
|
126
|
+
event_id=event.id,
|
|
127
|
+
step=event.step,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
elif parent_index >= index:
|
|
131
|
+
issues.append(
|
|
132
|
+
ValidationIssue(
|
|
133
|
+
code="parent_not_earlier",
|
|
134
|
+
message=(
|
|
135
|
+
f"Event {event.id} parent {event.parent_id} does "
|
|
136
|
+
"not appear earlier in the trace."
|
|
137
|
+
),
|
|
138
|
+
event_id=event.id,
|
|
139
|
+
step=event.step,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if event.type is EventType.RUN_START:
|
|
144
|
+
run_start_count += 1
|
|
145
|
+
if event.type is EventType.ERROR:
|
|
146
|
+
has_error_event = True
|
|
147
|
+
|
|
148
|
+
if verify_payload_hashes:
|
|
149
|
+
expected_hash_fields = event_payload_hash_fields(event)
|
|
150
|
+
for field in sorted(expected_hash_fields):
|
|
151
|
+
if getattr(event, field) is None:
|
|
152
|
+
issues.append(
|
|
153
|
+
ValidationIssue(
|
|
154
|
+
code="integrity_unverified",
|
|
155
|
+
message=f"Event {event.id} is missing {field}.",
|
|
156
|
+
event_id=event.id,
|
|
157
|
+
step=event.step,
|
|
158
|
+
location=f"events.jsonl:{field}",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
for field in verify_event_payload_hashes(event):
|
|
162
|
+
issues.append(
|
|
163
|
+
ValidationIssue(
|
|
164
|
+
code="payload_hash_mismatch",
|
|
165
|
+
message=f"Event {event.id} has a {field} mismatch.",
|
|
166
|
+
event_id=event.id,
|
|
167
|
+
step=event.step,
|
|
168
|
+
location=f"events.jsonl:{field}",
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if strict and run_start_count != 1:
|
|
173
|
+
issues.append(
|
|
174
|
+
ValidationIssue(
|
|
175
|
+
code="run_start_count",
|
|
176
|
+
message=(
|
|
177
|
+
"Strict validation requires exactly one run_start; "
|
|
178
|
+
f"found {run_start_count}."
|
|
179
|
+
),
|
|
180
|
+
location="events.jsonl",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if strict and trace.status is RunStatus.FAILED and not has_error_event:
|
|
185
|
+
issues.append(
|
|
186
|
+
ValidationIssue(
|
|
187
|
+
code="missing_error_event",
|
|
188
|
+
message=(
|
|
189
|
+
"Failed traces require at least one error event in strict mode."
|
|
190
|
+
),
|
|
191
|
+
location="events.jsonl",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return ValidationResult(issues=issues)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def validate_bundle(
|
|
199
|
+
path: str | Path,
|
|
200
|
+
*,
|
|
201
|
+
strict: bool = True,
|
|
202
|
+
verify_integrity: bool = False,
|
|
203
|
+
) -> ValidationResult:
|
|
204
|
+
"""Load and validate a `.sftrace` bundle."""
|
|
205
|
+
bundle_path = Path(path)
|
|
206
|
+
missing = _missing_files(bundle_path)
|
|
207
|
+
if missing:
|
|
208
|
+
return ValidationResult(
|
|
209
|
+
issues=[
|
|
210
|
+
ValidationIssue(
|
|
211
|
+
code="missing_file",
|
|
212
|
+
message=f"Missing required file: {file_path}.",
|
|
213
|
+
location=str(file_path),
|
|
214
|
+
)
|
|
215
|
+
for file_path in missing
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
trace = load_trace(bundle_path)
|
|
221
|
+
except TraceStorageError as exc:
|
|
222
|
+
message = str(exc)
|
|
223
|
+
return ValidationResult(
|
|
224
|
+
issues=[
|
|
225
|
+
ValidationIssue(
|
|
226
|
+
code=_storage_error_code(message),
|
|
227
|
+
message=message,
|
|
228
|
+
location=_storage_error_location(message),
|
|
229
|
+
)
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
result = validate_trace(
|
|
234
|
+
trace,
|
|
235
|
+
strict=strict,
|
|
236
|
+
verify_payload_hashes=verify_integrity,
|
|
237
|
+
)
|
|
238
|
+
if not verify_integrity:
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
integrity_result = verify_bundle_integrity(bundle_path)
|
|
242
|
+
integrity_issues = [
|
|
243
|
+
ValidationIssue(
|
|
244
|
+
code=issue.code,
|
|
245
|
+
message=issue.message,
|
|
246
|
+
location=issue.file,
|
|
247
|
+
)
|
|
248
|
+
for issue in integrity_result.issues
|
|
249
|
+
]
|
|
250
|
+
if integrity_result.status is IntegrityStatus.VERIFIED:
|
|
251
|
+
return result
|
|
252
|
+
return ValidationResult(issues=[*result.issues, *integrity_issues])
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _missing_files(path: Path) -> list[Path]:
|
|
256
|
+
if not path.exists() or not path.is_dir():
|
|
257
|
+
return [path]
|
|
258
|
+
return [
|
|
259
|
+
path / filename
|
|
260
|
+
for filename in REQUIRED_FILES
|
|
261
|
+
if not (path / filename).is_file()
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _storage_error_code(message: str) -> str:
|
|
266
|
+
if "unsupported" in message:
|
|
267
|
+
return "unsupported_schema"
|
|
268
|
+
if "invalid JSON" in message or "blank JSONL line" in message:
|
|
269
|
+
return "invalid_json"
|
|
270
|
+
if "invalid manifest" in message:
|
|
271
|
+
return "invalid_manifest"
|
|
272
|
+
if "invalid event" in message:
|
|
273
|
+
return "invalid_event"
|
|
274
|
+
if "missing required file" in message:
|
|
275
|
+
return "missing_file"
|
|
276
|
+
return "invalid_manifest"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _storage_error_location(message: str) -> str | None:
|
|
280
|
+
if ":" not in message:
|
|
281
|
+
return None
|
|
282
|
+
first, second, *_ = message.split(":")
|
|
283
|
+
if second.isdigit():
|
|
284
|
+
return f"{first}:{second}"
|
|
285
|
+
return first
|