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
@@ -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
+ )