execweave 0.6.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.
Files changed (49) hide show
  1. execweave/__init__.py +3 -0
  2. execweave/__main__.py +5 -0
  3. execweave/analysis.py +406 -0
  4. execweave/backends.py +63 -0
  5. execweave/benchmark.py +80 -0
  6. execweave/claude_adapter.py +448 -0
  7. execweave/claude_hook_cli.py +101 -0
  8. execweave/claude_record.py +106 -0
  9. execweave/cli.py +588 -0
  10. execweave/codex_adapter.py +314 -0
  11. execweave/codex_hook_cli.py +98 -0
  12. execweave/codex_record.py +111 -0
  13. execweave/collector.py +301 -0
  14. execweave/correlation.py +604 -0
  15. execweave/cursor_adapter.py +347 -0
  16. execweave/cursor_hook_cli.py +82 -0
  17. execweave/cursor_record.py +96 -0
  18. execweave/filesystem.py +103 -0
  19. execweave/focus.py +118 -0
  20. execweave/gemini_adapter.py +265 -0
  21. execweave/gemini_hook_cli.py +77 -0
  22. execweave/gemini_record.py +94 -0
  23. execweave/graph.py +300 -0
  24. execweave/graph_ops.py +446 -0
  25. execweave/inference_gateway.py +422 -0
  26. execweave/inference_gateway_cli.py +106 -0
  27. execweave/inference_identity.py +76 -0
  28. execweave/inference_identity_cli.py +60 -0
  29. execweave/live.py +275 -0
  30. execweave/model_runtime.py +535 -0
  31. execweave/model_runtime_cli.py +154 -0
  32. execweave/opencode_adapter.py +316 -0
  33. execweave/opencode_hook_cli.py +57 -0
  34. execweave/opencode_plugin_cli.py +110 -0
  35. execweave/opencode_record.py +96 -0
  36. execweave/overhead_benchmark.py +440 -0
  37. execweave/provider_record.py +215 -0
  38. execweave/schema.py +62 -0
  39. execweave/semantic.py +346 -0
  40. execweave/sink.py +33 -0
  41. execweave/strace_backend.py +682 -0
  42. execweave/validate.py +193 -0
  43. execweave/viewer.py +283 -0
  44. execweave/workflow.py +114 -0
  45. execweave-0.6.0.dist-info/METADATA +356 -0
  46. execweave-0.6.0.dist-info/RECORD +49 -0
  47. execweave-0.6.0.dist-info/WHEEL +4 -0
  48. execweave-0.6.0.dist-info/entry_points.txt +17 -0
  49. execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
