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.
- execweave/__init__.py +3 -0
- execweave/__main__.py +5 -0
- execweave/analysis.py +406 -0
- execweave/backends.py +63 -0
- execweave/benchmark.py +80 -0
- execweave/claude_adapter.py +448 -0
- execweave/claude_hook_cli.py +101 -0
- execweave/claude_record.py +106 -0
- execweave/cli.py +588 -0
- execweave/codex_adapter.py +314 -0
- execweave/codex_hook_cli.py +98 -0
- execweave/codex_record.py +111 -0
- execweave/collector.py +301 -0
- execweave/correlation.py +604 -0
- execweave/cursor_adapter.py +347 -0
- execweave/cursor_hook_cli.py +82 -0
- execweave/cursor_record.py +96 -0
- execweave/filesystem.py +103 -0
- execweave/focus.py +118 -0
- execweave/gemini_adapter.py +265 -0
- execweave/gemini_hook_cli.py +77 -0
- execweave/gemini_record.py +94 -0
- execweave/graph.py +300 -0
- execweave/graph_ops.py +446 -0
- execweave/inference_gateway.py +422 -0
- execweave/inference_gateway_cli.py +106 -0
- execweave/inference_identity.py +76 -0
- execweave/inference_identity_cli.py +60 -0
- execweave/live.py +275 -0
- execweave/model_runtime.py +535 -0
- execweave/model_runtime_cli.py +154 -0
- execweave/opencode_adapter.py +316 -0
- execweave/opencode_hook_cli.py +57 -0
- execweave/opencode_plugin_cli.py +110 -0
- execweave/opencode_record.py +96 -0
- execweave/overhead_benchmark.py +440 -0
- execweave/provider_record.py +215 -0
- execweave/schema.py +62 -0
- execweave/semantic.py +346 -0
- execweave/sink.py +33 -0
- execweave/strace_backend.py +682 -0
- execweave/validate.py +193 -0
- execweave/viewer.py +283 -0
- execweave/workflow.py +114 -0
- execweave-0.6.0.dist-info/METADATA +356 -0
- execweave-0.6.0.dist-info/RECORD +49 -0
- execweave-0.6.0.dist-info/WHEEL +4 -0
- execweave-0.6.0.dist-info/entry_points.txt +17 -0
- execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import html
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import statistics
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import psutil
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
|
|
23
|
+
_SAMPLE_INTERVAL_SECONDS = 0.005
|
|
24
|
+
_WORKLOAD_ID = "agent_like_files_and_subprocesses_v1"
|
|
25
|
+
_WORKLOAD_CODE = """
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
import hashlib
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
root = Path("payload")
|
|
32
|
+
root.mkdir(exist_ok=True)
|
|
33
|
+
payload = (b"execweave-reference-workload-" * 2048)[:32768]
|
|
34
|
+
for index in range(256):
|
|
35
|
+
path = root / f"file-{index % 16}.bin"
|
|
36
|
+
path.write_bytes(payload)
|
|
37
|
+
hashlib.sha256(path.read_bytes()).digest()
|
|
38
|
+
for path in root.iterdir():
|
|
39
|
+
path.unlink()
|
|
40
|
+
root.rmdir()
|
|
41
|
+
for _ in range(6):
|
|
42
|
+
subprocess.run(
|
|
43
|
+
[sys.executable, "-c", "sum(i*i for i in range(20000))"],
|
|
44
|
+
check=True,
|
|
45
|
+
stdout=subprocess.DEVNULL,
|
|
46
|
+
stderr=subprocess.DEVNULL,
|
|
47
|
+
)
|
|
48
|
+
""".strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _workload_command() -> list[str]:
|
|
52
|
+
return [sys.executable, "-c", _WORKLOAD_CODE]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _tree_rss_bytes(pid: int) -> int:
|
|
56
|
+
try:
|
|
57
|
+
root = psutil.Process(pid)
|
|
58
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
59
|
+
return 0
|
|
60
|
+
processes = [root]
|
|
61
|
+
try:
|
|
62
|
+
processes.extend(root.children(recursive=True))
|
|
63
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
64
|
+
pass
|
|
65
|
+
rss = 0
|
|
66
|
+
for process in processes:
|
|
67
|
+
try:
|
|
68
|
+
rss += process.memory_info().rss
|
|
69
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
70
|
+
continue
|
|
71
|
+
return rss
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _directory_size_bytes(path: Path) -> int:
|
|
75
|
+
total = 0
|
|
76
|
+
if not path.exists():
|
|
77
|
+
return 0
|
|
78
|
+
for candidate in path.rglob("*"):
|
|
79
|
+
try:
|
|
80
|
+
if candidate.is_file():
|
|
81
|
+
total += candidate.stat().st_size
|
|
82
|
+
except OSError:
|
|
83
|
+
continue
|
|
84
|
+
return total
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _measure_command(command: list[str], *, cwd: Path) -> dict[str, float | int]:
|
|
88
|
+
start = time.perf_counter()
|
|
89
|
+
process = subprocess.Popen(
|
|
90
|
+
command,
|
|
91
|
+
cwd=cwd,
|
|
92
|
+
stdout=subprocess.DEVNULL,
|
|
93
|
+
stderr=subprocess.DEVNULL,
|
|
94
|
+
)
|
|
95
|
+
peak_rss = 0
|
|
96
|
+
while True:
|
|
97
|
+
peak_rss = max(peak_rss, _tree_rss_bytes(process.pid))
|
|
98
|
+
return_code = process.poll()
|
|
99
|
+
if return_code is not None:
|
|
100
|
+
break
|
|
101
|
+
time.sleep(_SAMPLE_INTERVAL_SECONDS)
|
|
102
|
+
elapsed = time.perf_counter() - start
|
|
103
|
+
if return_code != 0:
|
|
104
|
+
raise RuntimeError(f"benchmark command failed with exit code {return_code}")
|
|
105
|
+
return {
|
|
106
|
+
"wall_seconds": elapsed,
|
|
107
|
+
"peak_tree_rss_bytes": peak_rss,
|
|
108
|
+
"artifact_bytes": _directory_size_bytes(cwd),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _instrumented_command(backend: str, *, cwd: Path, output: Path) -> list[str]:
|
|
113
|
+
return [
|
|
114
|
+
sys.executable,
|
|
115
|
+
"-m",
|
|
116
|
+
"execweave",
|
|
117
|
+
"run",
|
|
118
|
+
"--backend",
|
|
119
|
+
backend,
|
|
120
|
+
"--watch-root",
|
|
121
|
+
str(cwd),
|
|
122
|
+
"--interval",
|
|
123
|
+
"0.05",
|
|
124
|
+
"--no-network",
|
|
125
|
+
"--output",
|
|
126
|
+
str(output),
|
|
127
|
+
"--",
|
|
128
|
+
*_workload_command(),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run_one(profile: str, *, root: Path, index: str) -> dict[str, float | int]:
|
|
133
|
+
run_dir = root / f"{profile}-{index}"
|
|
134
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
if profile == "off":
|
|
136
|
+
measurement = _measure_command(_workload_command(), cwd=run_dir)
|
|
137
|
+
else:
|
|
138
|
+
output = run_dir / "events.jsonl"
|
|
139
|
+
measurement = _measure_command(
|
|
140
|
+
_instrumented_command(profile, cwd=run_dir, output=output),
|
|
141
|
+
cwd=run_dir,
|
|
142
|
+
)
|
|
143
|
+
measurement["artifact_bytes"] = _directory_size_bytes(run_dir)
|
|
144
|
+
return measurement
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _median(values: list[float]) -> float:
|
|
148
|
+
return float(statistics.median(values))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _percentile(values: list[float], fraction: float) -> float:
|
|
152
|
+
if not values:
|
|
153
|
+
return 0.0
|
|
154
|
+
ordered = sorted(values)
|
|
155
|
+
index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1))
|
|
156
|
+
return float(ordered[index])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _summarize(
|
|
160
|
+
name: str,
|
|
161
|
+
backend: str,
|
|
162
|
+
measurements: list[dict[str, float | int]],
|
|
163
|
+
*,
|
|
164
|
+
baseline: dict[str, Any] | None,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
walls = [float(item["wall_seconds"]) for item in measurements]
|
|
167
|
+
rss = [float(item["peak_tree_rss_bytes"]) for item in measurements]
|
|
168
|
+
artifacts = [float(item["artifact_bytes"]) for item in measurements]
|
|
169
|
+
wall_median_ms = _median(walls) * 1000.0
|
|
170
|
+
rss_median_mb = _median(rss) / (1024.0 * 1024.0)
|
|
171
|
+
artifact_median_kb = _median(artifacts) / 1024.0
|
|
172
|
+
if baseline is None:
|
|
173
|
+
overhead_percent = 0.0
|
|
174
|
+
rss_delta_mb = 0.0
|
|
175
|
+
else:
|
|
176
|
+
baseline_wall = float(baseline["wall_median_ms"])
|
|
177
|
+
baseline_rss = float(baseline["peak_tree_rss_median_mb"])
|
|
178
|
+
overhead_percent = (
|
|
179
|
+
((wall_median_ms / baseline_wall) - 1.0) * 100.0 if baseline_wall else 0.0
|
|
180
|
+
)
|
|
181
|
+
rss_delta_mb = rss_median_mb - baseline_rss
|
|
182
|
+
return {
|
|
183
|
+
"name": name,
|
|
184
|
+
"backend": backend,
|
|
185
|
+
"samples": len(measurements),
|
|
186
|
+
"wall_median_ms": round(wall_median_ms, 3),
|
|
187
|
+
"wall_p95_ms": round(_percentile(walls, 0.95) * 1000.0, 3),
|
|
188
|
+
"runtime_overhead_percent": round(overhead_percent, 3),
|
|
189
|
+
"peak_tree_rss_median_mb": round(rss_median_mb, 3),
|
|
190
|
+
"peak_tree_rss_delta_mb": round(rss_delta_mb, 3),
|
|
191
|
+
"additional_peak_rss_mb": round(max(0.0, rss_delta_mb), 3),
|
|
192
|
+
"artifact_median_kb": round(artifact_median_kb, 3),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _cpu_model() -> str:
|
|
197
|
+
cpuinfo = Path("/proc/cpuinfo")
|
|
198
|
+
if cpuinfo.exists():
|
|
199
|
+
try:
|
|
200
|
+
for line in cpuinfo.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
201
|
+
if line.lower().startswith("model name") and ":" in line:
|
|
202
|
+
return line.split(":", 1)[1].strip()
|
|
203
|
+
except OSError:
|
|
204
|
+
pass
|
|
205
|
+
return platform.processor() or platform.machine() or "unknown"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _distribution_size_bytes() -> int | None:
|
|
209
|
+
try:
|
|
210
|
+
distribution = importlib.metadata.distribution("execweave")
|
|
211
|
+
except importlib.metadata.PackageNotFoundError:
|
|
212
|
+
return None
|
|
213
|
+
total = 0
|
|
214
|
+
seen = False
|
|
215
|
+
for relative in distribution.files or []:
|
|
216
|
+
try:
|
|
217
|
+
candidate = Path(distribution.locate_file(relative))
|
|
218
|
+
if candidate.is_file():
|
|
219
|
+
total += candidate.stat().st_size
|
|
220
|
+
seen = True
|
|
221
|
+
except OSError:
|
|
222
|
+
continue
|
|
223
|
+
return total if seen else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_reference_benchmark(*, iterations: int = 7, strace: str = "auto") -> dict[str, Any]:
|
|
227
|
+
if iterations < 1:
|
|
228
|
+
raise ValueError("iterations must be >= 1")
|
|
229
|
+
if strace not in {"auto", "on", "off"}:
|
|
230
|
+
raise ValueError("strace must be auto, on, or off")
|
|
231
|
+
strace_available = platform.system() == "Linux" and shutil.which("strace") is not None
|
|
232
|
+
if strace == "on" and not strace_available:
|
|
233
|
+
raise RuntimeError("strace was requested but is unavailable")
|
|
234
|
+
backends = ["portable"]
|
|
235
|
+
if strace_available and strace != "off":
|
|
236
|
+
backends.append("strace")
|
|
237
|
+
|
|
238
|
+
with tempfile.TemporaryDirectory(prefix="execweave-reference-benchmark-") as temp:
|
|
239
|
+
root = Path(temp)
|
|
240
|
+
_run_one("off", root=root, index="warmup")
|
|
241
|
+
for backend in backends:
|
|
242
|
+
_run_one(backend, root=root, index="warmup")
|
|
243
|
+
|
|
244
|
+
baseline_measurements = [
|
|
245
|
+
_run_one("off", root=root, index=str(index)) for index in range(iterations)
|
|
246
|
+
]
|
|
247
|
+
baseline = _summarize(
|
|
248
|
+
"ExecWeave OFF",
|
|
249
|
+
"off",
|
|
250
|
+
baseline_measurements,
|
|
251
|
+
baseline=None,
|
|
252
|
+
)
|
|
253
|
+
profiles = [baseline]
|
|
254
|
+
for backend in backends:
|
|
255
|
+
measurements = [
|
|
256
|
+
_run_one(backend, root=root, index=str(index)) for index in range(iterations)
|
|
257
|
+
]
|
|
258
|
+
profiles.append(
|
|
259
|
+
_summarize(
|
|
260
|
+
f"{backend.capitalize()} ON",
|
|
261
|
+
backend,
|
|
262
|
+
measurements,
|
|
263
|
+
baseline=baseline,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
distribution_bytes = _distribution_size_bytes()
|
|
268
|
+
return {
|
|
269
|
+
"schema_version": "0.1",
|
|
270
|
+
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
271
|
+
"execweave_version": __version__,
|
|
272
|
+
"workload": {
|
|
273
|
+
"id": _WORKLOAD_ID,
|
|
274
|
+
"description": "fixed file read/write/hash operations plus short-lived Python subprocesses",
|
|
275
|
+
},
|
|
276
|
+
"iterations": iterations,
|
|
277
|
+
"environment": {
|
|
278
|
+
"os": platform.platform(),
|
|
279
|
+
"python": platform.python_version(),
|
|
280
|
+
"machine": platform.machine(),
|
|
281
|
+
"cpu_model": _cpu_model(),
|
|
282
|
+
"logical_cpus": psutil.cpu_count(logical=True),
|
|
283
|
+
"memory_total_mb": round(psutil.virtual_memory().total / (1024.0 * 1024.0), 1),
|
|
284
|
+
},
|
|
285
|
+
"package": {
|
|
286
|
+
"distribution_kb": (
|
|
287
|
+
round(distribution_bytes / 1024.0, 3) if distribution_bytes is not None else None
|
|
288
|
+
),
|
|
289
|
+
"scope": "ExecWeave distribution files only; Python and dependency footprints excluded",
|
|
290
|
+
},
|
|
291
|
+
"profiles": profiles,
|
|
292
|
+
"interpretation": {
|
|
293
|
+
"x": "additional_peak_rss_mb",
|
|
294
|
+
"y": "runtime_overhead_percent",
|
|
295
|
+
"bubble": "artifact_median_kb",
|
|
296
|
+
"preferred_region": "lower-left",
|
|
297
|
+
"warning": "Reference microbenchmark only; rerun on the target host before making capacity claims.",
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _nice_ceiling(value: float, minimum: float) -> float:
|
|
303
|
+
value = max(value, minimum)
|
|
304
|
+
magnitude = 10 ** math.floor(math.log10(value)) if value > 0 else 1
|
|
305
|
+
scaled = value / magnitude
|
|
306
|
+
if scaled <= 1:
|
|
307
|
+
step = 1
|
|
308
|
+
elif scaled <= 2:
|
|
309
|
+
step = 2
|
|
310
|
+
elif scaled <= 5:
|
|
311
|
+
step = 5
|
|
312
|
+
else:
|
|
313
|
+
step = 10
|
|
314
|
+
return step * magnitude
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def render_tradeoff_svg(report: dict[str, Any]) -> str:
|
|
318
|
+
profiles = [profile for profile in report.get("profiles", []) if isinstance(profile, dict)]
|
|
319
|
+
x_values = [max(0.0, float(profile.get("additional_peak_rss_mb", 0.0))) for profile in profiles]
|
|
320
|
+
y_values = [float(profile.get("runtime_overhead_percent", 0.0)) for profile in profiles]
|
|
321
|
+
x_max = _nice_ceiling(max(x_values, default=0.0) * 1.25, 8.0)
|
|
322
|
+
y_min = min(0.0, min(y_values, default=0.0) * 1.15)
|
|
323
|
+
y_max = _nice_ceiling(max(y_values, default=0.0) * 1.25, 10.0)
|
|
324
|
+
if y_max <= y_min:
|
|
325
|
+
y_max = y_min + 10.0
|
|
326
|
+
|
|
327
|
+
width, height = 980, 640
|
|
328
|
+
left, right, top, bottom = 110, 70, 125, 110
|
|
329
|
+
plot_w = width - left - right
|
|
330
|
+
plot_h = height - top - bottom
|
|
331
|
+
|
|
332
|
+
def sx(value: float) -> float:
|
|
333
|
+
return left + (max(0.0, value) / x_max) * plot_w
|
|
334
|
+
|
|
335
|
+
def sy(value: float) -> float:
|
|
336
|
+
return top + ((y_max - value) / (y_max - y_min)) * plot_h
|
|
337
|
+
|
|
338
|
+
colors = {"off": "#64748b", "portable": "#2563eb", "strace": "#7c3aed"}
|
|
339
|
+
env = report.get("environment") or {}
|
|
340
|
+
subtitle = f"{env.get('os', 'unknown OS')} · Python {env.get('python', '?')} · n={report.get('iterations', '?')}"
|
|
341
|
+
lines = [
|
|
342
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
|
|
343
|
+
'<title id="title">ExecWeave runtime overhead versus memory footprint</title>',
|
|
344
|
+
'<desc id="desc">Lower-left is better. Bubble area indicates median artifact size per reference run.</desc>',
|
|
345
|
+
'<rect width="100%" height="100%" rx="24" fill="#ffffff"/>',
|
|
346
|
+
'<text x="70" y="54" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#0f172a">ExecWeave overhead trade-off</text>',
|
|
347
|
+
f'<text x="70" y="83" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#64748b">{html.escape(subtitle)}</text>',
|
|
348
|
+
'<text x="910" y="54" text-anchor="end" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" font-weight="600" fill="#16a34a">LOWER-LEFT IS BETTER ↙</text>',
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
for index in range(6):
|
|
352
|
+
fraction = index / 5
|
|
353
|
+
x = left + fraction * plot_w
|
|
354
|
+
value = fraction * x_max
|
|
355
|
+
lines.append(f'<line x1="{x:.1f}" y1="{top}" x2="{x:.1f}" y2="{top + plot_h}" stroke="#e2e8f0" stroke-width="1"/>')
|
|
356
|
+
lines.append(f'<text x="{x:.1f}" y="{top + plot_h + 28}" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#64748b">{value:.0f}</text>')
|
|
357
|
+
for index in range(6):
|
|
358
|
+
fraction = index / 5
|
|
359
|
+
y = top + fraction * plot_h
|
|
360
|
+
value = y_max - fraction * (y_max - y_min)
|
|
361
|
+
lines.append(f'<line x1="{left}" y1="{y:.1f}" x2="{left + plot_w}" y2="{y:.1f}" stroke="#e2e8f0" stroke-width="1"/>')
|
|
362
|
+
lines.append(f'<text x="{left - 18}" y="{y + 4:.1f}" text-anchor="end" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#64748b">{value:.0f}</text>')
|
|
363
|
+
|
|
364
|
+
lines.extend(
|
|
365
|
+
[
|
|
366
|
+
f'<line x1="{left}" y1="{top + plot_h}" x2="{left + plot_w}" y2="{top + plot_h}" stroke="#334155" stroke-width="1.5"/>',
|
|
367
|
+
f'<line x1="{left}" y1="{top}" x2="{left}" y2="{top + plot_h}" stroke="#334155" stroke-width="1.5"/>',
|
|
368
|
+
f'<text x="{left + plot_w / 2:.1f}" y="{height - 45}" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" font-weight="600" fill="#334155">Additional peak process-tree RSS (MB) → higher</text>',
|
|
369
|
+
f'<text x="32" y="{top + plot_h / 2:.1f}" transform="rotate(-90 32 {top + plot_h / 2:.1f})" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" font-weight="600" fill="#334155">Runtime overhead (%) → higher</text>',
|
|
370
|
+
]
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
for profile in profiles:
|
|
374
|
+
backend = str(profile.get("backend", "unknown"))
|
|
375
|
+
x_value = max(0.0, float(profile.get("additional_peak_rss_mb", 0.0)))
|
|
376
|
+
y_value = float(profile.get("runtime_overhead_percent", 0.0))
|
|
377
|
+
artifact = max(0.0, float(profile.get("artifact_median_kb", 0.0)))
|
|
378
|
+
radius = 8.0 if backend == "off" else min(28.0, 10.0 + math.sqrt(artifact) * 0.9)
|
|
379
|
+
x, y = sx(x_value), sy(y_value)
|
|
380
|
+
color = colors.get(backend, "#0f766e")
|
|
381
|
+
name = html.escape(str(profile.get("name", backend)))
|
|
382
|
+
detail = html.escape(f"+{x_value:.1f} MB RSS · {y_value:+.1f}% time · {artifact:.1f} KB/run")
|
|
383
|
+
label_x = x + 16
|
|
384
|
+
label_y = y - radius - 8
|
|
385
|
+
if backend == "off":
|
|
386
|
+
label_y = y - 18
|
|
387
|
+
lines.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{radius:.1f}" fill="{color}" fill-opacity="0.88" stroke="#ffffff" stroke-width="3"/>')
|
|
388
|
+
lines.append(f'<text x="{label_x:.1f}" y="{label_y:.1f}" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" font-weight="700" fill="#0f172a">{name}</text>')
|
|
389
|
+
lines.append(f'<text x="{label_x:.1f}" y="{label_y + 19:.1f}" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#64748b">{detail}</text>')
|
|
390
|
+
|
|
391
|
+
package = report.get("package") or {}
|
|
392
|
+
package_kb = package.get("distribution_kb")
|
|
393
|
+
package_text = f"Package footprint: {package_kb:.1f} KB (ExecWeave files only)" if isinstance(package_kb, (int, float)) else "Package footprint unavailable"
|
|
394
|
+
lines.extend(
|
|
395
|
+
[
|
|
396
|
+
f'<text x="{left}" y="{height - 18}" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Bubble area ≈ median residual artifact size per run · {html.escape(package_text)}</text>',
|
|
397
|
+
'</svg>',
|
|
398
|
+
]
|
|
399
|
+
)
|
|
400
|
+
return "\n".join(lines) + "\n"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _write_text(path: Path, content: str) -> Path:
|
|
404
|
+
destination = path.expanduser().resolve()
|
|
405
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
destination.write_text(content, encoding="utf-8")
|
|
407
|
+
return destination
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
411
|
+
parser = argparse.ArgumentParser(
|
|
412
|
+
prog="execweave-overhead",
|
|
413
|
+
description="Measure ExecWeave runtime overhead and render a reproducible trade-off chart.",
|
|
414
|
+
)
|
|
415
|
+
parser.add_argument("--iterations", type=int, default=7)
|
|
416
|
+
parser.add_argument("--strace", choices=["auto", "on", "off"], default="auto")
|
|
417
|
+
parser.add_argument("--output-json", type=Path, default=None)
|
|
418
|
+
parser.add_argument("--output-svg", type=Path, default=None)
|
|
419
|
+
return parser
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def main(argv: list[str] | None = None) -> int:
|
|
423
|
+
parser = build_parser()
|
|
424
|
+
args = parser.parse_args(argv)
|
|
425
|
+
try:
|
|
426
|
+
report = run_reference_benchmark(iterations=args.iterations, strace=args.strace)
|
|
427
|
+
except (RuntimeError, ValueError) as exc:
|
|
428
|
+
parser.error(str(exc))
|
|
429
|
+
rendered = json.dumps(report, indent=2, sort_keys=True)
|
|
430
|
+
print(rendered)
|
|
431
|
+
print("EXECWEAVE_BENCHMARK_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))
|
|
432
|
+
if args.output_json is not None:
|
|
433
|
+
_write_text(args.output_json, rendered + "\n")
|
|
434
|
+
if args.output_svg is not None:
|
|
435
|
+
_write_text(args.output_svg, render_tradeoff_svg(report))
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
if __name__ == "__main__":
|
|
440
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import webbrowser
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from .backends import BackendName
|
|
10
|
+
from .correlation import CorrelationResult, correlate_tool_process
|
|
11
|
+
from .graph import build_execution_graph, write_execution_graph
|
|
12
|
+
from .semantic import SemanticMergeResult, merge_semantic_sidecar
|
|
13
|
+
from .viewer import write_graph_html
|
|
14
|
+
from .workflow import RecordResult, record_to_viewer
|
|
15
|
+
|
|
16
|
+
_SEMANTIC_ENV = "EXECWEAVE_SEMANTIC_SIDECAR"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ProviderRecordResult:
|
|
21
|
+
runtime: RecordResult
|
|
22
|
+
semantic_status: str
|
|
23
|
+
semantic_sidecar: Path
|
|
24
|
+
merged_event_stream: Path | None
|
|
25
|
+
semantic_graph: Path | None
|
|
26
|
+
semantic_viewer: Path | None
|
|
27
|
+
semantic_merge: SemanticMergeResult | None
|
|
28
|
+
correlation_status: str
|
|
29
|
+
correlated_event_stream: Path | None
|
|
30
|
+
correlated_graph: Path | None
|
|
31
|
+
correlated_viewer: Path | None
|
|
32
|
+
correlation: CorrelationResult | None
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, object]:
|
|
35
|
+
return {
|
|
36
|
+
"runtime": self.runtime.to_dict(),
|
|
37
|
+
"semantic_status": self.semantic_status,
|
|
38
|
+
"semantic_sidecar": str(self.semantic_sidecar),
|
|
39
|
+
"merged_event_stream": (
|
|
40
|
+
str(self.merged_event_stream) if self.merged_event_stream is not None else None
|
|
41
|
+
),
|
|
42
|
+
"semantic_graph": str(self.semantic_graph) if self.semantic_graph is not None else None,
|
|
43
|
+
"semantic_viewer": str(self.semantic_viewer) if self.semantic_viewer is not None else None,
|
|
44
|
+
"semantic_merge": (
|
|
45
|
+
self.semantic_merge.to_dict() if self.semantic_merge is not None else None
|
|
46
|
+
),
|
|
47
|
+
"correlation_status": self.correlation_status,
|
|
48
|
+
"correlated_event_stream": (
|
|
49
|
+
str(self.correlated_event_stream)
|
|
50
|
+
if self.correlated_event_stream is not None
|
|
51
|
+
else None
|
|
52
|
+
),
|
|
53
|
+
"correlated_graph": (
|
|
54
|
+
str(self.correlated_graph) if self.correlated_graph is not None else None
|
|
55
|
+
),
|
|
56
|
+
"correlated_viewer": (
|
|
57
|
+
str(self.correlated_viewer) if self.correlated_viewer is not None else None
|
|
58
|
+
),
|
|
59
|
+
"correlation": self.correlation.to_dict() if self.correlation is not None else None,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _artifact_paths(run_dir: Path) -> dict[str, Path]:
|
|
64
|
+
return {
|
|
65
|
+
"semantic_sidecar": run_dir / "semantic.jsonl",
|
|
66
|
+
"merged_event_stream": run_dir / "events.semantic.jsonl",
|
|
67
|
+
"semantic_graph": run_dir / "graph.semantic.json",
|
|
68
|
+
"semantic_viewer": run_dir / "viewer.semantic.html",
|
|
69
|
+
"correlated_event_stream": run_dir / "events.correlated.jsonl",
|
|
70
|
+
"correlated_graph": run_dir / "graph.correlated.json",
|
|
71
|
+
"correlated_viewer": run_dir / "viewer.correlated.html",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _preflight(paths: list[Path], *, provider_name: str) -> None:
|
|
76
|
+
conflicts = [path for path in paths if path.exists() and path.stat().st_size > 0]
|
|
77
|
+
if conflicts:
|
|
78
|
+
rendered = ", ".join(str(path) for path in conflicts)
|
|
79
|
+
raise FileExistsError(
|
|
80
|
+
f"ExecWeave {provider_name} semantic artifacts already exist: {rendered}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def record_provider_to_viewer(
|
|
85
|
+
command: list[str],
|
|
86
|
+
*,
|
|
87
|
+
provider_name: str,
|
|
88
|
+
watch_root: str | Path,
|
|
89
|
+
output_dir: str | Path | None = None,
|
|
90
|
+
backend: BackendName = "auto",
|
|
91
|
+
poll_interval: float = 0.10,
|
|
92
|
+
collect_filesystem: bool = True,
|
|
93
|
+
collect_network: bool = True,
|
|
94
|
+
keep_raw_trace: bool = False,
|
|
95
|
+
correlation_window_ms: int = 3000,
|
|
96
|
+
open_browser: bool = False,
|
|
97
|
+
) -> ProviderRecordResult:
|
|
98
|
+
"""Record runtime + provider semantic evidence into layered local artifacts.
|
|
99
|
+
|
|
100
|
+
Provider-specific hooks must already be configured to write to the inherited
|
|
101
|
+
``EXECWEAVE_SEMANTIC_SIDECAR`` path. This core never edits provider settings.
|
|
102
|
+
Raw runtime and semantic evidence remain separate; correlation always derives a
|
|
103
|
+
new stream and stays explicitly inferred/non-causal.
|
|
104
|
+
"""
|
|
105
|
+
if not command:
|
|
106
|
+
raise ValueError("command must not be empty")
|
|
107
|
+
if not provider_name.strip():
|
|
108
|
+
raise ValueError("provider_name must not be empty")
|
|
109
|
+
if correlation_window_ms <= 0:
|
|
110
|
+
raise ValueError("correlation_window_ms must be greater than zero")
|
|
111
|
+
|
|
112
|
+
root = Path(watch_root).expanduser().resolve()
|
|
113
|
+
run_dir = (
|
|
114
|
+
Path(output_dir).expanduser().resolve()
|
|
115
|
+
if output_dir is not None
|
|
116
|
+
else root / ".execweave" / "runs" / uuid4().hex
|
|
117
|
+
)
|
|
118
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
paths = _artifact_paths(run_dir)
|
|
120
|
+
_preflight(list(paths.values()), provider_name=provider_name)
|
|
121
|
+
|
|
122
|
+
semantic_sidecar = paths["semantic_sidecar"]
|
|
123
|
+
previous = os.environ.get(_SEMANTIC_ENV)
|
|
124
|
+
os.environ[_SEMANTIC_ENV] = str(semantic_sidecar)
|
|
125
|
+
try:
|
|
126
|
+
runtime = record_to_viewer(
|
|
127
|
+
command,
|
|
128
|
+
watch_root=root,
|
|
129
|
+
output_dir=run_dir,
|
|
130
|
+
backend=backend,
|
|
131
|
+
poll_interval=poll_interval,
|
|
132
|
+
collect_filesystem=collect_filesystem,
|
|
133
|
+
collect_network=collect_network,
|
|
134
|
+
keep_raw_trace=keep_raw_trace,
|
|
135
|
+
open_browser=False,
|
|
136
|
+
)
|
|
137
|
+
finally:
|
|
138
|
+
if previous is None:
|
|
139
|
+
os.environ.pop(_SEMANTIC_ENV, None)
|
|
140
|
+
else:
|
|
141
|
+
os.environ[_SEMANTIC_ENV] = previous
|
|
142
|
+
|
|
143
|
+
if not semantic_sidecar.exists() or semantic_sidecar.stat().st_size == 0:
|
|
144
|
+
if open_browser:
|
|
145
|
+
webbrowser.open(runtime.viewer.resolve().as_uri())
|
|
146
|
+
return ProviderRecordResult(
|
|
147
|
+
runtime=runtime,
|
|
148
|
+
semantic_status="no_events",
|
|
149
|
+
semantic_sidecar=semantic_sidecar.resolve(),
|
|
150
|
+
merged_event_stream=None,
|
|
151
|
+
semantic_graph=None,
|
|
152
|
+
semantic_viewer=None,
|
|
153
|
+
semantic_merge=None,
|
|
154
|
+
correlation_status="not_run_no_semantic_events",
|
|
155
|
+
correlated_event_stream=None,
|
|
156
|
+
correlated_graph=None,
|
|
157
|
+
correlated_viewer=None,
|
|
158
|
+
correlation=None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
merged_event_stream = paths["merged_event_stream"]
|
|
162
|
+
semantic_graph = paths["semantic_graph"]
|
|
163
|
+
semantic_viewer = paths["semantic_viewer"]
|
|
164
|
+
correlated_event_stream = paths["correlated_event_stream"]
|
|
165
|
+
correlated_graph = paths["correlated_graph"]
|
|
166
|
+
correlated_viewer = paths["correlated_viewer"]
|
|
167
|
+
|
|
168
|
+
merge_result = merge_semantic_sidecar(
|
|
169
|
+
runtime.event_stream,
|
|
170
|
+
semantic_sidecar,
|
|
171
|
+
merged_event_stream,
|
|
172
|
+
)
|
|
173
|
+
execution_graph = build_execution_graph(merged_event_stream)
|
|
174
|
+
write_execution_graph(execution_graph, semantic_graph)
|
|
175
|
+
write_graph_html(execution_graph.to_dict(), semantic_viewer, open_browser=False)
|
|
176
|
+
|
|
177
|
+
correlation_result = correlate_tool_process(
|
|
178
|
+
merged_event_stream,
|
|
179
|
+
correlated_event_stream,
|
|
180
|
+
max_window_ms=correlation_window_ms,
|
|
181
|
+
)
|
|
182
|
+
correlated_execution_graph = build_execution_graph(correlated_event_stream)
|
|
183
|
+
correlation_metadata = {"correlation": correlation_result.to_dict()}
|
|
184
|
+
write_execution_graph(
|
|
185
|
+
correlated_execution_graph,
|
|
186
|
+
correlated_graph,
|
|
187
|
+
metadata=correlation_metadata,
|
|
188
|
+
)
|
|
189
|
+
correlated_graph_payload = correlated_execution_graph.to_dict()
|
|
190
|
+
correlated_graph_payload["metadata"] = correlation_metadata
|
|
191
|
+
write_graph_html(
|
|
192
|
+
correlated_graph_payload,
|
|
193
|
+
correlated_viewer,
|
|
194
|
+
open_browser=open_browser,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
correlation_status = (
|
|
198
|
+
"correlated"
|
|
199
|
+
if correlation_result.correlated_tool_calls > 0
|
|
200
|
+
else "completed_no_matches"
|
|
201
|
+
)
|
|
202
|
+
return ProviderRecordResult(
|
|
203
|
+
runtime=runtime,
|
|
204
|
+
semantic_status="merged",
|
|
205
|
+
semantic_sidecar=semantic_sidecar.resolve(),
|
|
206
|
+
merged_event_stream=merged_event_stream.resolve(),
|
|
207
|
+
semantic_graph=semantic_graph.resolve(),
|
|
208
|
+
semantic_viewer=semantic_viewer.resolve(),
|
|
209
|
+
semantic_merge=merge_result,
|
|
210
|
+
correlation_status=correlation_status,
|
|
211
|
+
correlated_event_stream=correlated_event_stream.resolve(),
|
|
212
|
+
correlated_graph=correlated_graph.resolve(),
|
|
213
|
+
correlated_viewer=correlated_viewer.resolve(),
|
|
214
|
+
correlation=correlation_result,
|
|
215
|
+
)
|