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.
- pgc_runtime-2.0.0.dist-info/METADATA +181 -0
- pgc_runtime-2.0.0.dist-info/RECORD +28 -0
- pgc_runtime-2.0.0.dist-info/WHEEL +5 -0
- pgc_runtime-2.0.0.dist-info/entry_points.txt +2 -0
- pgc_runtime-2.0.0.dist-info/licenses/LICENSE +67 -0
- pgc_runtime-2.0.0.dist-info/licenses/NOTICE +11 -0
- pgc_runtime-2.0.0.dist-info/top_level.txt +1 -0
- runtime/__init__.py +0 -0
- runtime/api.py +71 -0
- runtime/boot.py +147 -0
- runtime/cli.py +387 -0
- runtime/conformance.py +249 -0
- runtime/ct_errors.py +33 -0
- runtime/ct_execute.py +84 -0
- runtime/ct_executor.py +312 -0
- runtime/dispatcher.py +448 -0
- runtime/evidence.py +245 -0
- runtime/examine/__init__.py +126 -0
- runtime/examine/classifier.py +350 -0
- runtime/examine/cli.py +35 -0
- runtime/examine/hint_engine.py +245 -0
- runtime/examine/locator.py +148 -0
- runtime/examine/parser.py +223 -0
- runtime/examine/reporter.py +110 -0
- runtime/loader.py +360 -0
- runtime/memory.py +125 -0
- runtime/scheduler.py +264 -0
- runtime/trace_viz.py +276 -0
|
@@ -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
|
+
)
|