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/schema.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ SCHEMA_VERSION = "0.2"
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Entity:
13
+ """A graph entity referenced by a runtime event."""
14
+
15
+ type: str
16
+ id: str
17
+ name: str | None = None
18
+ attributes: dict[str, Any] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RuntimeEvent:
23
+ """One graph-ready observation represented as source -> relation -> target."""
24
+
25
+ schema_version: str
26
+ event_id: str
27
+ session_id: str
28
+ timestamp: str
29
+ event_type: str
30
+ relation: str
31
+ source: Entity | None
32
+ target: Entity | None
33
+ sequence: int | None = None
34
+ attributes: dict[str, Any] = field(default_factory=dict)
35
+
36
+ @classmethod
37
+ def create(
38
+ cls,
39
+ *,
40
+ session_id: str,
41
+ event_type: str,
42
+ relation: str,
43
+ source: Entity | None = None,
44
+ target: Entity | None = None,
45
+ attributes: dict[str, Any] | None = None,
46
+ timestamp: str | None = None,
47
+ ) -> "RuntimeEvent":
48
+ return cls(
49
+ schema_version=SCHEMA_VERSION,
50
+ event_id=str(uuid4()),
51
+ session_id=session_id,
52
+ timestamp=timestamp
53
+ or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
54
+ event_type=event_type,
55
+ relation=relation,
56
+ source=source,
57
+ target=target,
58
+ attributes=attributes or {},
59
+ )
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ return asdict(self)
execweave/semantic.py ADDED
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from copy import deepcopy
7
+ from dataclasses import asdict, dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from .schema import SCHEMA_VERSION
14
+ from .validate import validate_event_stream
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SemanticMergeResult:
19
+ runtime_event_count: int
20
+ semantic_event_count: int
21
+ merged_event_count: int
22
+ resolved_process_references: int
23
+ unresolved_process_references: int
24
+ session_id: str
25
+ output: str
26
+
27
+ def to_dict(self) -> dict[str, object]:
28
+ return asdict(self)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class _ProcessCandidate:
33
+ pid: int
34
+ entity: dict[str, Any]
35
+ create_time: float | None
36
+
37
+
38
+ def _parse_timestamp(value: object, *, context: str) -> datetime:
39
+ if not isinstance(value, str) or not value:
40
+ raise ValueError(f"{context}: timestamp must be a non-empty ISO-8601 string")
41
+ try:
42
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
43
+ except ValueError as exc:
44
+ raise ValueError(f"{context}: timestamp must be ISO-8601") from exc
45
+ if parsed.tzinfo is None:
46
+ parsed = parsed.replace(tzinfo=timezone.utc)
47
+ return parsed.astimezone(timezone.utc)
48
+
49
+
50
+ def _load_jsonl(path: Path, *, label: str) -> list[dict[str, Any]]:
51
+ if not path.exists():
52
+ raise ValueError(f"{label} does not exist: {path}")
53
+ records: list[dict[str, Any]] = []
54
+ for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
55
+ if not raw.strip():
56
+ continue
57
+ try:
58
+ payload = json.loads(raw)
59
+ except json.JSONDecodeError as exc:
60
+ raise ValueError(f"{label} line {line_number}: invalid JSON: {exc.msg}") from exc
61
+ if not isinstance(payload, dict):
62
+ raise ValueError(f"{label} line {line_number}: record must be a JSON object")
63
+ records.append(payload)
64
+ return records
65
+
66
+
67
+ def _validate_entity(entity: object, *, context: str) -> None:
68
+ if entity is None:
69
+ return
70
+ if not isinstance(entity, dict):
71
+ raise ValueError(f"{context} must be an object or null")
72
+ for key in ("type", "id"):
73
+ value = entity.get(key)
74
+ if not isinstance(value, str) or not value:
75
+ raise ValueError(f"{context}.{key} must be a non-empty string")
76
+ attributes = entity.get("attributes", {})
77
+ if not isinstance(attributes, dict):
78
+ raise ValueError(f"{context}.attributes must be an object")
79
+
80
+
81
+ def _process_candidates(runtime_events: list[dict[str, Any]]) -> dict[int, list[_ProcessCandidate]]:
82
+ by_pid: dict[int, dict[str, _ProcessCandidate]] = {}
83
+ for event in runtime_events:
84
+ for entity in (event.get("source"), event.get("target")):
85
+ if not isinstance(entity, dict) or entity.get("type") != "process":
86
+ continue
87
+ entity_id = entity.get("id")
88
+ attributes = entity.get("attributes") or {}
89
+ if not isinstance(entity_id, str) or not isinstance(attributes, dict):
90
+ continue
91
+ pid = attributes.get("pid")
92
+ if not isinstance(pid, int) or isinstance(pid, bool):
93
+ continue
94
+ create_time_raw = attributes.get("create_time")
95
+ create_time = (
96
+ float(create_time_raw)
97
+ if isinstance(create_time_raw, (int, float)) and not isinstance(create_time_raw, bool)
98
+ else None
99
+ )
100
+ by_pid.setdefault(pid, {})[entity_id] = _ProcessCandidate(
101
+ pid=pid,
102
+ entity=deepcopy(entity),
103
+ create_time=create_time,
104
+ )
105
+ return {pid: list(candidates.values()) for pid, candidates in by_pid.items()}
106
+
107
+
108
+ def _resolve_process_reference(
109
+ entity: dict[str, Any],
110
+ *,
111
+ timestamp: datetime,
112
+ candidates: dict[int, list[_ProcessCandidate]],
113
+ ) -> tuple[dict[str, Any], bool]:
114
+ if entity.get("type") != "process_reference":
115
+ return deepcopy(entity), False
116
+ attributes = entity.get("attributes") or {}
117
+ pid = attributes.get("pid") if isinstance(attributes, dict) else None
118
+ if not isinstance(pid, int) or isinstance(pid, bool):
119
+ unresolved = deepcopy(entity)
120
+ unresolved.setdefault("attributes", {})["unresolved"] = True
121
+ return unresolved, False
122
+
123
+ options = candidates.get(pid, [])
124
+ if not options:
125
+ unresolved = deepcopy(entity)
126
+ unresolved.setdefault("attributes", {})["unresolved"] = True
127
+ return unresolved, False
128
+
129
+ explicit_create_time = attributes.get("create_time") if isinstance(attributes, dict) else None
130
+ if isinstance(explicit_create_time, (int, float)) and not isinstance(explicit_create_time, bool):
131
+ exact = [
132
+ candidate
133
+ for candidate in options
134
+ if candidate.create_time is not None
135
+ and abs(candidate.create_time - float(explicit_create_time)) < 0.001
136
+ ]
137
+ if len(exact) == 1:
138
+ return deepcopy(exact[0].entity), True
139
+
140
+ if len(options) == 1:
141
+ return deepcopy(options[0].entity), True
142
+
143
+ event_epoch = timestamp.timestamp()
144
+ started = [
145
+ candidate
146
+ for candidate in options
147
+ if candidate.create_time is not None and candidate.create_time <= event_epoch
148
+ ]
149
+ if started:
150
+ latest = max(candidate.create_time or 0.0 for candidate in started)
151
+ nearest = [
152
+ candidate
153
+ for candidate in started
154
+ if candidate.create_time is not None and abs(candidate.create_time - latest) < 0.001
155
+ ]
156
+ if len(nearest) == 1:
157
+ return deepcopy(nearest[0].entity), True
158
+
159
+ unresolved = deepcopy(entity)
160
+ unresolved.setdefault("attributes", {})["unresolved"] = True
161
+ unresolved["attributes"]["candidate_process_ids"] = sorted(
162
+ candidate.entity["id"] for candidate in options if isinstance(candidate.entity.get("id"), str)
163
+ )
164
+ return unresolved, False
165
+
166
+
167
+ def _normalize_semantic_record(
168
+ record: dict[str, Any],
169
+ *,
170
+ line_number: int,
171
+ session_id: str,
172
+ started_at: datetime,
173
+ finished_at: datetime,
174
+ candidates: dict[int, list[_ProcessCandidate]],
175
+ ) -> tuple[dict[str, Any], int, int]:
176
+ context = f"semantic sidecar line {line_number}"
177
+ timestamp = _parse_timestamp(record.get("timestamp"), context=context)
178
+ if timestamp < started_at or timestamp > finished_at:
179
+ raise ValueError(f"{context}: timestamp is outside the runtime session interval")
180
+
181
+ event_type = record.get("event_type")
182
+ relation = record.get("relation")
183
+ if not isinstance(event_type, str) or not event_type:
184
+ raise ValueError(f"{context}: event_type must be a non-empty string")
185
+ if not isinstance(relation, str) or not relation:
186
+ raise ValueError(f"{context}: relation must be a non-empty string")
187
+
188
+ source = record.get("source")
189
+ target = record.get("target")
190
+ _validate_entity(source, context=f"{context}.source")
191
+ _validate_entity(target, context=f"{context}.target")
192
+ attributes = record.get("attributes", {})
193
+ if not isinstance(attributes, dict):
194
+ raise ValueError(f"{context}: attributes must be an object")
195
+
196
+ resolved = 0
197
+ unresolved = 0
198
+ resolution_map: dict[str, str] = {}
199
+ normalized_entities: list[dict[str, Any] | None] = []
200
+ for entity in (source, target):
201
+ if entity is None:
202
+ normalized_entities.append(None)
203
+ continue
204
+ original_id = entity.get("id") if isinstance(entity, dict) else None
205
+ normalized, did_resolve = _resolve_process_reference(
206
+ entity,
207
+ timestamp=timestamp,
208
+ candidates=candidates,
209
+ )
210
+ if entity.get("type") == "process_reference":
211
+ if did_resolve:
212
+ resolved += 1
213
+ if isinstance(original_id, str) and isinstance(normalized.get("id"), str):
214
+ resolution_map[original_id] = normalized["id"]
215
+ else:
216
+ unresolved += 1
217
+ normalized_entities.append(normalized)
218
+
219
+ normalized_attributes = deepcopy(attributes)
220
+ normalized_attributes.setdefault("backend", "semantic")
221
+ normalized_attributes.setdefault("attribution", "semantic_sidecar")
222
+ if resolution_map:
223
+ normalized_attributes["resolved_process_references"] = resolution_map
224
+
225
+ event_id = record.get("event_id")
226
+ if not isinstance(event_id, str) or not event_id:
227
+ event_id = f"semantic:{uuid4()}"
228
+
229
+ return (
230
+ {
231
+ "schema_version": SCHEMA_VERSION,
232
+ "event_id": event_id,
233
+ "session_id": session_id,
234
+ "timestamp": record["timestamp"],
235
+ "event_type": event_type,
236
+ "relation": relation,
237
+ "source": normalized_entities[0],
238
+ "target": normalized_entities[1],
239
+ "sequence": None,
240
+ "attributes": normalized_attributes,
241
+ },
242
+ resolved,
243
+ unresolved,
244
+ )
245
+
246
+
247
+ def merge_semantic_sidecar(
248
+ runtime_path: str | Path,
249
+ semantic_path: str | Path,
250
+ output_path: str | Path,
251
+ ) -> SemanticMergeResult:
252
+ runtime = Path(runtime_path).expanduser().resolve()
253
+ semantic = Path(semantic_path).expanduser().resolve()
254
+ output = Path(output_path).expanduser().resolve()
255
+ if output.exists() and output.stat().st_size > 0:
256
+ raise FileExistsError(f"ExecWeave merged event stream already exists: {output}")
257
+
258
+ validation = validate_event_stream(runtime, require_complete_session=True)
259
+ if not validation.valid:
260
+ raise ValueError("invalid runtime event stream: " + "; ".join(validation.errors))
261
+ runtime_events = _load_jsonl(runtime, label="runtime event stream")
262
+ sidecar_records = _load_jsonl(semantic, label="semantic sidecar")
263
+ if not sidecar_records:
264
+ raise ValueError("semantic sidecar contains no events")
265
+
266
+ starts = [event for event in runtime_events if event.get("event_type") == "session.started"]
267
+ finishes = [event for event in runtime_events if event.get("event_type") == "session.finished"]
268
+ if len(starts) != 1 or len(finishes) != 1:
269
+ raise ValueError("runtime event stream must contain exactly one session start and finish")
270
+ session_id = validation.session_ids[0]
271
+ started_at = _parse_timestamp(starts[0].get("timestamp"), context="session.started")
272
+ finished_at = _parse_timestamp(finishes[0].get("timestamp"), context="session.finished")
273
+ candidates = _process_candidates(runtime_events)
274
+
275
+ semantic_events: list[dict[str, Any]] = []
276
+ resolved_total = 0
277
+ unresolved_total = 0
278
+ for line_number, record in enumerate(sidecar_records, start=1):
279
+ normalized, resolved, unresolved = _normalize_semantic_record(
280
+ record,
281
+ line_number=line_number,
282
+ session_id=session_id,
283
+ started_at=started_at,
284
+ finished_at=finished_at,
285
+ candidates=candidates,
286
+ )
287
+ semantic_events.append(normalized)
288
+ resolved_total += resolved
289
+ unresolved_total += unresolved
290
+
291
+ runtime_body = [
292
+ deepcopy(event)
293
+ for event in runtime_events
294
+ if event.get("event_type") not in {"session.started", "session.finished"}
295
+ ]
296
+ decorated: list[tuple[datetime, int, int, dict[str, Any]]] = []
297
+ for index, event in enumerate(runtime_body):
298
+ decorated.append(
299
+ (
300
+ _parse_timestamp(event.get("timestamp"), context=f"runtime event {index + 1}"),
301
+ 0,
302
+ index,
303
+ event,
304
+ )
305
+ )
306
+ for index, event in enumerate(semantic_events):
307
+ decorated.append(
308
+ (
309
+ _parse_timestamp(event.get("timestamp"), context=f"semantic event {index + 1}"),
310
+ 1,
311
+ index,
312
+ event,
313
+ )
314
+ )
315
+ decorated.sort(key=lambda item: (item[0], item[1], item[2]))
316
+
317
+ merged = [deepcopy(starts[0]), *[item[3] for item in decorated], deepcopy(finishes[0])]
318
+ for sequence, event in enumerate(merged, start=1):
319
+ event["sequence"] = sequence
320
+
321
+ output.parent.mkdir(parents=True, exist_ok=True)
322
+ fd, temp_name = tempfile.mkstemp(prefix=".execweave-semantic-", suffix=".jsonl", dir=output.parent)
323
+ os.close(fd)
324
+ temp_path = Path(temp_name)
325
+ try:
326
+ temp_path.write_text(
327
+ "".join(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n" for event in merged),
328
+ encoding="utf-8",
329
+ )
330
+ merged_validation = validate_event_stream(temp_path, require_complete_session=True)
331
+ if not merged_validation.valid:
332
+ raise ValueError("merged semantic event stream is invalid: " + "; ".join(merged_validation.errors))
333
+ temp_path.replace(output)
334
+ finally:
335
+ if temp_path.exists():
336
+ temp_path.unlink()
337
+
338
+ return SemanticMergeResult(
339
+ runtime_event_count=len(runtime_events),
340
+ semantic_event_count=len(semantic_events),
341
+ merged_event_count=len(merged),
342
+ resolved_process_references=resolved_total,
343
+ unresolved_process_references=unresolved_total,
344
+ session_id=session_id,
345
+ output=str(output),
346
+ )
execweave/sink.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from .schema import RuntimeEvent
8
+
9
+
10
+ class JsonlSink:
11
+ """Thread-safe local JSONL sink used by Phase 1 collectors.
12
+
13
+ One event file represents one ExecWeave session. Reusing a non-empty path is
14
+ rejected by default so event sequences and session identities cannot be
15
+ silently mixed by an accidental second run.
16
+ """
17
+
18
+ def __init__(self, path: str | Path) -> None:
19
+ self.path = Path(path).expanduser().resolve()
20
+ self.path.parent.mkdir(parents=True, exist_ok=True)
21
+ if self.path.exists() and self.path.stat().st_size > 0:
22
+ raise FileExistsError(f"ExecWeave event stream already exists: {self.path}")
23
+ self._lock = threading.Lock()
24
+ self._sequence = 0
25
+
26
+ def emit(self, event: RuntimeEvent) -> None:
27
+ payload = event.to_dict()
28
+ with self._lock:
29
+ self._sequence += 1
30
+ payload["sequence"] = self._sequence
31
+ line = json.dumps(payload, ensure_ascii=False, sort_keys=True)
32
+ with self.path.open("a", encoding="utf-8") as handle:
33
+ handle.write(line + "\n")