execweave/validate.py ADDED
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .schema import SCHEMA_VERSION
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ValidationResult:
14
+ path: str
15
+ valid: bool
16
+ event_count: int
17
+ errors: list[str]
18
+ warnings: list[str]
19
+ session_ids: list[str]
20
+ schema_versions: list[str]
21
+
22
+ def to_dict(self) -> dict[str, object]:
23
+ return {
24
+ "path": self.path,
25
+ "valid": self.valid,
26
+ "event_count": self.event_count,
27
+ "errors": self.errors,
28
+ "warnings": self.warnings,
29
+ "session_ids": self.session_ids,
30
+ "schema_versions": self.schema_versions,
31
+ }
32
+
33
+
34
+ def _valid_timestamp(value: object) -> bool:
35
+ if not isinstance(value, str) or not value:
36
+ return False
37
+ try:
38
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
39
+ except ValueError:
40
+ return False
41
+ return True
42
+
43
+
44
+ def _validate_entity(
45
+ entity: object,
46
+ *,
47
+ line_number: int,
48
+ field_name: str,
49
+ errors: list[str],
50
+ ) -> None:
51
+ if entity is None:
52
+ return
53
+ if not isinstance(entity, dict):
54
+ errors.append(f"line {line_number}: {field_name} must be an object or null")
55
+ return
56
+ for key in ("type", "id"):
57
+ value = entity.get(key)
58
+ if not isinstance(value, str) or not value:
59
+ errors.append(f"line {line_number}: {field_name}.{key} must be a non-empty string")
60
+
61
+
62
+ def validate_event_stream(
63
+ path: str | Path,
64
+ *,
65
+ require_complete_session: bool = True,
66
+ ) -> ValidationResult:
67
+ stream_path = Path(path).expanduser().resolve()
68
+ errors: list[str] = []
69
+ warnings: list[str] = []
70
+ session_ids: set[str] = set()
71
+ schema_versions: set[str] = set()
72
+ event_ids: set[str] = set()
73
+ sequences: list[int] = []
74
+ event_types: list[str] = []
75
+ event_count = 0
76
+
77
+ if not stream_path.exists():
78
+ return ValidationResult(
79
+ path=str(stream_path),
80
+ valid=False,
81
+ event_count=0,
82
+ errors=["event stream does not exist"],
83
+ warnings=[],
84
+ session_ids=[],
85
+ schema_versions=[],
86
+ )
87
+
88
+ for line_number, raw_line in enumerate(
89
+ stream_path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
90
+ ):
91
+ if not raw_line.strip():
92
+ warnings.append(f"line {line_number}: empty line ignored")
93
+ continue
94
+ try:
95
+ payload: Any = json.loads(raw_line)
96
+ except json.JSONDecodeError as exc:
97
+ errors.append(f"line {line_number}: invalid JSON: {exc.msg}")
98
+ continue
99
+ if not isinstance(payload, dict):
100
+ errors.append(f"line {line_number}: event must be a JSON object")
101
+ continue
102
+
103
+ event_count += 1
104
+ schema_version = payload.get("schema_version")
105
+ if isinstance(schema_version, str):
106
+ schema_versions.add(schema_version)
107
+ else:
108
+ errors.append(f"line {line_number}: schema_version must be a string")
109
+
110
+ event_id = payload.get("event_id")
111
+ if not isinstance(event_id, str) or not event_id:
112
+ errors.append(f"line {line_number}: event_id must be a non-empty string")
113
+ elif event_id in event_ids:
114
+ errors.append(f"line {line_number}: duplicate event_id {event_id}")
115
+ else:
116
+ event_ids.add(event_id)
117
+
118
+ session_id = payload.get("session_id")
119
+ if not isinstance(session_id, str) or not session_id:
120
+ errors.append(f"line {line_number}: session_id must be a non-empty string")
121
+ else:
122
+ session_ids.add(session_id)
123
+
124
+ sequence = payload.get("sequence")
125
+ if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 1:
126
+ errors.append(f"line {line_number}: sequence must be a positive integer")
127
+ else:
128
+ sequences.append(sequence)
129
+
130
+ timestamp = payload.get("timestamp")
131
+ if not _valid_timestamp(timestamp):
132
+ errors.append(f"line {line_number}: timestamp must be ISO-8601")
133
+
134
+ event_type = payload.get("event_type")
135
+ if not isinstance(event_type, str) or not event_type:
136
+ errors.append(f"line {line_number}: event_type must be a non-empty string")
137
+ else:
138
+ event_types.append(event_type)
139
+
140
+ relation = payload.get("relation")
141
+ if not isinstance(relation, str) or not relation:
142
+ errors.append(f"line {line_number}: relation must be a non-empty string")
143
+
144
+ _validate_entity(payload.get("source"), line_number=line_number, field_name="source", errors=errors)
145
+ _validate_entity(payload.get("target"), line_number=line_number, field_name="target", errors=errors)
146
+
147
+ attributes = payload.get("attributes")
148
+ if not isinstance(attributes, dict):
149
+ errors.append(f"line {line_number}: attributes must be an object")
150
+
151
+ if event_count == 0:
152
+ errors.append("event stream contains no events")
153
+
154
+ if len(session_ids) > 1:
155
+ errors.append(
156
+ "event stream contains multiple session IDs: " + ", ".join(sorted(session_ids))
157
+ )
158
+
159
+ if sequences:
160
+ expected = list(range(1, len(sequences) + 1))
161
+ if sequences != expected:
162
+ errors.append(
163
+ "sequence is not contiguous from 1; "
164
+ f"observed first/last={sequences[0]}/{sequences[-1]} count={len(sequences)}"
165
+ )
166
+
167
+ if schema_versions and schema_versions != {SCHEMA_VERSION}:
168
+ warnings.append(
169
+ "stream schema differs from current ExecWeave schema "
170
+ f"{SCHEMA_VERSION}: {', '.join(sorted(schema_versions))}"
171
+ )
172
+
173
+ if require_complete_session and event_count:
174
+ starts = event_types.count("session.started")
175
+ finishes = event_types.count("session.finished")
176
+ if starts != 1:
177
+ errors.append(f"expected exactly one session.started event, found {starts}")
178
+ if finishes != 1:
179
+ errors.append(f"expected exactly one session.finished event, found {finishes}")
180
+ if event_types and event_types[0] != "session.started":
181
+ warnings.append("first event is not session.started")
182
+ if event_types and event_types[-1] != "session.finished":
183
+ warnings.append("last event is not session.finished")
184
+
185
+ return ValidationResult(
186
+ path=str(stream_path),
187
+ valid=not errors,
188
+ event_count=event_count,
189
+ errors=errors,
190
+ warnings=warnings,
191
+ session_ids=sorted(session_ids),
192
+ schema_versions=sorted(schema_versions),
193
+ )
execweave/viewer.py ADDED
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import webbrowser
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .graph_ops import load_graph
9
+
10
+
11
+ def _safe_embedded_json(payload: dict[str, Any]) -> str:
12
+ return (
13
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
14
+ .replace("<", "\\u003c")
15
+ .replace(">", "\\u003e")
16
+ .replace("&", "\\u0026")
17
+ )
18
+
19
+
20
+ _VIEWER_TEMPLATE = """<!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8">
24
+ <meta name="viewport" content="width=device-width,initial-scale=1">
25
+ <title>ExecWeave — Execution Graph</title>
26
+ <style>
27
+ :root{color-scheme:dark;--bg:#0b0f14;--panel:#111821;--panel2:#18222e;--text:#e8edf3;--muted:#8ea0b5;--border:#2a3949;--edge:#72869c;--causal:#70d6a6;--noncausal:#f2b76d;--inferred:#c08cff;--identity:#38bdf8;--selected:#73b7ff}
28
+ *{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font:14px/1.4 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
29
+ #app{display:grid;grid-template-columns:minmax(0,1fr) 340px;grid-template-rows:auto minmax(0,1fr);width:100%;height:100%}
30
+ header{grid-column:1/3;display:flex;align-items:center;gap:9px;padding:9px 14px;border-bottom:1px solid var(--border);background:var(--panel);flex-wrap:wrap}
31
+ header strong{font-size:16px;margin-right:6px}.stats{color:var(--muted);white-space:nowrap;margin-right:auto}
32
+ input,select{border:1px solid var(--border);border-radius:7px;padding:7px 9px;background:var(--panel2);color:var(--text);outline:none}input:focus,select:focus{border-color:var(--selected)}
33
+ #search{width:min(240px,28vw)}#preset-select{max-width:160px}.toggle{display:inline-flex;align-items:center;gap:5px;color:var(--muted);font-size:12px;white-space:nowrap}.toggle input{accent-color:var(--selected)}
34
+ .timeline{display:flex;align-items:center;gap:7px;width:100%;padding-top:2px;color:var(--muted);font-size:12px}.timeline input[type=range]{flex:1;min-width:120px;padding:0;accent-color:var(--selected)}#sequence-label{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap}
35
+ #canvas-wrap{position:relative;min-width:0;min-height:0;overflow:hidden}#graph{width:100%;height:100%;display:block;cursor:grab;user-select:none}#graph.panning{cursor:grabbing}
36
+ aside{overflow:auto;border-left:1px solid var(--border);background:var(--panel);padding:16px}aside h2{margin:0 0 12px;font-size:15px}aside h3{margin:18px 0 8px;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
37
+ #details pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.empty{color:var(--muted)}.detail-actions{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 8px}.identity-note{margin:10px 0;padding:8px 10px;border:1px solid var(--identity);border-radius:7px;color:var(--text);background:var(--panel2);font-size:12px}
38
+ .correlation-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.correlation-stat{border:1px solid var(--border);border-radius:7px;background:var(--panel2);padding:7px 9px;color:var(--muted);font-size:11px}.correlation-stat strong{display:block;color:var(--text);font-size:16px;line-height:1.2;margin-top:2px}
39
+ button{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:7px;padding:6px 9px;cursor:pointer}button:hover{border-color:var(--selected)}button:disabled{opacity:.45;cursor:default}
40
+ .controls{position:absolute;top:12px;left:12px;z-index:5;display:flex;gap:6px;flex-wrap:wrap}
41
+ .node rect{stroke:var(--border);stroke-width:1.2;rx:9;ry:9}.node text{pointer-events:none;fill:var(--text)}.node .node-type{fill:var(--muted);font-size:10px}.node.selected rect{stroke:var(--selected);stroke-width:2.5}.node.dim{opacity:.13}.node.cluster rect{stroke:var(--selected);stroke-dasharray:6 4}
42
+ .edge{fill:none;stroke:var(--edge);stroke-width:1.4;opacity:.75;cursor:pointer}.edge.causal{stroke:var(--causal)}.edge.noncausal{stroke:var(--noncausal);stroke-dasharray:6 5}.edge.inferred{stroke:var(--inferred);stroke-width:1.8;stroke-dasharray:2 5}.edge.identity{stroke:var(--identity);stroke-width:2.2;stroke-dasharray:none}.edge.dim{opacity:.06}.edge-hit{fill:none;stroke:transparent;stroke-width:12;cursor:pointer}.edge-label{fill:var(--muted);font-size:9px;pointer-events:none}.edge-label.dim{opacity:.08}
43
+ .legend{display:flex;flex-wrap:wrap;gap:8px 12px}.legend span{display:inline-flex;align-items:center;gap:5px;color:var(--muted);font-size:12px}.dot{width:9px;height:9px;border-radius:50%;display:inline-block}
44
+ @media(max-width:900px){#app{grid-template-columns:1fr;grid-template-rows:auto minmax(0,1fr) 220px}header{grid-column:1}aside{border-left:0;border-top:1px solid var(--border)}#search{width:160px}}
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div id="app">
49
+ <header>
50
+ <strong>ExecWeave</strong><span class="stats" id="stats"></span>
51
+ <select id="type-filter" title="Node type"><option value="">All node types</option></select>
52
+ <select id="relation-filter" title="Relation"><option value="">All relations</option></select>
53
+ <label class="toggle"><input id="causal-filter" type="checkbox"> causal only</label>
54
+ <label class="toggle"><input id="observed-only-filter" type="checkbox"> observed only</label>
55
+ <input id="search" placeholder="Search visible graph…" autocomplete="off">
56
+ <select id="preset-select" title="Saved view presets"><option value="">Saved views</option></select>
57
+ <button id="save-preset" type="button">Save view</button>
58
+ <button id="delete-preset" type="button" disabled>Delete view</button>
59
+ <div class="timeline" id="timeline">
60
+ <button id="timeline-play" type="button">Play</button>
61
+ <span>Evidence sequence</span>
62
+ <input id="sequence-filter" type="range" min="0" max="0" value="0" step="1">
63
+ <span id="sequence-label">0 / 0</span>
64
+ </div>
65
+ </header>
66
+ <div id="canvas-wrap">
67
+ <div class="controls">
68
+ <button id="fit">Fit</button>
69
+ <button id="reset">Reset</button>
70
+ <button id="clear-focus" disabled>Clear focus</button>
71
+ <button id="collapse-clusters" disabled>Collapse clusters</button>
72
+ </div>
73
+ <svg id="graph" aria-label="ExecWeave execution graph">
74
+ <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10z" fill="context-stroke"></path></marker></defs>
75
+ <g id="viewport"><g id="edges"></g><g id="labels"></g><g id="nodes"></g></g>
76
+ </svg>
77
+ </div>
78
+ <aside>
79
+ <h2>Selection</h2><div id="details" class="empty">Click a node or edge.</div>
80
+ <section id="correlation-section" hidden><h3>Correlation</h3><div id="correlation-summary" class="correlation-summary"></div><div id="correlation-note" class="empty"></div></section>
81
+ <h3>Saved views</h3><div class="empty">Save the current node/relation/causal/observed-only filters, search text, timeline position, focused neighborhood, and expanded clusters as a browser-local preset. Graph evidence is never copied into preset storage. If browser storage is unavailable, presets safely fall back to this page session only.</div>
82
+ <h3>Focus</h3><div class="empty">Click a node, then choose <strong>Focus 1 hop</strong> or <strong>Focus 2 hops</strong>. Focus follows only edges allowed by the current timeline, relation, causal, and observed-only filters; it never creates inferred edges.</div>
83
+ <h3>Clusters</h3><div class="empty">Expandable cluster nodes have a dashed outline. Click one, then choose <strong>Expand cluster</strong>. Only graphs created with <code>graph-condense --keep-expansion</code> carry the original member evidence.</div>
84
+ <h3>Timeline</h3><div class="empty">Move the evidence-sequence slider or press Play to replay how the graph grew. Aggregated edges spanning future evidence are marked <code>partial</code>; future counts are never shown early.</div>
85
+ <h3>Filters</h3><div class="empty">Node type and relation filters change the visible subgraph. <strong>Observed only</strong> removes derived inferred relationships before focus traversal and layout. Exact identity remains visible because it is explicit identity evidence, while still non-causal. Search highlights within the remaining subgraph.</div>
86
+ <h3>Edge semantics</h3><div class="legend"><span><i class="dot" style="background:var(--causal)"></i>Causal evidence</span><span><i class="dot" style="background:var(--identity)"></i>Exact identity</span><span><i class="dot" style="background:var(--noncausal)"></i>Non-causal observation</span><span><i class="dot" style="background:var(--inferred)"></i>Inferred correlation</span><span><i class="dot" style="background:var(--edge)"></i>Mixed / unspecified</span></div><div class="empty">Exact identity means two observations carry an explicit shared identity; it does not prove one layer caused another. Inferred edges are heuristic correlations backed by explicit supporting evidence. They are not observed or causal evidence.</div>
87
+ </aside>
88
+ </div>
89
+ <script type="application/json" id="graph-data">__GRAPH_DATA__</script>
90
+ <script>
91
+ (()=>{
92
+ const graph=JSON.parse(document.getElementById('graph-data').textContent);
93
+ const baseNodes=graph.nodes||[],baseEdges=graph.edges||[];
94
+ const expansionClusters=(graph.expansion&&graph.expansion.clusters)||{};
95
+ const possibleNodes=[...baseNodes],possibleEdges=[...baseEdges];
96
+ Object.values(expansionClusters).forEach(entry=>{(entry.nodes||[]).forEach(n=>possibleNodes.push(n));(entry.edges||[]).forEach(e=>possibleEdges.push(e))});
97
+ const svg=document.getElementById('graph'),viewport=document.getElementById('viewport'),edgeLayer=document.getElementById('edges'),labelLayer=document.getElementById('labels'),nodeLayer=document.getElementById('nodes');
98
+ const details=document.getElementById('details'),search=document.getElementById('search'),stats=document.getElementById('stats'),typeFilter=document.getElementById('type-filter'),relationFilter=document.getElementById('relation-filter'),causalFilter=document.getElementById('causal-filter'),observedOnlyFilter=document.getElementById('observed-only-filter');
99
+ const correlationSection=document.getElementById('correlation-section'),correlationSummary=document.getElementById('correlation-summary'),correlationNote=document.getElementById('correlation-note');
100
+ const timeline=document.getElementById('timeline'),sequenceFilter=document.getElementById('sequence-filter'),sequenceLabel=document.getElementById('sequence-label'),playButton=document.getElementById('timeline-play'),collapseButton=document.getElementById('collapse-clusters'),clearFocusButton=document.getElementById('clear-focus');
101
+ const presetSelect=document.getElementById('preset-select'),savePresetButton=document.getElementById('save-preset'),deletePresetButton=document.getElementById('delete-preset');
102
+ const presetStorageKey=`execweave.viewer.presets.v1:${graph.session_id||'graph'}`;
103
+ const sequenceOf=(edge,key)=>Number.isInteger(edge[key])?edge[key]:null;
104
+ const maxSequence=Math.max(0,...possibleEdges.map(edge=>sequenceOf(edge,'last_sequence')??sequenceOf(edge,'first_sequence')??0));
105
+ let selectedSequence=maxSequence,playTimer=null,expandedClusters=new Set(),focusState=null,currentNodes=[],currentEdges=[],visibleNodes=[],visibleEdges=[],nodeById=new Map(),positions=new Map(),nodeElements=new Map(),edgeElements=[];
106
+ let transform={x:40,y:40,scale:1},panStart=null,dragNode=null,presets={},presetStorageAvailable=true;
107
+
108
+ function uniqueById(values){const seen=new Set();return values.filter(value=>{if(!value||!value.id||seen.has(value.id))return false;seen.add(value.id);return true})}
109
+ function materializedGraph(){
110
+ const hiddenClusterEdges=new Set();
111
+ expandedClusters.forEach(id=>{const entry=expansionClusters[id];if(entry&&entry.cluster_edge_id)hiddenClusterEdges.add(entry.cluster_edge_id)});
112
+ let nodes=baseNodes.filter(node=>!expandedClusters.has(node.id));
113
+ let edges=baseEdges.filter(edge=>!hiddenClusterEdges.has(edge.id));
114
+ expandedClusters.forEach(id=>{const entry=expansionClusters[id];if(!entry)return;nodes=nodes.concat(entry.nodes||[]);edges=edges.concat(entry.edges||[])});
115
+ return{nodes:uniqueById(nodes),edges:uniqueById(edges)};
116
+ }
117
+ function option(select,value){const o=document.createElement('option');o.value=value;o.textContent=value;select.appendChild(o)}
118
+ [...new Set(possibleNodes.map(n=>n.type).filter(Boolean))].sort().forEach(v=>option(typeFilter,v));
119
+ [...new Set(possibleEdges.map(e=>e.relation).filter(Boolean))].sort().forEach(v=>option(relationFilter,v));
120
+ if(maxSequence>0){sequenceFilter.max=String(maxSequence);sequenceFilter.value=String(maxSequence);sequenceLabel.textContent=`${maxSequence} / ${maxSequence}`}else{timeline.style.display='none'}
121
+ function colorForType(type){let h=0;for(const ch of String(type||'unknown'))h=((h<<5)-h+ch.charCodeAt(0))|0;return `hsl(${Math.abs(h)%360} 38% 27%)`}
122
+ function labelFor(node){const raw=node.name||node.id||node.type||'node';return raw.length>30?raw.slice(0,27)+'…':raw}
123
+ function edgeExistsAt(edge,sequence){const first=sequenceOf(edge,'first_sequence');return first===null||first<=sequence}
124
+ function edgeLabel(edge){const relation=edge.identity_exact===true?`${edge.relation} · exact identity`:edge.inferred===true?`${edge.relation} · inferred`:edge.relation;if(!(edge.count>1))return relation;const last=sequenceOf(edge,'last_sequence');if(selectedSequence>=maxSequence||last===null||last<=selectedSequence)return `${relation} ×${edge.count}`;return `${relation} · partial`}
125
+ function evidenceEdges(nodes,edges){
126
+ const ids=new Set(nodes.map(n=>n.id)),relation=relationFilter.value,causal=causalFilter.checked,observedOnly=observedOnlyFilter.checked;
127
+ return edges.filter(e=>ids.has(e.source)&&ids.has(e.target)&&(!relation||e.relation===relation)&&(!causal||e.causal===true)&&(!observedOnly||e.inferred!==true)&&edgeExistsAt(e,selectedSequence));
128
+ }
129
+ function focusNeighborhood(nodes,edges,state){
130
+ if(!state)return new Set(nodes.map(n=>n.id));
131
+ const ids=new Set(nodes.map(n=>n.id));if(!ids.has(state.anchor))return new Set();
132
+ const adjacency=new Map();nodes.forEach(n=>adjacency.set(n.id,[]));
133
+ edges.forEach(e=>{if(!adjacency.has(e.source)||!adjacency.has(e.target))return;adjacency.get(e.source).push(e.target);adjacency.get(e.target).push(e.source)});
134
+ const selected=new Set([state.anchor]),queue=[[state.anchor,0]];
135
+ for(let i=0;i<queue.length;i++){const [id,depth]=queue[i];if(depth>=state.hops)continue;for(const next of adjacency.get(id)||[]){if(selected.has(next))continue;selected.add(next);queue.push([next,depth+1])}}
136
+ return selected;
137
+ }
138
+ function applyGraphFilters(){
139
+ const materialized=materializedGraph();currentNodes=materialized.nodes;currentEdges=materialized.edges;
140
+ const type=typeFilter.value,relation=relationFilter.value,causal=causalFilter.checked,observedOnly=observedOnlyFilter.checked,timelineActive=maxSequence>0&&selectedSequence<maxSequence;
141
+ const eligible=evidenceEdges(currentNodes,currentEdges),focusIds=focusNeighborhood(currentNodes,eligible,focusState);
142
+ let nodes=currentNodes.filter(n=>focusIds.has(n.id)&&(!type||n.type===type));let ids=new Set(nodes.map(n=>n.id));
143
+ let edges=eligible.filter(e=>ids.has(e.source)&&ids.has(e.target));
144
+ if(relation||causal||observedOnly||timelineActive){const connected=new Set();edges.forEach(e=>{connected.add(e.source);connected.add(e.target)});if(focusState&&ids.has(focusState.anchor))connected.add(focusState.anchor);nodes=nodes.filter(n=>connected.has(n.id));ids=new Set(nodes.map(n=>n.id));edges=edges.filter(e=>ids.has(e.source)&&ids.has(e.target))}
145
+ visibleNodes=nodes;visibleEdges=edges;nodeById=new Map(nodes.map(n=>[n.id,n]));positions=new Map();collapseButton.disabled=expandedClusters.size===0;clearFocusButton.disabled=focusState===null;
146
+ computeLayout();renderNodes();renderEdges();updateStats();applyTransform();applySearch();requestAnimationFrame(fit);
147
+ }
148
+ function updateStats(){
149
+ const seq=maxSequence>0?` · seq ${selectedSequence}/${maxSequence}`:'';
150
+ const expanded=expandedClusters.size?` · ${expandedClusters.size} cluster${expandedClusters.size===1?'':'s'} expanded`:'';
151
+ const focused=focusState?` · focus ${focusState.hops}-hop`:'';
152
+ const observed=observedOnlyFilter.checked?' · observed only':'';
153
+ stats.textContent=`${visibleNodes.length}/${currentNodes.length} nodes · ${visibleEdges.length}/${currentEdges.length} edges · ${graph.event_count??0} events${seq}${expanded}${focused}${observed}`;
154
+ }
155
+ function renderCorrelationSummary(){
156
+ const correlation=graph.metadata&&graph.metadata.correlation;
157
+ if(!correlation||typeof correlation!=='object')return;
158
+ const items=[
159
+ ['Matched',correlation.correlated_tool_calls],
160
+ ['Ambiguous',correlation.skipped_ambiguous],
161
+ ['No match',correlation.skipped_no_match],
162
+ ['Unsupported',correlation.skipped_unsupported],
163
+ ['Considered',correlation.tool_calls_considered],
164
+ ['Window (ms)',correlation.max_window_ms],
165
+ ];
166
+ correlationSummary.replaceChildren();
167
+ items.forEach(([label,value])=>{const box=document.createElement('div');box.className='correlation-stat';const name=document.createElement('span');name.textContent=label;const count=document.createElement('strong');count.textContent=Number.isFinite(Number(value))?String(value):'—';box.append(name,count);correlationSummary.appendChild(box)});
168
+ correlationNote.textContent='Missing inferred edges can mean conservative rejection: ambiguous, unmatched, or unsupported tool calls intentionally produce no bridge.';
169
+ correlationSection.hidden=false;
170
+ }
171
+ function computeLayout(){
172
+ const ids=[...nodeById.keys()],indegree=new Map(ids.map(id=>[id,0])),outgoing=new Map(ids.map(id=>[id,[]]));
173
+ visibleEdges.forEach(e=>{if(!nodeById.has(e.source)||!nodeById.has(e.target))return;indegree.set(e.target,(indegree.get(e.target)||0)+1);outgoing.get(e.source).push(e.target)});
174
+ const roots=ids.filter(id=>(indegree.get(id)||0)===0);if(!roots.length&&ids.length)roots.push(ids[0]);const depth=new Map(),q=roots.map(id=>[id,0]);q.forEach(([id,d])=>depth.set(id,d));
175
+ for(let i=0;i<q.length;i++){const [id,d]=q[i];for(const next of outgoing.get(id)||[]){if(!depth.has(next)){depth.set(next,d+1);q.push([next,d+1])}}}
176
+ const max=Math.max(0,...depth.values());ids.forEach(id=>{if(!depth.has(id))depth.set(id,max+1)});const layers=new Map();ids.forEach(id=>{const d=depth.get(id);if(!layers.has(d))layers.set(d,[]);layers.get(d).push(id)});
177
+ [...layers.entries()].sort((a,b)=>a[0]-b[0]).forEach(([d,layer])=>{layer.sort((a,b)=>String(nodeById.get(a).type).localeCompare(String(nodeById.get(b).type))||a.localeCompare(b));layer.forEach((id,i)=>positions.set(id,{x:d*250,y:i*88}))});
178
+ }
179
+ function applyTransform(){viewport.setAttribute('transform',`translate(${transform.x} ${transform.y}) scale(${transform.scale})`)}
180
+ function anchor(id,right){const p=positions.get(id)||{x:0,y:0};return{x:p.x+(right?170:0),y:p.y+29}}
181
+ function edgePath(e){const a=anchor(e.source,true),b=anchor(e.target,false),bend=Math.max(45,Math.abs(b.x-a.x)*.45);return `M ${a.x} ${a.y} C ${a.x+bend} ${a.y}, ${b.x-bend} ${b.y}, ${b.x} ${b.y}`}
182
+ function setFocus(nodeId,hops){focusState={anchor:nodeId,hops};details.textContent=`Focused ${hops}-hop runtime neighborhood around ${nodeId}.`;applyGraphFilters()}
183
+ function showDetails(kind,value){
184
+ details.classList.remove('empty');details.replaceChildren();const t=document.createElement('strong');t.textContent=kind;const p=document.createElement('pre');p.textContent=JSON.stringify(value,null,2);details.append(t,document.createElement('br'));
185
+ if(kind==='Node'){
186
+ const actions=document.createElement('div');actions.className='detail-actions';
187
+ [1,2].forEach(hops=>{const button=document.createElement('button');button.textContent=`Focus ${hops} ${hops===1?'hop':'hops'}`;button.addEventListener('click',()=>setFocus(value.id,hops));actions.appendChild(button)});
188
+ if(expansionClusters[value.id]&&!expandedClusters.has(value.id)){const button=document.createElement('button');button.textContent='Expand cluster';button.addEventListener('click',()=>{focusState=null;expandedClusters.add(value.id);details.textContent='Cluster expanded into original evidence nodes.';applyGraphFilters()});actions.appendChild(button)}
189
+ details.append(actions);
190
+ }else if(value.identity_exact===true){const note=document.createElement('div');note.className='identity-note';note.textContent='Exact identity: explicit shared identity evidence. This edge is non-causal and not inferred.';details.append(note)}else{details.append(document.createElement('br'))}
191
+ details.append(p);
192
+ }
193
+ function renderEdges(){
194
+ edgeLayer.replaceChildren();labelLayer.replaceChildren();edgeElements=[];
195
+ visibleEdges.forEach(edge=>{if(!positions.has(edge.source)||!positions.has(edge.target))return;const d=edgePath(edge),line=document.createElementNS('http://www.w3.org/2000/svg','path');line.setAttribute('d',d);line.setAttribute('marker-end','url(#arrow)');line.classList.add('edge');if(edge.identity_exact===true)line.classList.add('identity');else if(edge.inferred===true)line.classList.add('inferred');else if(edge.causal===true)line.classList.add('causal');else if(edge.causal===false)line.classList.add('noncausal');edgeLayer.appendChild(line);const hit=document.createElementNS('http://www.w3.org/2000/svg','path');hit.setAttribute('d',d);hit.classList.add('edge-hit');hit.addEventListener('click',ev=>{ev.stopPropagation();showDetails('Edge',edge)});edgeLayer.appendChild(hit);const a=anchor(edge.source,true),b=anchor(edge.target,false),text=document.createElementNS('http://www.w3.org/2000/svg','text');text.setAttribute('x',String((a.x+b.x)/2));text.setAttribute('y',String((a.y+b.y)/2-7));text.setAttribute('text-anchor','middle');text.classList.add('edge-label');text.textContent=edgeLabel(edge);labelLayer.appendChild(text);edgeElements.push({edge,visible:line,hit,text})});
196
+ }
197
+ function renderNodes(){
198
+ nodeLayer.replaceChildren();nodeElements=new Map();
199
+ visibleNodes.forEach(node=>{const p=positions.get(node.id);if(!p)return;const g=document.createElementNS('http://www.w3.org/2000/svg','g');g.classList.add('node');if(expansionClusters[node.id])g.classList.add('cluster');g.setAttribute('transform',`translate(${p.x} ${p.y})`);const r=document.createElementNS('http://www.w3.org/2000/svg','rect');r.setAttribute('width','170');r.setAttribute('height','58');r.setAttribute('fill',colorForType(node.type));g.appendChild(r);const type=document.createElementNS('http://www.w3.org/2000/svg','text');type.setAttribute('x','12');type.setAttribute('y','18');type.classList.add('node-type');type.textContent=node.type||'unknown';g.appendChild(type);const label=document.createElementNS('http://www.w3.org/2000/svg','text');label.setAttribute('x','12');label.setAttribute('y','39');label.textContent=labelFor(node);g.appendChild(label);g.addEventListener('pointerdown',ev=>{ev.stopPropagation();const pt=svgPoint(ev);dragNode={id:node.id,dx:pt.x-p.x,dy:pt.y-p.y};g.setPointerCapture(ev.pointerId)});g.addEventListener('pointermove',ev=>{if(!dragNode||dragNode.id!==node.id)return;const pt=svgPoint(ev),np={x:pt.x-dragNode.dx,y:pt.y-dragNode.dy};positions.set(node.id,np);g.setAttribute('transform',`translate(${np.x} ${np.y})`);renderEdges()});g.addEventListener('pointerup',ev=>{dragNode=null;try{g.releasePointerCapture(ev.pointerId)}catch(_){}});g.addEventListener('click',ev=>{ev.stopPropagation();document.querySelectorAll('.node.selected').forEach(el=>el.classList.remove('selected'));g.classList.add('selected');showDetails('Node',node)});nodeLayer.appendChild(g);nodeElements.set(node.id,g)});
200
+ }
201
+ function svgPoint(ev){const rect=svg.getBoundingClientRect();return{x:(ev.clientX-rect.left-transform.x)/transform.scale,y:(ev.clientY-rect.top-transform.y)/transform.scale}}
202
+ function fit(){if(!positions.size)return;const xs=[...positions.values()].map(p=>p.x),ys=[...positions.values()].map(p=>p.y),minX=Math.min(...xs),maxX=Math.max(...xs)+170,minY=Math.min(...ys),maxY=Math.max(...ys)+58,box=svg.getBoundingClientRect(),width=Math.max(1,maxX-minX),height=Math.max(1,maxY-minY),scale=Math.min(1.25,Math.max(.08,Math.min((box.width-70)/width,(box.height-70)/height)));transform={x:35-minX*scale,y:35-minY*scale,scale};applyTransform()}
203
+ function applySearch(){const q=search.value.trim().toLowerCase();if(!q){nodeElements.forEach(el=>el.classList.remove('dim'));edgeElements.forEach(i=>{i.visible.classList.remove('dim');i.text.classList.remove('dim')});return}const matched=new Set();visibleNodes.forEach(n=>{if(`${n.id} ${n.name||''} ${n.type||''}`.toLowerCase().includes(q))matched.add(n.id)});nodeElements.forEach((el,id)=>el.classList.toggle('dim',!matched.has(id)));edgeElements.forEach(i=>{const keep=matched.has(i.edge.source)||matched.has(i.edge.target)||String(i.edge.relation).toLowerCase().includes(q);i.visible.classList.toggle('dim',!keep);i.text.classList.toggle('dim',!keep)})}
204
+ function stopPlayback(){if(playTimer!==null){clearInterval(playTimer);playTimer=null}playButton.textContent='Play'}
205
+ function setSequence(value){selectedSequence=Math.max(0,Math.min(maxSequence,Number(value)||0));sequenceFilter.value=String(selectedSequence);sequenceLabel.textContent=`${selectedSequence} / ${maxSequence}`;applyGraphFilters()}
206
+ function togglePlayback(){if(playTimer!==null){stopPlayback();return}if(maxSequence<=0)return;if(selectedSequence>=maxSequence)setSequence(0);playButton.textContent='Pause';const step=Math.max(1,Math.ceil(maxSequence/180));playTimer=setInterval(()=>{if(selectedSequence>=maxSequence){stopPlayback();return}setSequence(Math.min(maxSequence,selectedSequence+step))},160)}
207
+ function snapshotView(){return{version:1,node_type:typeFilter.value,relation:relationFilter.value,causal_only:causalFilter.checked,observed_only:observedOnlyFilter.checked,search:search.value,sequence:selectedSequence,focus:focusState?{anchor:focusState.anchor,hops:focusState.hops}:null,expanded_clusters:[...expandedClusters]}}
208
+ function renderPresetOptions(selected=''){
209
+ presetSelect.replaceChildren();const empty=document.createElement('option');empty.value='';empty.textContent='Saved views';presetSelect.appendChild(empty);
210
+ Object.keys(presets).sort((a,b)=>a.localeCompare(b)).forEach(name=>{const o=document.createElement('option');o.value=name;o.textContent=name;presetSelect.appendChild(o)});
211
+ presetSelect.value=selected&&presets[selected]?selected:'';deletePresetButton.disabled=!presetSelect.value;
212
+ }
213
+ function loadPresets(){
214
+ try{const raw=localStorage.getItem(presetStorageKey);const parsed=raw?JSON.parse(raw):{};presets=parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed:{}}
215
+ catch(_){presetStorageAvailable=false;presets={}}
216
+ renderPresetOptions();
217
+ }
218
+ function persistPresets(){try{localStorage.setItem(presetStorageKey,JSON.stringify(presets))}catch(_){presetStorageAvailable=false}}
219
+ function applyPreset(name){
220
+ const state=presets[name];if(!state||typeof state!=='object')return;stopPlayback();
221
+ typeFilter.value=typeof state.node_type==='string'?state.node_type:'';relationFilter.value=typeof state.relation==='string'?state.relation:'';causalFilter.checked=state.causal_only===true;observedOnlyFilter.checked=state.observed_only===true;search.value=typeof state.search==='string'?state.search:'';
222
+ selectedSequence=maxSequence>0?Math.max(0,Math.min(maxSequence,Number(state.sequence)||0)):0;if(maxSequence>0){sequenceFilter.value=String(selectedSequence);sequenceLabel.textContent=`${selectedSequence} / ${maxSequence}`}
223
+ const expanded=Array.isArray(state.expanded_clusters)?state.expanded_clusters:[];expandedClusters=new Set(expanded.filter(id=>expansionClusters[id]));
224
+ focusState=state.focus&&typeof state.focus.anchor==='string'&&Number.isInteger(state.focus.hops)?{anchor:state.focus.anchor,hops:Math.max(0,state.focus.hops)}:null;
225
+ details.textContent=`Loaded saved view: ${name}`;applyGraphFilters();
226
+ }
227
+ function savePreset(){
228
+ const name=(window.prompt('Saved view name')||'').trim();if(!name)return;presets[name]=snapshotView();persistPresets();renderPresetOptions(name);
229
+ details.textContent=presetStorageAvailable?`Saved view locally: ${name}`:`Saved view for this page session: ${name}`;
230
+ }
231
+ function deletePreset(){const name=presetSelect.value;if(!name||!presets[name])return;delete presets[name];persistPresets();renderPresetOptions();details.textContent=`Deleted saved view: ${name}`}
232
+ svg.addEventListener('pointerdown',ev=>{if(ev.target.closest?.('.node'))return;panStart={x:ev.clientX,y:ev.clientY,tx:transform.x,ty:transform.y};svg.classList.add('panning');svg.setPointerCapture(ev.pointerId)});
233
+ svg.addEventListener('pointermove',ev=>{if(!panStart)return;transform.x=panStart.tx+ev.clientX-panStart.x;transform.y=panStart.ty+ev.clientY-panStart.y;applyTransform()});
234
+ svg.addEventListener('pointerup',ev=>{panStart=null;svg.classList.remove('panning');try{svg.releasePointerCapture(ev.pointerId)}catch(_){}});
235
+ svg.addEventListener('wheel',ev=>{ev.preventDefault();const rect=svg.getBoundingClientRect(),mx=ev.clientX-rect.left,my=ev.clientY-rect.top,old=transform.scale,next=Math.min(4,Math.max(.08,old*Math.exp(-ev.deltaY*.0012))),gx=(mx-transform.x)/old,gy=(my-transform.y)/old;transform.scale=next;transform.x=mx-gx*next;transform.y=my-gy*next;applyTransform()},{passive:false});
236
+ search.addEventListener('input',applySearch);
237
+ [typeFilter,relationFilter,causalFilter,observedOnlyFilter].forEach(el=>el.addEventListener('change',applyGraphFilters));
238
+ sequenceFilter.addEventListener('input',()=>{stopPlayback();setSequence(sequenceFilter.value)});
239
+ playButton.addEventListener('click',togglePlayback);
240
+ clearFocusButton.addEventListener('click',()=>{focusState=null;details.textContent='Focused subgraph cleared.';applyGraphFilters()});
241
+ collapseButton.addEventListener('click',()=>{focusState=null;expandedClusters=new Set();details.textContent='All expandable clusters collapsed.';applyGraphFilters()});
242
+ presetSelect.addEventListener('change',()=>{deletePresetButton.disabled=!presetSelect.value;if(presetSelect.value)applyPreset(presetSelect.value)});
243
+ savePresetButton.addEventListener('click',savePreset);deletePresetButton.addEventListener('click',deletePreset);
244
+ document.getElementById('fit').addEventListener('click',fit);
245
+ document.getElementById('reset').addEventListener('click',()=>{computeLayout();renderNodes();renderEdges();applySearch();fit()});
246
+ window.addEventListener('resize',fit);
247
+ svg.addEventListener('click',()=>document.querySelectorAll('.node.selected').forEach(el=>el.classList.remove('selected')));
248
+ renderCorrelationSummary();loadPresets();applyGraphFilters();
249
+ })();
250
+ </script>
251
+ </body>
252
+ </html>
253
+ """
254
+
255
+
256
+ def render_graph_html(graph: dict[str, Any]) -> str:
257
+ return _VIEWER_TEMPLATE.replace("__GRAPH_DATA__", _safe_embedded_json(graph))
258
+
259
+
260
+ def write_graph_html(
261
+ graph: dict[str, Any],
262
+ path: str | Path,
263
+ *,
264
+ open_browser: bool = False,
265
+ ) -> Path:
266
+ output = Path(path).expanduser().resolve()
267
+ output.parent.mkdir(parents=True, exist_ok=True)
268
+ if output.exists() and output.stat().st_size > 0:
269
+ raise FileExistsError(f"ExecWeave viewer output already exists: {output}")
270
+ output.write_text(render_graph_html(graph), encoding="utf-8")
271
+ if open_browser:
272
+ webbrowser.open(output.as_uri())
273
+ return output
274
+
275
+
276
+ def build_viewer_from_graph(
277
+ graph_path: str | Path,
278
+ output_path: str | Path,
279
+ *,
280
+ open_browser: bool = False,
281
+ ) -> Path:
282
+ graph = load_graph(graph_path)
283
+ return write_graph_html(graph, output_path, open_browser=open_browser)
execweave/workflow.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+
7
+ from .backends import BackendName, create_collector, resolve_backend
8
+ from .graph import build_execution_graph, write_execution_graph
9
+ from .sink import JsonlSink
10
+ from .validate import validate_event_stream
11
+ from .viewer import write_graph_html
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RecordResult:
16
+ session_id: str
17
+ backend: str
18
+ return_code: int
19
+ output_dir: Path
20
+ event_stream: Path
21
+ graph: Path
22
+ viewer: Path
23
+ event_count: int
24
+ node_count: int
25
+ edge_count: int
26
+
27
+ def to_dict(self) -> dict[str, object]:
28
+ return {
29
+ "session_id": self.session_id,
30
+ "backend": self.backend,
31
+ "return_code": self.return_code,
32
+ "output_dir": str(self.output_dir),
33
+ "event_stream": str(self.event_stream),
34
+ "graph": str(self.graph),
35
+ "viewer": str(self.viewer),
36
+ "event_count": self.event_count,
37
+ "node_count": self.node_count,
38
+ "edge_count": self.edge_count,
39
+ }
40
+
41
+
42
+ def _preflight_artifacts(paths: list[Path]) -> None:
43
+ conflicts = [path for path in paths if path.exists() and path.stat().st_size > 0]
44
+ if conflicts:
45
+ rendered = ", ".join(str(path) for path in conflicts)
46
+ raise FileExistsError(f"ExecWeave record artifacts already exist: {rendered}")
47
+
48
+
49
+ def record_to_viewer(
50
+ command: list[str],
51
+ *,
52
+ watch_root: str | Path,
53
+ output_dir: str | Path | None = None,
54
+ backend: BackendName = "auto",
55
+ poll_interval: float = 0.10,
56
+ collect_filesystem: bool = True,
57
+ collect_network: bool = True,
58
+ keep_raw_trace: bool = False,
59
+ open_browser: bool = False,
60
+ ) -> RecordResult:
61
+ """Record one command and materialize a local graph/viewer after it exits."""
62
+ if not command:
63
+ raise ValueError("command must not be empty")
64
+
65
+ session_id = uuid4().hex
66
+ root = Path(watch_root).expanduser().resolve()
67
+ run_dir = (
68
+ Path(output_dir).expanduser().resolve()
69
+ if output_dir is not None
70
+ else root / ".execweave" / "runs" / session_id
71
+ )
72
+ run_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ event_path = run_dir / "events.jsonl"
75
+ graph_path = run_dir / "graph.json"
76
+ viewer_path = run_dir / "viewer.html"
77
+ _preflight_artifacts([event_path, graph_path, viewer_path])
78
+
79
+ sink = JsonlSink(event_path)
80
+ resolved = resolve_backend(backend)
81
+ collector = create_collector(
82
+ backend=backend,
83
+ session_id=session_id,
84
+ sink=sink,
85
+ watch_root=root,
86
+ poll_interval=poll_interval,
87
+ collect_filesystem=collect_filesystem,
88
+ collect_network=collect_network,
89
+ keep_raw_trace=keep_raw_trace,
90
+ )
91
+
92
+ return_code = collector.run(command)
93
+
94
+ validation = validate_event_stream(event_path)
95
+ if not validation.valid:
96
+ details = "; ".join(validation.errors)
97
+ raise RuntimeError(f"recorded event stream failed validation: {details}")
98
+
99
+ execution_graph = build_execution_graph(event_path)
100
+ write_execution_graph(execution_graph, graph_path)
101
+ write_graph_html(execution_graph.to_dict(), viewer_path, open_browser=open_browser)
102
+
103
+ return RecordResult(
104
+ session_id=session_id,
105
+ backend=resolved,
106
+ return_code=return_code,
107
+ output_dir=run_dir,
108
+ event_stream=event_path,
109
+ graph=graph_path,
110
+ viewer=viewer_path,
111
+ event_count=execution_graph.event_count,
112
+ node_count=len(execution_graph.nodes),
113
+ edge_count=len(execution_graph.edges),
114
+ )