trace-agent-server 0.0.1__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.
- trace_agent_server/__init__.py +5 -0
- trace_agent_server/api/__init__.py +1 -0
- trace_agent_server/api/runs.py +1214 -0
- trace_agent_server/cli.py +29 -0
- trace_agent_server/config.py +57 -0
- trace_agent_server/db.py +24 -0
- trace_agent_server/main.py +73 -0
- trace_agent_server/models.py +131 -0
- trace_agent_server/schemas.py +549 -0
- trace_agent_server/services/__init__.py +1 -0
- trace_agent_server/services/analytics.py +305 -0
- trace_agent_server/services/audit_guardrails.py +303 -0
- trace_agent_server/services/compare.py +851 -0
- trace_agent_server/services/dashboard.py +125 -0
- trace_agent_server/services/events.py +131 -0
- trace_agent_server/services/explanation.py +230 -0
- trace_agent_server/services/graph.py +103 -0
- trace_agent_server/services/observability.py +140 -0
- trace_agent_server/services/provider.py +547 -0
- trace_agent_server/services/run_summary.py +363 -0
- trace_agent_server-0.0.1.dist-info/METADATA +21 -0
- trace_agent_server-0.0.1.dist-info/RECORD +25 -0
- trace_agent_server-0.0.1.dist-info/WHEEL +5 -0
- trace_agent_server-0.0.1.dist-info/entry_points.txt +2 -0
- trace_agent_server-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from trace_agent_server.models import Artifact, Decision, Event, Run
|
|
6
|
+
from trace_agent_server.services.run_summary import (
|
|
7
|
+
build_command_chain,
|
|
8
|
+
build_file_activity,
|
|
9
|
+
build_patch_activity,
|
|
10
|
+
build_timeline_groups,
|
|
11
|
+
build_tool_chain,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _event_map(events: list[Event]) -> dict[int, Event]:
|
|
16
|
+
return {event.id: event for event in events}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _first_seq(event_ids: list[int], lookup: dict[int, Event]) -> int:
|
|
20
|
+
seqs = [lookup[event_id].seq for event_id in event_ids if event_id in lookup]
|
|
21
|
+
return min(seqs) if seqs else 10**9
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _target(
|
|
25
|
+
*,
|
|
26
|
+
label: str,
|
|
27
|
+
status: str | None,
|
|
28
|
+
summary: str | None,
|
|
29
|
+
event_ids: list[int],
|
|
30
|
+
step_id: str | None,
|
|
31
|
+
duration_ms: int | None = None,
|
|
32
|
+
metadata: dict[str, Any] | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"label": label,
|
|
36
|
+
"status": status,
|
|
37
|
+
"summary": summary,
|
|
38
|
+
"event_ids": event_ids,
|
|
39
|
+
"step_id": step_id,
|
|
40
|
+
"duration_ms": duration_ms,
|
|
41
|
+
"metadata": metadata or {},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_label(value: str | None) -> str:
|
|
46
|
+
return (value or "").strip().lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pair_summary(kind: str, relation: str, left_label: str | None, right_label: str | None) -> str:
|
|
50
|
+
if relation == "shared":
|
|
51
|
+
return f"Shared {kind}: {left_label or right_label}."
|
|
52
|
+
if relation == "left_only":
|
|
53
|
+
return f"Only left run used {kind}: {left_label}."
|
|
54
|
+
if relation == "right_only":
|
|
55
|
+
return f"Only right run used {kind}: {right_label}."
|
|
56
|
+
if left_label == right_label:
|
|
57
|
+
return f"Same {kind} with a different outcome: {left_label}."
|
|
58
|
+
return f"{kind.title()} diverged: {left_label or 'none'} vs {right_label or 'none'}."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _align_sequence(
|
|
62
|
+
left_items: list[dict[str, Any]],
|
|
63
|
+
right_items: list[dict[str, Any]],
|
|
64
|
+
*,
|
|
65
|
+
kind: str,
|
|
66
|
+
label_key: str,
|
|
67
|
+
step_key: str = "step_id",
|
|
68
|
+
status_key: str = "status",
|
|
69
|
+
summary_keys: tuple[str, ...],
|
|
70
|
+
metadata_builder,
|
|
71
|
+
) -> list[dict[str, Any]]:
|
|
72
|
+
pairs: list[dict[str, Any]] = []
|
|
73
|
+
left_index = 0
|
|
74
|
+
right_index = 0
|
|
75
|
+
|
|
76
|
+
while left_index < len(left_items) or right_index < len(right_items):
|
|
77
|
+
left_item = left_items[left_index] if left_index < len(left_items) else None
|
|
78
|
+
right_item = right_items[right_index] if right_index < len(right_items) else None
|
|
79
|
+
|
|
80
|
+
if left_item is None and right_item is not None:
|
|
81
|
+
right_label = str(right_item.get(label_key) or "unknown")
|
|
82
|
+
pairs.append(
|
|
83
|
+
{
|
|
84
|
+
"relation": "right_only",
|
|
85
|
+
"kind": kind,
|
|
86
|
+
"summary": _pair_summary(kind, "right_only", None, right_label),
|
|
87
|
+
"left": None,
|
|
88
|
+
"right": _target(
|
|
89
|
+
label=right_label,
|
|
90
|
+
status=right_item.get(status_key),
|
|
91
|
+
summary=next((right_item.get(key) for key in summary_keys if right_item.get(key)), None),
|
|
92
|
+
event_ids=list(right_item.get("event_ids", [right_item.get("event_id")]).copy()),
|
|
93
|
+
step_id=right_item.get(step_key),
|
|
94
|
+
duration_ms=right_item.get("duration_ms"),
|
|
95
|
+
metadata=metadata_builder(right_item),
|
|
96
|
+
),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
right_index += 1
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if right_item is None and left_item is not None:
|
|
103
|
+
left_label = str(left_item.get(label_key) or "unknown")
|
|
104
|
+
pairs.append(
|
|
105
|
+
{
|
|
106
|
+
"relation": "left_only",
|
|
107
|
+
"kind": kind,
|
|
108
|
+
"summary": _pair_summary(kind, "left_only", left_label, None),
|
|
109
|
+
"left": _target(
|
|
110
|
+
label=left_label,
|
|
111
|
+
status=left_item.get(status_key),
|
|
112
|
+
summary=next((left_item.get(key) for key in summary_keys if left_item.get(key)), None),
|
|
113
|
+
event_ids=list(left_item.get("event_ids", [left_item.get("event_id")]).copy()),
|
|
114
|
+
step_id=left_item.get(step_key),
|
|
115
|
+
duration_ms=left_item.get("duration_ms"),
|
|
116
|
+
metadata=metadata_builder(left_item),
|
|
117
|
+
),
|
|
118
|
+
"right": None,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
left_index += 1
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
assert left_item is not None and right_item is not None
|
|
125
|
+
left_label = str(left_item.get(label_key) or "unknown")
|
|
126
|
+
right_label = str(right_item.get(label_key) or "unknown")
|
|
127
|
+
left_step = left_item.get(step_key)
|
|
128
|
+
right_step = right_item.get(step_key)
|
|
129
|
+
|
|
130
|
+
match = False
|
|
131
|
+
if left_step and right_step and left_step == right_step:
|
|
132
|
+
match = True
|
|
133
|
+
elif _normalize_label(left_label) == _normalize_label(right_label):
|
|
134
|
+
match = True
|
|
135
|
+
elif left_index + 1 < len(left_items) and _normalize_label(str(left_items[left_index + 1].get(label_key) or "")) == _normalize_label(right_label):
|
|
136
|
+
pairs.append(
|
|
137
|
+
{
|
|
138
|
+
"relation": "left_only",
|
|
139
|
+
"kind": kind,
|
|
140
|
+
"summary": _pair_summary(kind, "left_only", left_label, None),
|
|
141
|
+
"left": _target(
|
|
142
|
+
label=left_label,
|
|
143
|
+
status=left_item.get(status_key),
|
|
144
|
+
summary=next((left_item.get(key) for key in summary_keys if left_item.get(key)), None),
|
|
145
|
+
event_ids=list(left_item.get("event_ids", [left_item.get("event_id")]).copy()),
|
|
146
|
+
step_id=left_item.get(step_key),
|
|
147
|
+
duration_ms=left_item.get("duration_ms"),
|
|
148
|
+
metadata=metadata_builder(left_item),
|
|
149
|
+
),
|
|
150
|
+
"right": None,
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
left_index += 1
|
|
154
|
+
continue
|
|
155
|
+
elif right_index + 1 < len(right_items) and _normalize_label(left_label) == _normalize_label(str(right_items[right_index + 1].get(label_key) or "")):
|
|
156
|
+
pairs.append(
|
|
157
|
+
{
|
|
158
|
+
"relation": "right_only",
|
|
159
|
+
"kind": kind,
|
|
160
|
+
"summary": _pair_summary(kind, "right_only", None, right_label),
|
|
161
|
+
"left": None,
|
|
162
|
+
"right": _target(
|
|
163
|
+
label=right_label,
|
|
164
|
+
status=right_item.get(status_key),
|
|
165
|
+
summary=next((right_item.get(key) for key in summary_keys if right_item.get(key)), None),
|
|
166
|
+
event_ids=list(right_item.get("event_ids", [right_item.get("event_id")]).copy()),
|
|
167
|
+
step_id=right_item.get(step_key),
|
|
168
|
+
duration_ms=right_item.get("duration_ms"),
|
|
169
|
+
metadata=metadata_builder(right_item),
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
right_index += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
relation = "shared"
|
|
177
|
+
if not match or left_item.get(status_key) != right_item.get(status_key):
|
|
178
|
+
relation = "different"
|
|
179
|
+
|
|
180
|
+
pairs.append(
|
|
181
|
+
{
|
|
182
|
+
"relation": relation,
|
|
183
|
+
"kind": kind,
|
|
184
|
+
"summary": _pair_summary(kind, relation, left_label, right_label),
|
|
185
|
+
"left": _target(
|
|
186
|
+
label=left_label,
|
|
187
|
+
status=left_item.get(status_key),
|
|
188
|
+
summary=next((left_item.get(key) for key in summary_keys if left_item.get(key)), None),
|
|
189
|
+
event_ids=list(left_item.get("event_ids", [left_item.get("event_id")]).copy()),
|
|
190
|
+
step_id=left_item.get(step_key),
|
|
191
|
+
duration_ms=left_item.get("duration_ms"),
|
|
192
|
+
metadata=metadata_builder(left_item),
|
|
193
|
+
),
|
|
194
|
+
"right": _target(
|
|
195
|
+
label=right_label,
|
|
196
|
+
status=right_item.get(status_key),
|
|
197
|
+
summary=next((right_item.get(key) for key in summary_keys if right_item.get(key)), None),
|
|
198
|
+
event_ids=list(right_item.get("event_ids", [right_item.get("event_id")]).copy()),
|
|
199
|
+
step_id=right_item.get(step_key),
|
|
200
|
+
duration_ms=right_item.get("duration_ms"),
|
|
201
|
+
metadata=metadata_builder(right_item),
|
|
202
|
+
),
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
left_index += 1
|
|
206
|
+
right_index += 1
|
|
207
|
+
|
|
208
|
+
return pairs
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _final_file_state(file_activity: list[dict[str, Any]], patch_activity: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
212
|
+
state: dict[str, dict[str, Any]] = {}
|
|
213
|
+
for item in file_activity:
|
|
214
|
+
if item["event_type"] != "file.written":
|
|
215
|
+
continue
|
|
216
|
+
state[item["path"]] = {
|
|
217
|
+
**item,
|
|
218
|
+
"source": "file",
|
|
219
|
+
}
|
|
220
|
+
for item in patch_activity:
|
|
221
|
+
state[item["path"]] = {
|
|
222
|
+
"step_id": item["step_id"],
|
|
223
|
+
"event_id": item["event_id"],
|
|
224
|
+
"path": item["path"],
|
|
225
|
+
"status": item["status"],
|
|
226
|
+
"change_type": "update",
|
|
227
|
+
"summary": item["summary"],
|
|
228
|
+
"content_hash": item.get("after_hash"),
|
|
229
|
+
"before_hash": item.get("before_hash"),
|
|
230
|
+
"after_hash": item.get("after_hash"),
|
|
231
|
+
"diff_summary": item.get("diff_summary"),
|
|
232
|
+
"line_additions": item.get("line_additions"),
|
|
233
|
+
"line_deletions": item.get("line_deletions"),
|
|
234
|
+
"timestamp": item["timestamp"],
|
|
235
|
+
"source": "patch",
|
|
236
|
+
}
|
|
237
|
+
return state
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _compare_file_state(left_state: dict[str, dict[str, Any]], right_state: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
241
|
+
pairs: list[dict[str, Any]] = []
|
|
242
|
+
paths = sorted(set(left_state) | set(right_state))
|
|
243
|
+
for path in paths:
|
|
244
|
+
left_item = left_state.get(path)
|
|
245
|
+
right_item = right_state.get(path)
|
|
246
|
+
relation = "shared"
|
|
247
|
+
if left_item is None:
|
|
248
|
+
relation = "right_only"
|
|
249
|
+
elif right_item is None:
|
|
250
|
+
relation = "left_only"
|
|
251
|
+
elif (left_item.get("after_hash") or left_item.get("content_hash")) != (right_item.get("after_hash") or right_item.get("content_hash")):
|
|
252
|
+
relation = "different"
|
|
253
|
+
|
|
254
|
+
pairs.append(
|
|
255
|
+
{
|
|
256
|
+
"relation": relation,
|
|
257
|
+
"kind": "file",
|
|
258
|
+
"summary": _pair_summary("file", relation, path if left_item else None, path if right_item else None),
|
|
259
|
+
"left": None
|
|
260
|
+
if left_item is None
|
|
261
|
+
else _target(
|
|
262
|
+
label=path,
|
|
263
|
+
status=left_item.get("status"),
|
|
264
|
+
summary=left_item.get("diff_summary") or left_item.get("summary"),
|
|
265
|
+
event_ids=[left_item.get("event_id")],
|
|
266
|
+
step_id=left_item.get("step_id"),
|
|
267
|
+
metadata={
|
|
268
|
+
"path": path,
|
|
269
|
+
"change_type": left_item.get("change_type"),
|
|
270
|
+
"after_hash": left_item.get("after_hash"),
|
|
271
|
+
"before_hash": left_item.get("before_hash"),
|
|
272
|
+
"line_additions": left_item.get("line_additions"),
|
|
273
|
+
"line_deletions": left_item.get("line_deletions"),
|
|
274
|
+
"source": left_item.get("source"),
|
|
275
|
+
},
|
|
276
|
+
),
|
|
277
|
+
"right": None
|
|
278
|
+
if right_item is None
|
|
279
|
+
else _target(
|
|
280
|
+
label=path,
|
|
281
|
+
status=right_item.get("status"),
|
|
282
|
+
summary=right_item.get("diff_summary") or right_item.get("summary"),
|
|
283
|
+
event_ids=[right_item.get("event_id")],
|
|
284
|
+
step_id=right_item.get("step_id"),
|
|
285
|
+
metadata={
|
|
286
|
+
"path": path,
|
|
287
|
+
"change_type": right_item.get("change_type"),
|
|
288
|
+
"after_hash": right_item.get("after_hash"),
|
|
289
|
+
"before_hash": right_item.get("before_hash"),
|
|
290
|
+
"line_additions": right_item.get("line_additions"),
|
|
291
|
+
"line_deletions": right_item.get("line_deletions"),
|
|
292
|
+
"source": right_item.get("source"),
|
|
293
|
+
},
|
|
294
|
+
),
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
return pairs
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _compare_artifacts(left_artifacts: list[Artifact], right_artifacts: list[Artifact]) -> list[dict[str, Any]]:
|
|
301
|
+
left_map = {f"{artifact.kind}:{artifact.label}": artifact for artifact in left_artifacts}
|
|
302
|
+
right_map = {f"{artifact.kind}:{artifact.label}": artifact for artifact in right_artifacts}
|
|
303
|
+
pairs: list[dict[str, Any]] = []
|
|
304
|
+
for key in sorted(set(left_map) | set(right_map)):
|
|
305
|
+
left_artifact = left_map.get(key)
|
|
306
|
+
right_artifact = right_map.get(key)
|
|
307
|
+
relation = "shared"
|
|
308
|
+
if left_artifact is None:
|
|
309
|
+
relation = "right_only"
|
|
310
|
+
elif right_artifact is None:
|
|
311
|
+
relation = "left_only"
|
|
312
|
+
elif left_artifact.content_hash != right_artifact.content_hash:
|
|
313
|
+
relation = "different"
|
|
314
|
+
|
|
315
|
+
label = key.split(":", 1)[1]
|
|
316
|
+
pairs.append(
|
|
317
|
+
{
|
|
318
|
+
"relation": relation,
|
|
319
|
+
"kind": "artifact",
|
|
320
|
+
"summary": _pair_summary("artifact", relation, label if left_artifact else None, label if right_artifact else None),
|
|
321
|
+
"left": None
|
|
322
|
+
if left_artifact is None
|
|
323
|
+
else _target(
|
|
324
|
+
label=left_artifact.label,
|
|
325
|
+
status="created",
|
|
326
|
+
summary=left_artifact.summary,
|
|
327
|
+
event_ids=[left_artifact.source_event_id] if left_artifact.source_event_id else [],
|
|
328
|
+
step_id=None,
|
|
329
|
+
metadata={"kind": left_artifact.kind, "content_hash": left_artifact.content_hash},
|
|
330
|
+
),
|
|
331
|
+
"right": None
|
|
332
|
+
if right_artifact is None
|
|
333
|
+
else _target(
|
|
334
|
+
label=right_artifact.label,
|
|
335
|
+
status="created",
|
|
336
|
+
summary=right_artifact.summary,
|
|
337
|
+
event_ids=[right_artifact.source_event_id] if right_artifact.source_event_id else [],
|
|
338
|
+
step_id=None,
|
|
339
|
+
metadata={"kind": right_artifact.kind, "content_hash": right_artifact.content_hash},
|
|
340
|
+
),
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
return pairs
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _timeline_sequence(
|
|
347
|
+
tool_chain: list[dict[str, Any]],
|
|
348
|
+
command_chain: list[dict[str, Any]],
|
|
349
|
+
file_activity: list[dict[str, Any]],
|
|
350
|
+
patch_activity: list[dict[str, Any]],
|
|
351
|
+
lookup: dict[int, Event],
|
|
352
|
+
) -> list[dict[str, Any]]:
|
|
353
|
+
items: list[dict[str, Any]] = []
|
|
354
|
+
for item in tool_chain:
|
|
355
|
+
items.append(
|
|
356
|
+
{
|
|
357
|
+
"kind": "tool",
|
|
358
|
+
"label": item["tool_name"],
|
|
359
|
+
"status": item["status"],
|
|
360
|
+
"summary": item["error_summary"] or item["output_summary"] or item["args_summary"],
|
|
361
|
+
"event_ids": item["event_ids"],
|
|
362
|
+
"step_id": item["step_id"],
|
|
363
|
+
"duration_ms": item["duration_ms"],
|
|
364
|
+
"metadata": {"tool_name": item["tool_name"]},
|
|
365
|
+
"_seq": _first_seq(item["event_ids"], lookup),
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
for item in command_chain:
|
|
369
|
+
items.append(
|
|
370
|
+
{
|
|
371
|
+
"kind": "command",
|
|
372
|
+
"label": item["command"],
|
|
373
|
+
"status": item["status"],
|
|
374
|
+
"summary": item["error_summary"] or item["output_summary"],
|
|
375
|
+
"event_ids": item["event_ids"],
|
|
376
|
+
"step_id": item["step_id"],
|
|
377
|
+
"duration_ms": item["duration_ms"],
|
|
378
|
+
"metadata": {"cwd": item["cwd"], "exit_code": item["exit_code"]},
|
|
379
|
+
"_seq": _first_seq(item["event_ids"], lookup),
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
for item in file_activity:
|
|
383
|
+
items.append(
|
|
384
|
+
{
|
|
385
|
+
"kind": "file",
|
|
386
|
+
"label": item["path"],
|
|
387
|
+
"status": item["status"],
|
|
388
|
+
"summary": item["summary"],
|
|
389
|
+
"event_ids": [item["event_id"]],
|
|
390
|
+
"step_id": item["step_id"],
|
|
391
|
+
"duration_ms": None,
|
|
392
|
+
"metadata": {"event_type": item["event_type"], "change_type": item["change_type"]},
|
|
393
|
+
"_seq": lookup[item["event_id"]].seq,
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
for item in patch_activity:
|
|
397
|
+
items.append(
|
|
398
|
+
{
|
|
399
|
+
"kind": "file",
|
|
400
|
+
"label": item["path"],
|
|
401
|
+
"status": item["status"],
|
|
402
|
+
"summary": item["summary"],
|
|
403
|
+
"event_ids": [item["event_id"]],
|
|
404
|
+
"step_id": item["step_id"],
|
|
405
|
+
"duration_ms": None,
|
|
406
|
+
"metadata": {"event_type": "patch.applied", "change_type": "update"},
|
|
407
|
+
"_seq": lookup[item["event_id"]].seq,
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
items.sort(key=lambda item: item["_seq"])
|
|
411
|
+
return items
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _align_timeline(left_items: list[dict[str, Any]], right_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
415
|
+
pairs: list[dict[str, Any]] = []
|
|
416
|
+
left_index = 0
|
|
417
|
+
right_index = 0
|
|
418
|
+
|
|
419
|
+
while left_index < len(left_items) or right_index < len(right_items):
|
|
420
|
+
left_item = left_items[left_index] if left_index < len(left_items) else None
|
|
421
|
+
right_item = right_items[right_index] if right_index < len(right_items) else None
|
|
422
|
+
|
|
423
|
+
if left_item is None and right_item is not None:
|
|
424
|
+
pairs.append(
|
|
425
|
+
{
|
|
426
|
+
"relation": "right_only",
|
|
427
|
+
"kind": right_item["kind"],
|
|
428
|
+
"summary": f"Only right run has {right_item['kind']}: {right_item['label']}.",
|
|
429
|
+
"left": None,
|
|
430
|
+
"right": _target(
|
|
431
|
+
label=right_item["label"],
|
|
432
|
+
status=right_item["status"],
|
|
433
|
+
summary=right_item["summary"],
|
|
434
|
+
event_ids=right_item["event_ids"],
|
|
435
|
+
step_id=right_item["step_id"],
|
|
436
|
+
duration_ms=right_item["duration_ms"],
|
|
437
|
+
metadata=right_item["metadata"],
|
|
438
|
+
),
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
right_index += 1
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
if right_item is None and left_item is not None:
|
|
445
|
+
pairs.append(
|
|
446
|
+
{
|
|
447
|
+
"relation": "left_only",
|
|
448
|
+
"kind": left_item["kind"],
|
|
449
|
+
"summary": f"Only left run has {left_item['kind']}: {left_item['label']}.",
|
|
450
|
+
"left": _target(
|
|
451
|
+
label=left_item["label"],
|
|
452
|
+
status=left_item["status"],
|
|
453
|
+
summary=left_item["summary"],
|
|
454
|
+
event_ids=left_item["event_ids"],
|
|
455
|
+
step_id=left_item["step_id"],
|
|
456
|
+
duration_ms=left_item["duration_ms"],
|
|
457
|
+
metadata=left_item["metadata"],
|
|
458
|
+
),
|
|
459
|
+
"right": None,
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
left_index += 1
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
assert left_item is not None and right_item is not None
|
|
466
|
+
if left_item["kind"] == right_item["kind"] and _normalize_label(left_item["label"]) == _normalize_label(right_item["label"]):
|
|
467
|
+
relation = "shared" if left_item["status"] == right_item["status"] else "different"
|
|
468
|
+
pairs.append(
|
|
469
|
+
{
|
|
470
|
+
"relation": relation,
|
|
471
|
+
"kind": left_item["kind"],
|
|
472
|
+
"summary": _pair_summary(left_item["kind"], relation, left_item["label"], right_item["label"]),
|
|
473
|
+
"left": _target(
|
|
474
|
+
label=left_item["label"],
|
|
475
|
+
status=left_item["status"],
|
|
476
|
+
summary=left_item["summary"],
|
|
477
|
+
event_ids=left_item["event_ids"],
|
|
478
|
+
step_id=left_item["step_id"],
|
|
479
|
+
duration_ms=left_item["duration_ms"],
|
|
480
|
+
metadata=left_item["metadata"],
|
|
481
|
+
),
|
|
482
|
+
"right": _target(
|
|
483
|
+
label=right_item["label"],
|
|
484
|
+
status=right_item["status"],
|
|
485
|
+
summary=right_item["summary"],
|
|
486
|
+
event_ids=right_item["event_ids"],
|
|
487
|
+
step_id=right_item["step_id"],
|
|
488
|
+
duration_ms=right_item["duration_ms"],
|
|
489
|
+
metadata=right_item["metadata"],
|
|
490
|
+
),
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
left_index += 1
|
|
494
|
+
right_index += 1
|
|
495
|
+
continue
|
|
496
|
+
|
|
497
|
+
if left_item["_seq"] <= right_item["_seq"]:
|
|
498
|
+
pairs.append(
|
|
499
|
+
{
|
|
500
|
+
"relation": "left_only",
|
|
501
|
+
"kind": left_item["kind"],
|
|
502
|
+
"summary": f"Only left run has {left_item['kind']}: {left_item['label']}.",
|
|
503
|
+
"left": _target(
|
|
504
|
+
label=left_item["label"],
|
|
505
|
+
status=left_item["status"],
|
|
506
|
+
summary=left_item["summary"],
|
|
507
|
+
event_ids=left_item["event_ids"],
|
|
508
|
+
step_id=left_item["step_id"],
|
|
509
|
+
duration_ms=left_item["duration_ms"],
|
|
510
|
+
metadata=left_item["metadata"],
|
|
511
|
+
),
|
|
512
|
+
"right": None,
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
left_index += 1
|
|
516
|
+
else:
|
|
517
|
+
pairs.append(
|
|
518
|
+
{
|
|
519
|
+
"relation": "right_only",
|
|
520
|
+
"kind": right_item["kind"],
|
|
521
|
+
"summary": f"Only right run has {right_item['kind']}: {right_item['label']}.",
|
|
522
|
+
"left": None,
|
|
523
|
+
"right": _target(
|
|
524
|
+
label=right_item["label"],
|
|
525
|
+
status=right_item["status"],
|
|
526
|
+
summary=right_item["summary"],
|
|
527
|
+
event_ids=right_item["event_ids"],
|
|
528
|
+
step_id=right_item["step_id"],
|
|
529
|
+
duration_ms=right_item["duration_ms"],
|
|
530
|
+
metadata=right_item["metadata"],
|
|
531
|
+
),
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
right_index += 1
|
|
535
|
+
|
|
536
|
+
return pairs
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _overview_diff(left_run: Run, right_run: Run, left_events: list[Event], right_events: list[Event]) -> dict[str, Any]:
|
|
540
|
+
left_token_total = sum(int((event.event_metadata.get("usage") or {}).get("total_tokens") or 0) for event in left_events if event.type == "model.responded")
|
|
541
|
+
right_token_total = sum(int((event.event_metadata.get("usage") or {}).get("total_tokens") or 0) for event in right_events if event.type == "model.responded")
|
|
542
|
+
|
|
543
|
+
duration_ms_delta = None
|
|
544
|
+
if left_run.ended_at is not None and right_run.ended_at is not None:
|
|
545
|
+
left_duration = int((left_run.ended_at - left_run.started_at).total_seconds() * 1000)
|
|
546
|
+
right_duration = int((right_run.ended_at - right_run.started_at).total_seconds() * 1000)
|
|
547
|
+
duration_ms_delta = right_duration - left_duration
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
"tool_count_delta": right_run.tool_count - left_run.tool_count,
|
|
551
|
+
"command_count_delta": sum(1 for event in right_events if event.type in {"command.succeeded", "command.failed"})
|
|
552
|
+
- sum(1 for event in left_events if event.type in {"command.succeeded", "command.failed"}),
|
|
553
|
+
"error_count_delta": right_run.error_count - left_run.error_count,
|
|
554
|
+
"retry_count_delta": right_run.retry_count - left_run.retry_count,
|
|
555
|
+
"artifact_count_delta": right_run.artifact_count - left_run.artifact_count,
|
|
556
|
+
"token_total_delta": right_token_total - left_token_total,
|
|
557
|
+
"duration_ms_delta": duration_ms_delta,
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _detect_retry_without_adaptation(tool_chain: list[dict[str, Any]]) -> list[int]:
|
|
562
|
+
last_failed_by_tool: dict[str, dict[str, Any]] = {}
|
|
563
|
+
for item in tool_chain:
|
|
564
|
+
if item["status"] == "failed":
|
|
565
|
+
last_failed_by_tool[item["tool_name"]] = item
|
|
566
|
+
continue
|
|
567
|
+
previous = last_failed_by_tool.get(item["tool_name"])
|
|
568
|
+
if previous and previous.get("args_summary") == item.get("args_summary"):
|
|
569
|
+
return previous["event_ids"] + item["event_ids"]
|
|
570
|
+
return []
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _derive_root_cause(
|
|
574
|
+
*,
|
|
575
|
+
left_run: Run,
|
|
576
|
+
right_run: Run,
|
|
577
|
+
divergence: dict[str, Any] | None,
|
|
578
|
+
tool_diff: list[dict[str, Any]],
|
|
579
|
+
file_diff: list[dict[str, Any]],
|
|
580
|
+
artifact_diff: list[dict[str, Any]],
|
|
581
|
+
left_tool_chain: list[dict[str, Any]],
|
|
582
|
+
right_tool_chain: list[dict[str, Any]],
|
|
583
|
+
left_lookup: dict[int, Event],
|
|
584
|
+
right_lookup: dict[int, Event],
|
|
585
|
+
) -> dict[str, Any]:
|
|
586
|
+
if divergence is None:
|
|
587
|
+
return {
|
|
588
|
+
"label": "no_observable_root_cause",
|
|
589
|
+
"summary": "No observable divergence was found between the selected runs.",
|
|
590
|
+
"first_causal_event_left": None,
|
|
591
|
+
"first_causal_event_right": None,
|
|
592
|
+
"supporting_event_ids": {"left": [], "right": []},
|
|
593
|
+
"impact_summary": "Both runs stayed aligned across the observed execution path.",
|
|
594
|
+
"confidence": "low",
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
left_event_ids = divergence.get("left_event_ids", [])
|
|
598
|
+
right_event_ids = divergence.get("right_event_ids", [])
|
|
599
|
+
mismatch_kind = divergence["first_mismatch_kind"]
|
|
600
|
+
|
|
601
|
+
left_events = [left_lookup[event_id] for event_id in left_event_ids if event_id in left_lookup]
|
|
602
|
+
right_events = [right_lookup[event_id] for event_id in right_event_ids if event_id in right_lookup]
|
|
603
|
+
|
|
604
|
+
if any(event.type == "run.failed" and event.error_code == "provider_error" for event in left_events + right_events):
|
|
605
|
+
return {
|
|
606
|
+
"label": "provider_failure",
|
|
607
|
+
"summary": "The provider failed before the runs could stay aligned.",
|
|
608
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
609
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
610
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
611
|
+
"impact_summary": "The provider error interrupted the execution path and changed the final status.",
|
|
612
|
+
"confidence": "high",
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if mismatch_kind == "command":
|
|
616
|
+
return {
|
|
617
|
+
"label": "command_failure",
|
|
618
|
+
"summary": "A command diverged first, and one run failed or changed outcome at that command step.",
|
|
619
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
620
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
621
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
622
|
+
"impact_summary": f"Run status diverged to {left_run.status} vs {right_run.status} after command execution changed.",
|
|
623
|
+
"confidence": "high",
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if mismatch_kind == "tool":
|
|
627
|
+
retry_left = _detect_retry_without_adaptation(left_tool_chain)
|
|
628
|
+
retry_right = _detect_retry_without_adaptation(right_tool_chain)
|
|
629
|
+
if retry_left or retry_right:
|
|
630
|
+
return {
|
|
631
|
+
"label": "retry_without_adaptation",
|
|
632
|
+
"summary": "One run retried the same tool without changing the observable input or path.",
|
|
633
|
+
"first_causal_event_left": (retry_left or left_event_ids or [None])[0],
|
|
634
|
+
"first_causal_event_right": (retry_right or right_event_ids or [None])[0],
|
|
635
|
+
"supporting_event_ids": {"left": retry_left or left_event_ids, "right": retry_right or right_event_ids},
|
|
636
|
+
"impact_summary": "The extra retry delayed or derailed the run without introducing a new strategy.",
|
|
637
|
+
"confidence": "high",
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
first_tool_diff = next((item for item in tool_diff if item["relation"] == "different"), None)
|
|
641
|
+
if first_tool_diff and first_tool_diff.get("left") and first_tool_diff.get("right"):
|
|
642
|
+
left_label = first_tool_diff["left"]["label"]
|
|
643
|
+
right_label = first_tool_diff["right"]["label"]
|
|
644
|
+
if left_label != right_label:
|
|
645
|
+
return {
|
|
646
|
+
"label": "bad_tool_selection",
|
|
647
|
+
"summary": "The runs chose different tools at the same point in the execution.",
|
|
648
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
649
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
650
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
651
|
+
"impact_summary": f"The tool choice changed the rest of the execution path: {left_label} vs {right_label}.",
|
|
652
|
+
"confidence": "high",
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
"label": "dead_end_branch",
|
|
657
|
+
"summary": "The runs diverged on tool execution and one branch stopped matching the successful path.",
|
|
658
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
659
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
660
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
661
|
+
"impact_summary": "The tool path changed before the final result diverged.",
|
|
662
|
+
"confidence": "medium",
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
different_file = next((item for item in file_diff if item["relation"] == "different"), None)
|
|
666
|
+
if mismatch_kind == "file" or different_file is not None:
|
|
667
|
+
if different_file is None and (
|
|
668
|
+
not any(item["relation"] in {"left_only", "right_only"} for item in file_diff)
|
|
669
|
+
and (
|
|
670
|
+
any(event.type == "file.read" for event in left_events) or any(event.type == "file.read" for event in right_events)
|
|
671
|
+
)
|
|
672
|
+
):
|
|
673
|
+
return {
|
|
674
|
+
"label": "missing_context",
|
|
675
|
+
"summary": "One run observed file context that the other run did not capture before diverging.",
|
|
676
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
677
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
678
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
679
|
+
"impact_summary": "The missing file read changed the context available to the run before the result diverged.",
|
|
680
|
+
"confidence": "medium",
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
"label": "file_change_divergence",
|
|
684
|
+
"summary": "The runs produced different file changes or patched different content.",
|
|
685
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
686
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
687
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
688
|
+
"impact_summary": "The file state diverged before the final output or artifact changed.",
|
|
689
|
+
"confidence": "high",
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
different_artifact = next((item for item in artifact_diff if item["relation"] == "different"), None)
|
|
693
|
+
if different_artifact is not None:
|
|
694
|
+
return {
|
|
695
|
+
"label": "artifact_mismatch",
|
|
696
|
+
"summary": "The runs ended with different persisted artifacts despite sharing part of the execution path.",
|
|
697
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
698
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
699
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
700
|
+
"impact_summary": "The observable output differed at the artifact layer.",
|
|
701
|
+
"confidence": "medium",
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
"label": "no_observable_root_cause",
|
|
706
|
+
"summary": "The runs diverged, but the first causal reason is not observable from the stored events.",
|
|
707
|
+
"first_causal_event_left": left_event_ids[0] if left_event_ids else None,
|
|
708
|
+
"first_causal_event_right": right_event_ids[0] if right_event_ids else None,
|
|
709
|
+
"supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
|
|
710
|
+
"impact_summary": "The result changed after an observable divergence, but not enough evidence was captured to classify it.",
|
|
711
|
+
"confidence": "low",
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _first_divergence(pairs: list[dict[str, Any]], left_lookup: dict[int, Event], right_lookup: dict[int, Event]) -> dict[str, Any] | None:
|
|
716
|
+
candidates = [pair for pair in pairs if pair["relation"] != "shared"]
|
|
717
|
+
if not candidates:
|
|
718
|
+
return None
|
|
719
|
+
|
|
720
|
+
def sort_key(pair: dict[str, Any]) -> int:
|
|
721
|
+
seqs: list[int] = []
|
|
722
|
+
if pair.get("left"):
|
|
723
|
+
seqs.append(_first_seq(pair["left"]["event_ids"], left_lookup))
|
|
724
|
+
if pair.get("right"):
|
|
725
|
+
seqs.append(_first_seq(pair["right"]["event_ids"], right_lookup))
|
|
726
|
+
return min(seqs) if seqs else 10**9
|
|
727
|
+
|
|
728
|
+
first_pair = min(candidates, key=sort_key)
|
|
729
|
+
return {
|
|
730
|
+
"first_mismatch_kind": first_pair["kind"],
|
|
731
|
+
"left_event_ids": first_pair["left"]["event_ids"] if first_pair.get("left") else [],
|
|
732
|
+
"right_event_ids": first_pair["right"]["event_ids"] if first_pair.get("right") else [],
|
|
733
|
+
"summary": first_pair["summary"],
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def compare_runs(
|
|
738
|
+
*,
|
|
739
|
+
left_run: Run,
|
|
740
|
+
right_run: Run,
|
|
741
|
+
left_events: list[Event],
|
|
742
|
+
right_events: list[Event],
|
|
743
|
+
left_artifacts: list[Artifact],
|
|
744
|
+
right_artifacts: list[Artifact],
|
|
745
|
+
left_decisions: list[Decision],
|
|
746
|
+
right_decisions: list[Decision],
|
|
747
|
+
) -> dict[str, Any]:
|
|
748
|
+
left_lookup = _event_map(left_events)
|
|
749
|
+
right_lookup = _event_map(right_events)
|
|
750
|
+
|
|
751
|
+
left_tool_chain = build_tool_chain(left_events)
|
|
752
|
+
right_tool_chain = build_tool_chain(right_events)
|
|
753
|
+
left_command_chain = build_command_chain(left_events)
|
|
754
|
+
right_command_chain = build_command_chain(right_events)
|
|
755
|
+
left_file_activity = build_file_activity(left_events)
|
|
756
|
+
right_file_activity = build_file_activity(right_events)
|
|
757
|
+
left_patch_activity = build_patch_activity(left_events)
|
|
758
|
+
right_patch_activity = build_patch_activity(right_events)
|
|
759
|
+
|
|
760
|
+
tool_diff = _align_sequence(
|
|
761
|
+
left_tool_chain,
|
|
762
|
+
right_tool_chain,
|
|
763
|
+
kind="tool",
|
|
764
|
+
label_key="tool_name",
|
|
765
|
+
summary_keys=("error_summary", "output_summary", "args_summary"),
|
|
766
|
+
metadata_builder=lambda item: {
|
|
767
|
+
"tool_name": item.get("tool_name"),
|
|
768
|
+
"error_code": item.get("error_code"),
|
|
769
|
+
"artifact_count": item.get("artifact_count"),
|
|
770
|
+
},
|
|
771
|
+
)
|
|
772
|
+
command_diff = _align_sequence(
|
|
773
|
+
left_command_chain,
|
|
774
|
+
right_command_chain,
|
|
775
|
+
kind="command",
|
|
776
|
+
label_key="command",
|
|
777
|
+
summary_keys=("error_summary", "output_summary"),
|
|
778
|
+
metadata_builder=lambda item: {
|
|
779
|
+
"cwd": item.get("cwd"),
|
|
780
|
+
"exit_code": item.get("exit_code"),
|
|
781
|
+
"error_code": item.get("error_code"),
|
|
782
|
+
},
|
|
783
|
+
)
|
|
784
|
+
decision_diff = _align_sequence(
|
|
785
|
+
[
|
|
786
|
+
{
|
|
787
|
+
"step_id": decision.id,
|
|
788
|
+
"label": decision.label,
|
|
789
|
+
"status": decision.outcome,
|
|
790
|
+
"summary": decision.rationale_summary,
|
|
791
|
+
"event_ids": decision.evidence_event_ids,
|
|
792
|
+
"duration_ms": None,
|
|
793
|
+
"kind": decision.kind,
|
|
794
|
+
}
|
|
795
|
+
for decision in left_decisions
|
|
796
|
+
],
|
|
797
|
+
[
|
|
798
|
+
{
|
|
799
|
+
"step_id": decision.id,
|
|
800
|
+
"label": decision.label,
|
|
801
|
+
"status": decision.outcome,
|
|
802
|
+
"summary": decision.rationale_summary,
|
|
803
|
+
"event_ids": decision.evidence_event_ids,
|
|
804
|
+
"duration_ms": None,
|
|
805
|
+
"kind": decision.kind,
|
|
806
|
+
}
|
|
807
|
+
for decision in right_decisions
|
|
808
|
+
],
|
|
809
|
+
kind="decision",
|
|
810
|
+
label_key="label",
|
|
811
|
+
summary_keys=("summary",),
|
|
812
|
+
metadata_builder=lambda item: {"kind": item.get("kind")},
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
file_diff = _compare_file_state(
|
|
816
|
+
_final_file_state(left_file_activity, left_patch_activity),
|
|
817
|
+
_final_file_state(right_file_activity, right_patch_activity),
|
|
818
|
+
)
|
|
819
|
+
artifact_diff = _compare_artifacts(left_artifacts, right_artifacts)
|
|
820
|
+
|
|
821
|
+
aligned_timeline = _align_timeline(
|
|
822
|
+
_timeline_sequence(left_tool_chain, left_command_chain, left_file_activity, left_patch_activity, left_lookup),
|
|
823
|
+
_timeline_sequence(right_tool_chain, right_command_chain, right_file_activity, right_patch_activity, right_lookup),
|
|
824
|
+
)
|
|
825
|
+
divergence = _first_divergence(aligned_timeline, left_lookup, right_lookup)
|
|
826
|
+
root_cause = _derive_root_cause(
|
|
827
|
+
left_run=left_run,
|
|
828
|
+
right_run=right_run,
|
|
829
|
+
divergence=divergence,
|
|
830
|
+
tool_diff=tool_diff,
|
|
831
|
+
file_diff=file_diff,
|
|
832
|
+
artifact_diff=artifact_diff,
|
|
833
|
+
left_tool_chain=left_tool_chain,
|
|
834
|
+
right_tool_chain=right_tool_chain,
|
|
835
|
+
left_lookup=left_lookup,
|
|
836
|
+
right_lookup=right_lookup,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
"overview_diff": _overview_diff(left_run, right_run, left_events, right_events),
|
|
841
|
+
"divergence": divergence,
|
|
842
|
+
"aligned_timeline": aligned_timeline,
|
|
843
|
+
"tool_diff": tool_diff,
|
|
844
|
+
"command_diff": command_diff,
|
|
845
|
+
"file_diff": file_diff,
|
|
846
|
+
"artifact_diff": artifact_diff,
|
|
847
|
+
"decision_diff": decision_diff,
|
|
848
|
+
"root_cause": root_cause,
|
|
849
|
+
"left_timeline_groups": build_timeline_groups(left_events),
|
|
850
|
+
"right_timeline_groups": build_timeline_groups(right_events),
|
|
851
|
+
}
|