pgc-runtime 2.0.0__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.
@@ -0,0 +1,245 @@
1
+ """
2
+ hint_engine.py — Prescriptive fix hint generation.
3
+
4
+ Governed by: Trace Examiner spec §G4
5
+
6
+ Generates short, concrete, actionable fix hints from failure classification.
7
+ Every hint references a specific artifact and field. No generic messages.
8
+ Pure function — no external imports.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from runtime.examine.classifier import ClassificationResult, FailureClass
16
+ from runtime.examine.parser import ParsedTrace, TraceEventDict
17
+
18
+
19
+ def _payload(event: TraceEventDict) -> dict[str, Any]:
20
+ return event.get("payload", {})
21
+
22
+
23
+ def generate_hint(
24
+ trace: ParsedTrace,
25
+ result: ClassificationResult,
26
+ artifact_path: str | None,
27
+ ) -> str:
28
+ """
29
+ Generate a prescriptive fix hint from classification result.
30
+
31
+ Args:
32
+ trace: Parsed trace.
33
+ result: Classification result.
34
+ artifact_path: Resolved artifact path (may be None).
35
+
36
+ Returns:
37
+ Concrete, actionable fix hint string.
38
+ """
39
+ fc = result.failure_class
40
+ if fc is None:
41
+ return "No action required"
42
+
43
+ node_id = result.node_id or "unknown node"
44
+ error_code = result.error_code or ""
45
+ message = result.message
46
+
47
+ if fc == FailureClass.EXPRESSION_ERROR:
48
+ return _hint_expression_error(node_id, message, artifact_path)
49
+
50
+ if fc == FailureClass.SCHEMA_ERROR:
51
+ return _hint_schema_error(node_id, message, artifact_path)
52
+
53
+ if fc == FailureClass.BINDING_ERROR:
54
+ return _hint_binding_error(node_id, error_code, message, artifact_path)
55
+
56
+ if fc == FailureClass.CT_STRUCTURE_ERROR:
57
+ return _hint_ct_error(node_id, error_code, message, artifact_path, result)
58
+
59
+ if fc == FailureClass.CS_RUNTIME_ERROR:
60
+ return _hint_cs_error(node_id, message, artifact_path)
61
+
62
+ if fc == FailureClass.ADMISSION_ERROR:
63
+ return _hint_admission_error(trace, result, error_code, message, artifact_path)
64
+
65
+ if fc == FailureClass.GRAPH_STRUCTURE_ERROR:
66
+ return _hint_graph_error(trace, result, artifact_path)
67
+
68
+ if fc == FailureClass.BUSINESS_VIOLATION:
69
+ return f"Business violation at {node_id} — this is domain behavior, not a bug"
70
+
71
+ return f"Check {artifact_path or 'workflow artifacts'} for issues at {node_id}"
72
+
73
+
74
+ def _hint_expression_error(
75
+ node_id: str, message: str, artifact_path: str | None
76
+ ) -> str:
77
+ """Hint for EXPRESSION_RESOLUTION_FAILED."""
78
+ # Try to extract the expression path from the message
79
+ # Message format is typically: "Cannot resolve $.payload.field_name"
80
+ expr = _extract_expression(message)
81
+ if expr:
82
+ field_name = expr.split(".")[-1] if "." in expr else expr
83
+ target = f" in {artifact_path}" if artifact_path else ""
84
+ return (
85
+ f"Add '{field_name}' to input payload or fix expression '{expr}' "
86
+ f"in input_bindings for node {node_id}{target}"
87
+ )
88
+ target = f" — check {artifact_path}" if artifact_path else ""
89
+ return f"Fix unresolved expression in input_bindings for node {node_id}{target}"
90
+
91
+
92
+ def _hint_schema_error(
93
+ node_id: str, message: str, artifact_path: str | None
94
+ ) -> str:
95
+ """Hint for SCHEMA_VALIDATION_FAILED."""
96
+ target = f" in {artifact_path}" if artifact_path else ""
97
+ return f"Fix schema violation at node {node_id}{target}: {_truncate(message, 100)}"
98
+
99
+
100
+ def _hint_binding_error(
101
+ node_id: str, error_code: str, message: str, artifact_path: str | None
102
+ ) -> str:
103
+ """Hint for BINDING_RESOLUTION_FAILED."""
104
+ target = f" in {artifact_path}" if artifact_path else ""
105
+ if "missing" in message.lower() or "not found" in message.lower():
106
+ return (
107
+ f"Add missing binding entry for {node_id}{target} — "
108
+ f"check runtime binding and CC pipeline bindings"
109
+ )
110
+ return f"Fix binding resolution for {node_id}{target}: {_truncate(message, 100)}"
111
+
112
+
113
+ def _hint_ct_error(
114
+ node_id: str,
115
+ error_code: str,
116
+ message: str,
117
+ artifact_path: str | None,
118
+ result: ClassificationResult,
119
+ ) -> str:
120
+ """Hint for CT_* errors."""
121
+ target = f" — check {artifact_path}" if artifact_path else ""
122
+
123
+ if error_code == "CT_ARTIFACT_NOT_FOUND":
124
+ artifact_code = _extract_ct_code_from_result(result)
125
+ if artifact_code:
126
+ return f"CT artifact '{artifact_code}' not found{target}. Run build or check atom registration"
127
+ return f"CT artifact not found for {node_id}{target}. Run build or check atom registration"
128
+
129
+ if error_code == "CT_VALIDATION_FAILED":
130
+ return f"CT validation failed for {node_id}{target}: {_truncate(message, 100)}"
131
+
132
+ if error_code == "CT_EXECUTION_FAILED":
133
+ return f"CT execution failed at {node_id}{target}: {_truncate(message, 80)}"
134
+
135
+ if error_code == "CAPABILITY_NOT_FOUND":
136
+ return f"Capability contract '{node_id}' not found in loaded contracts{target}"
137
+
138
+ if error_code == "CAPABILITY_DISPATCH_FAILED":
139
+ return f"Capability dispatch failed for {node_id}{target}: {_truncate(message, 80)}"
140
+
141
+ return f"Transform error at {node_id}{target}: {_truncate(message, 80)}"
142
+
143
+
144
+ def _hint_cs_error(
145
+ node_id: str, message: str, artifact_path: str | None
146
+ ) -> str:
147
+ """Hint for CS_EXECUTION_FAILED."""
148
+ target = f" — check runtime binding at {artifact_path}" if artifact_path else ""
149
+ return f"Side-effect execution failed at {node_id}{target}: {_truncate(message, 80)}"
150
+
151
+
152
+ def _hint_admission_error(
153
+ trace: ParsedTrace,
154
+ result: ClassificationResult,
155
+ error_code: str,
156
+ message: str,
157
+ artifact_path: str | None,
158
+ ) -> str:
159
+ """Hint for ADMISSION_ERROR — missing binding field or admission denial."""
160
+ target = f" — see {artifact_path}" if artifact_path else ""
161
+
162
+ if error_code == "ADMISSION_BINDING_MISSING_FIELD":
163
+ # Extract the missing payload field from the structured message.
164
+ # Message format: "Admission binding for 'EV_...' references payload field 'field_name' ..."
165
+ import re
166
+ field_match = re.search(r"payload field '([^']+)'", message)
167
+ event_match = re.search(r"binding for '([^']+)'", message)
168
+ field = field_match.group(1) if field_match else "unknown"
169
+ event_code = event_match.group(1) if event_match else "unknown"
170
+
171
+ return (
172
+ f"Admission gate for {trace.workflow_code} requires '{field}' in payload{target}. "
173
+ f"Binding: {event_code} → payload.{field}. "
174
+ f"Ensure the caller forwards '{field}' from the upstream workflow that produced it "
175
+ f"(e.g. via output of the registration step)."
176
+ )
177
+
178
+ # Generic admission denial
179
+ return (
180
+ f"Admission denied for {trace.workflow_code}{target}. "
181
+ f"Check admission.requires conditions are satisfied before invoking this workflow. "
182
+ f"Detail: {_truncate(message, 100)}"
183
+ )
184
+
185
+
186
+ def _hint_graph_error(
187
+ trace: ParsedTrace,
188
+ result: ClassificationResult,
189
+ artifact_path: str | None,
190
+ ) -> str:
191
+ """Hint for GRAPH_STRUCTURE_ERROR."""
192
+ target = f" in {artifact_path}" if artifact_path else ""
193
+ exit_reason = trace.exit_reason_code
194
+
195
+ if exit_reason == "NO_TRANSITION":
196
+ # Try to extract from/status from exit_condition message
197
+ condition = trace.exit_condition
198
+ return (
199
+ f"No transition edge found{target}. "
200
+ f"Add missing edge for the result_status in workflow spec. "
201
+ f"Detail: {_truncate(condition, 100)}"
202
+ )
203
+
204
+ if exit_reason == "NO_ENTRY_NODE":
205
+ return f"DAG has no entry node{target}. Check workflow spec node definitions"
206
+
207
+ if exit_reason == "NODE_NOT_FOUND":
208
+ return f"Referenced node missing from DAG{target}. Check edge targets in workflow spec"
209
+
210
+ return f"Graph structure error ({exit_reason}){target}"
211
+
212
+
213
+ # --- Helpers ---
214
+
215
+
216
+ def _extract_expression(message: str) -> str:
217
+ """Try to extract a $.path expression from an error message."""
218
+ # Look for $.payload.xxx or $.capability_result.xxx patterns
219
+ import re
220
+
221
+ match = re.search(r'(\$\.\w+(?:\.\w+)*)', message)
222
+ if match:
223
+ return match.group(1)
224
+ return ""
225
+
226
+
227
+ def _extract_ct_code_from_result(result: ClassificationResult) -> str:
228
+ """Try to extract CT code from result details."""
229
+ if result.root_event is None:
230
+ return ""
231
+ p = _payload(result.root_event)
232
+ details = p.get("details", {})
233
+ if isinstance(details, dict) and "artifact_code" in details:
234
+ return details["artifact_code"]
235
+ node_id = p.get("node_id", "")
236
+ if node_id.startswith("CT_"):
237
+ return node_id
238
+ return ""
239
+
240
+
241
+ def _truncate(s: str, max_len: int) -> str:
242
+ """Truncate string with ellipsis if too long."""
243
+ if len(s) <= max_len:
244
+ return s
245
+ return s[: max_len - 3] + "..."
@@ -0,0 +1,148 @@
1
+ """
2
+ locator.py — Artifact path resolution for failing nodes.
3
+
4
+ Governed by: Trace Examiner spec §G3
5
+
6
+ Maps node_id / capability codes to artifact file paths via snapshot artifacts dir.
7
+
8
+ Snapshot artifacts follow the naming convention: {namespace}__{CODE}.json
9
+ Snapshot root is derived from the trace path by the caller (STRUCTURE layout convention):
10
+ trace_path = {workspace}/traces/{id}/{id}.jsonl
11
+ snapshot = {workspace}/protocol_snapshot
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from runtime.examine.classifier import ClassificationResult, FailureClass
20
+ from runtime.examine.parser import ParsedTrace, TraceEventDict
21
+
22
+
23
+ def _payload(event: TraceEventDict) -> dict[str, Any]:
24
+ return event.get("payload", {})
25
+
26
+
27
+ def _find_node_start(trace: ParsedTrace, node_id: str) -> TraceEventDict | None:
28
+ """Find node_start event for a given node_id."""
29
+ for event in trace.events:
30
+ if (
31
+ event.get("event_type") == "node_start"
32
+ and _payload(event).get("node_id") == node_id
33
+ ):
34
+ return event
35
+ return None
36
+
37
+
38
+ def _artifacts_dir(snapshot_root: Path, artifact_type: str) -> Path | None:
39
+ """Return the snapshot artifacts dir for the given type, or None if missing."""
40
+ d = snapshot_root / "artifacts" / artifact_type
41
+ return d if d.is_dir() else None
42
+
43
+
44
+ def _find_artifact(snapshot_root: Path, artifact_type: str, code: str) -> str | None:
45
+ """
46
+ Find an artifact file in the snapshot by code suffix match.
47
+
48
+ Snapshot artifacts: {namespace}__{CODE}.json (case-insensitive code match).
49
+ Returns the absolute path as a string, or None if not found.
50
+ """
51
+ d = _artifacts_dir(snapshot_root, artifact_type)
52
+ if d is None:
53
+ return None
54
+ suffix_lower = f"__{code.lower()}.json"
55
+ for artifact_path in d.glob("*.json"):
56
+ if artifact_path.name.lower().endswith(suffix_lower):
57
+ return str(artifact_path)
58
+ return None
59
+
60
+
61
+ def locate_artifact(
62
+ trace: ParsedTrace,
63
+ result: ClassificationResult,
64
+ snapshot_root: Path | None = None,
65
+ ) -> str | None:
66
+ """
67
+ Resolve the failing artifact path from classification result.
68
+
69
+ Uses snapshot_root (derived from workspace layout) to find artifacts.
70
+ Returns the absolute path string, or None if unresolvable.
71
+
72
+ Args:
73
+ trace: Parsed trace.
74
+ result: Classification result from classifier.
75
+ snapshot_root: Path to the protocol_snapshot directory. If None,
76
+ artifact resolution is skipped and None is returned.
77
+
78
+ Returns:
79
+ Absolute path to the failing artifact, or None.
80
+ """
81
+ if snapshot_root is None:
82
+ return None
83
+
84
+ node_id = result.node_id
85
+
86
+ # Admission errors fire before DAG entry — "ADMISSION" is a synthetic pre-node identifier.
87
+ # There is no node_start event for it; route directly to the workflow spec.
88
+ if result.failure_class == FailureClass.ADMISSION_ERROR:
89
+ return _find_artifact(snapshot_root, "workflows", trace.workflow_code)
90
+
91
+ if node_id is None:
92
+ # Graph structure errors — point to the workflow spec
93
+ if result.failure_class == FailureClass.GRAPH_STRUCTURE_ERROR:
94
+ return _find_artifact(snapshot_root, "workflows", trace.workflow_code)
95
+ return None
96
+
97
+ # Look up node_type from node_start event
98
+ node_start = _find_node_start(trace, node_id)
99
+ node_type = _payload(node_start).get("node_type", "") if node_start else ""
100
+
101
+ # Route to artifact based on node_type and failure class
102
+ if result.failure_class == FailureClass.EXPRESSION_ERROR:
103
+ return _find_artifact(snapshot_root, "workflows", trace.workflow_code)
104
+
105
+ if result.failure_class == FailureClass.BINDING_ERROR:
106
+ if node_id.startswith("CC_"):
107
+ return _find_artifact(snapshot_root, "capability_contracts", node_id)
108
+ return _find_artifact(snapshot_root, "workflows", trace.workflow_code)
109
+
110
+ if result.failure_class == FailureClass.CT_STRUCTURE_ERROR:
111
+ error_code = result.error_code
112
+ if error_code in ("CT_ARTIFACT_NOT_FOUND", "CT_VALIDATION_FAILED", "CT_EXECUTION_FAILED"):
113
+ artifact_code = _extract_ct_code(result)
114
+ if artifact_code:
115
+ return _find_artifact(snapshot_root, "capability_transforms", artifact_code)
116
+ if node_id.startswith("CC_"):
117
+ return _find_artifact(snapshot_root, "capability_contracts", node_id)
118
+ return _find_artifact(snapshot_root, "workflows", trace.workflow_code)
119
+
120
+ if result.failure_class == FailureClass.CS_RUNTIME_ERROR:
121
+ return _find_artifact(snapshot_root, "runtime_bindings", trace.workflow_code)
122
+
123
+ if result.failure_class == FailureClass.SCHEMA_ERROR:
124
+ if node_id.startswith("CC_"):
125
+ return _find_artifact(snapshot_root, "capability_contracts", node_id)
126
+ return _find_artifact(snapshot_root, "workflows", trace.workflow_code)
127
+
128
+ if node_type == "intent":
129
+ return _find_artifact(snapshot_root, "intents", node_id)
130
+
131
+ if node_type == "capability_contract":
132
+ return _find_artifact(snapshot_root, "capability_contracts", node_id)
133
+
134
+ return _find_artifact(snapshot_root, "workflows", trace.workflow_code)
135
+
136
+
137
+ def _extract_ct_code(result: ClassificationResult) -> str | None:
138
+ """Try to extract CT code from error event details."""
139
+ if result.root_event is None:
140
+ return None
141
+ p = _payload(result.root_event)
142
+ details = p.get("details", {})
143
+ if isinstance(details, dict) and "artifact_code" in details:
144
+ return details["artifact_code"]
145
+ node_id = p.get("node_id", "")
146
+ if node_id.startswith("CT_"):
147
+ return node_id
148
+ return None
@@ -0,0 +1,223 @@
1
+ """
2
+ parser.py — JSONL trace loader with strict field validation.
3
+
4
+ Governed by: STRUCTURE_TRACE_SCHEMA_V0
5
+
6
+ Reads a completed trace JSONL file and returns a ParsedTrace.
7
+ Validates required fields per schema — rejects malformed events.
8
+ Imports only json and pathlib — no execution/machine imports.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ # Type alias — raw dict from JSONL line
20
+ TraceEventDict = dict[str, Any]
21
+
22
+ # ─── Required fields per SCHEMA_TRACE_EVENT_V0 ───
23
+ # Duplicated as constants (spec §6: no imports from execution/machine/)
24
+
25
+ # Version pin — examiner rejects traces produced by incompatible schema versions.
26
+ # Increment when execution_start or workflow_complete payload shape changes.
27
+ _EXPECTED_TRACE_SCHEMA_VERSION = "TRACE_SCHEMA_V0"
28
+
29
+ # Every event must have these top-level fields
30
+ _REQUIRED_TOP_LEVEL = ("event_type", "timestamp", "execution_id", "sequence")
31
+
32
+ # Per-event-type required payload fields (from schema allOf conditionals)
33
+ _REQUIRED_PAYLOAD: dict[str, tuple[str, ...]] = {
34
+ "execution_start": ("workflow_code", "trace_schema_version"),
35
+ "node_start": ("node_id", "node_type"),
36
+ "node_end": ("node_id", "status", "duration_ms"),
37
+ "workflow_complete": ("status", "duration_ms", "exit_condition", "exit_reason_code"),
38
+ "capability_dispatch": ("cc_code", "node_id"),
39
+ "transform_start": ("artifact_code",),
40
+ "transform_end": ("artifact_code", "duration_ms"),
41
+ "context_snapshot": ("context_hash", "sequence"),
42
+ "error": ("error_code", "message", "node_category"),
43
+ }
44
+
45
+
46
+ @dataclass
47
+ class ParsedTrace:
48
+ """
49
+ Structured representation of a completed trace.
50
+
51
+ Extracted from JSONL trace file after execution.
52
+ """
53
+
54
+ execution_id: str
55
+ workflow_code: str
56
+ trace_schema_version: str # from execution_start.payload.trace_schema_version
57
+ events: list[TraceEventDict]
58
+ status: str # from workflow_complete.payload.status
59
+ exit_reason_code: str # from workflow_complete.payload.exit_reason_code
60
+ exit_condition: str # from workflow_complete.payload.exit_condition
61
+
62
+ # Convenience: pre-indexed subsets
63
+ error_events: list[TraceEventDict] = field(default_factory=list)
64
+ node_end_events: list[TraceEventDict] = field(default_factory=list)
65
+ capability_dispatch_events: list[TraceEventDict] = field(default_factory=list)
66
+ workflow_complete_event: TraceEventDict | None = None
67
+
68
+
69
+ class TraceParseError(Exception):
70
+ """Raised when a trace file cannot be parsed or contains invalid events."""
71
+
72
+ pass
73
+
74
+
75
+ def _validate_event(event: TraceEventDict, line_num: int, trace_path: Path) -> None:
76
+ """
77
+ Validate required fields on a single trace event.
78
+
79
+ Checks:
80
+ 1. Top-level required fields (event_type, timestamp, execution_id, sequence)
81
+ 2. Per-event-type required payload fields from SCHEMA_TRACE_EVENT_V0
82
+
83
+ Raises:
84
+ TraceParseError on missing required field.
85
+ """
86
+ # Top-level required fields
87
+ for field_name in _REQUIRED_TOP_LEVEL:
88
+ if field_name not in event:
89
+ raise TraceParseError(
90
+ f"Event at line {line_num} missing required field '{field_name}' "
91
+ f"in {trace_path}"
92
+ )
93
+
94
+ # Per-event-type payload validation
95
+ event_type = event["event_type"]
96
+ required_payload_fields = _REQUIRED_PAYLOAD.get(event_type)
97
+ if required_payload_fields is not None:
98
+ payload = event.get("payload")
99
+ if payload is None:
100
+ raise TraceParseError(
101
+ f"Event '{event_type}' at line {line_num} missing 'payload' "
102
+ f"in {trace_path}"
103
+ )
104
+ for field_name in required_payload_fields:
105
+ if field_name not in payload:
106
+ raise TraceParseError(
107
+ f"Event '{event_type}' at line {line_num} missing required "
108
+ f"payload field '{field_name}' in {trace_path}"
109
+ )
110
+
111
+
112
+ def parse_trace(trace_path: Path) -> ParsedTrace:
113
+ """
114
+ Parse a JSONL trace file into a ParsedTrace.
115
+
116
+ Args:
117
+ trace_path: Path to the .jsonl trace file.
118
+
119
+ Returns:
120
+ ParsedTrace with all events loaded and indexed.
121
+
122
+ Raises:
123
+ TraceParseError: If trace is missing, empty, or structurally invalid.
124
+ """
125
+ if not trace_path.exists():
126
+ raise TraceParseError(f"Trace file not found: {trace_path}")
127
+
128
+ events: list[TraceEventDict] = []
129
+ error_events: list[TraceEventDict] = []
130
+ node_end_events: list[TraceEventDict] = []
131
+ capability_dispatch_events: list[TraceEventDict] = []
132
+ workflow_complete_event: TraceEventDict | None = None
133
+
134
+ with open(trace_path, "r", encoding="utf-8") as f:
135
+ for line_num, line in enumerate(f, start=1):
136
+ line = line.strip()
137
+ if not line:
138
+ continue
139
+ try:
140
+ event = json.loads(line)
141
+ except json.JSONDecodeError as e:
142
+ raise TraceParseError(
143
+ f"Invalid JSON at line {line_num} in {trace_path}: {e}"
144
+ )
145
+
146
+ _validate_event(event, line_num, trace_path)
147
+ events.append(event)
148
+
149
+ event_type = event.get("event_type")
150
+ if event_type == "error":
151
+ error_events.append(event)
152
+ elif event_type == "node_end":
153
+ node_end_events.append(event)
154
+ elif event_type == "capability_dispatch":
155
+ capability_dispatch_events.append(event)
156
+ elif event_type == "workflow_complete":
157
+ workflow_complete_event = event
158
+
159
+ if not events:
160
+ raise TraceParseError(f"Empty trace file: {trace_path}")
161
+
162
+ # Enforce monotonic sequence ordering (spec §5: earliest by sequence wins)
163
+ # Events must be in sequence order for deterministic classification.
164
+ # If file order doesn't match sequence order, sort explicitly.
165
+ sequences = [e.get("sequence", 0) for e in events]
166
+ if sequences != sorted(sequences):
167
+ events.sort(key=lambda e: e.get("sequence", 0))
168
+ # Re-index after sort
169
+ error_events = [e for e in events if e.get("event_type") == "error"]
170
+ node_end_events = [e for e in events if e.get("event_type") == "node_end"]
171
+ capability_dispatch_events = [e for e in events if e.get("event_type") == "capability_dispatch"]
172
+
173
+ # Extract execution_id from first event
174
+ execution_id = events[0].get("execution_id", "")
175
+ if not execution_id:
176
+ raise TraceParseError(f"First event missing execution_id in {trace_path}")
177
+
178
+ # Extract workflow_code and trace_schema_version from execution_start
179
+ workflow_code = ""
180
+ trace_schema_version = ""
181
+ for event in events:
182
+ if event.get("event_type") == "execution_start":
183
+ payload = event.get("payload", {})
184
+ workflow_code = payload.get("workflow_code", "")
185
+ trace_schema_version = payload.get("trace_schema_version", "")
186
+ break
187
+
188
+ if not workflow_code:
189
+ raise TraceParseError(f"No execution_start event with workflow_code in {trace_path}")
190
+
191
+ if trace_schema_version != _EXPECTED_TRACE_SCHEMA_VERSION:
192
+ raise TraceParseError(
193
+ f"Trace schema version mismatch in {trace_path}: "
194
+ f"expected '{_EXPECTED_TRACE_SCHEMA_VERSION}', got '{trace_schema_version}'"
195
+ )
196
+
197
+ # Extract final status from workflow_complete
198
+ if workflow_complete_event is None:
199
+ raise TraceParseError(f"No workflow_complete event in {trace_path}")
200
+
201
+ wc_payload = workflow_complete_event.get("payload", {})
202
+ status = wc_payload.get("status", "")
203
+ exit_reason_code = wc_payload.get("exit_reason_code", "")
204
+ exit_condition = wc_payload.get("exit_condition", "")
205
+
206
+ if not status:
207
+ raise TraceParseError(f"workflow_complete missing status in {trace_path}")
208
+ if not exit_reason_code:
209
+ raise TraceParseError(f"workflow_complete missing exit_reason_code in {trace_path}")
210
+
211
+ return ParsedTrace(
212
+ execution_id=execution_id,
213
+ workflow_code=workflow_code,
214
+ trace_schema_version=trace_schema_version,
215
+ events=events,
216
+ status=status,
217
+ exit_reason_code=exit_reason_code,
218
+ exit_condition=exit_condition,
219
+ error_events=error_events,
220
+ node_end_events=node_end_events,
221
+ capability_dispatch_events=capability_dispatch_events,
222
+ workflow_complete_event=workflow_complete_event,
223
+ )