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/collector.py ADDED
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ import psutil
11
+
12
+ from .filesystem import FileWatcher
13
+ from .schema import Entity, RuntimeEvent
14
+ from .sink import JsonlSink
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProcessSnapshot:
19
+ pid: int
20
+ ppid: int
21
+ name: str
22
+ cmdline: list[str]
23
+ exe: str | None
24
+ create_time: float
25
+
26
+ @property
27
+ def entity(self) -> Entity:
28
+ identity = f"{self.pid}:{int(self.create_time * 1_000_000)}"
29
+ return Entity(
30
+ type="process",
31
+ id=f"process:{identity}",
32
+ name=self.name,
33
+ attributes={
34
+ "pid": self.pid,
35
+ "ppid": self.ppid,
36
+ "cmdline": self.cmdline,
37
+ "exe": self.exe,
38
+ "create_time": self.create_time,
39
+ },
40
+ )
41
+
42
+
43
+ def _safe_process_snapshot(proc: psutil.Process) -> ProcessSnapshot | None:
44
+ try:
45
+ with proc.oneshot():
46
+ try:
47
+ exe = proc.exe()
48
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
49
+ exe = None
50
+ return ProcessSnapshot(
51
+ pid=proc.pid,
52
+ ppid=proc.ppid(),
53
+ name=proc.name(),
54
+ cmdline=proc.cmdline(),
55
+ exe=exe,
56
+ create_time=proc.create_time(),
57
+ )
58
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
59
+ return None
60
+
61
+
62
+ def _format_address(address: object) -> str | None:
63
+ if not address:
64
+ return None
65
+ ip = getattr(address, "ip", None)
66
+ port = getattr(address, "port", None)
67
+ if ip is not None and port is not None:
68
+ return f"{ip}:{port}"
69
+ if isinstance(address, tuple) and len(address) >= 2:
70
+ return f"{address[0]}:{address[1]}"
71
+ return str(address)
72
+
73
+
74
+ def infer_agent_name(command: Iterable[str]) -> str:
75
+ parts = list(command)
76
+ if not parts:
77
+ return "unknown-agent"
78
+ executable = Path(parts[0]).name.lower()
79
+ known = {
80
+ "claude": "Claude Code",
81
+ "claude.exe": "Claude Code",
82
+ "codex": "OpenAI Codex",
83
+ "codex.exe": "OpenAI Codex",
84
+ "gemini": "Gemini CLI",
85
+ "gemini.exe": "Gemini CLI",
86
+ "opencode": "OpenCode",
87
+ "opencode.exe": "OpenCode",
88
+ }
89
+ return known.get(executable, Path(parts[0]).name)
90
+
91
+
92
+ class RuntimeCollector:
93
+ """Portable polling collector used on all platforms and as a fallback backend."""
94
+
95
+ backend_name = "portable"
96
+
97
+ def __init__(
98
+ self,
99
+ *,
100
+ session_id: str,
101
+ sink: JsonlSink,
102
+ watch_root: Path,
103
+ poll_interval: float = 0.10,
104
+ collect_filesystem: bool = True,
105
+ collect_network: bool = True,
106
+ ) -> None:
107
+ self.session_id = session_id
108
+ self.sink = sink
109
+ self.watch_root = watch_root.expanduser().resolve()
110
+ self.poll_interval = max(0.02, poll_interval)
111
+ self.collect_filesystem = collect_filesystem
112
+ self.collect_network = collect_network
113
+ self._seen_processes: dict[int, ProcessSnapshot] = {}
114
+ self._seen_connections: set[tuple[str, str | None, str | None, str]] = set()
115
+
116
+ def run(self, command: list[str]) -> int:
117
+ if not command:
118
+ raise ValueError("command must not be empty")
119
+
120
+ agent_name = infer_agent_name(command)
121
+ agent = Entity(type="agent", id=f"agent:{agent_name}", name=agent_name)
122
+ session = Entity(
123
+ type="session",
124
+ id=f"session:{self.session_id}",
125
+ name=self.session_id,
126
+ attributes={
127
+ "command": command,
128
+ "cwd": str(self.watch_root),
129
+ "backend": self.backend_name,
130
+ },
131
+ )
132
+ self.sink.emit(
133
+ RuntimeEvent.create(
134
+ session_id=self.session_id,
135
+ event_type="session.started",
136
+ relation="STARTED_SESSION",
137
+ source=agent,
138
+ target=session,
139
+ attributes={"collector_pid": os.getpid(), "backend": self.backend_name},
140
+ )
141
+ )
142
+
143
+ watcher: FileWatcher | None = None
144
+ internal_root = self.watch_root / ".execweave"
145
+ if self.collect_filesystem:
146
+ watcher = FileWatcher(
147
+ root=self.watch_root,
148
+ session_id=self.session_id,
149
+ session_entity=session,
150
+ sink=self.sink,
151
+ excluded_roots=[internal_root, self.sink.path],
152
+ )
153
+ watcher.start()
154
+
155
+ process: subprocess.Popen[bytes] | None = None
156
+ return_code = 1
157
+ try:
158
+ process = subprocess.Popen(command, cwd=str(self.watch_root))
159
+ root = psutil.Process(process.pid)
160
+ snapshot = _safe_process_snapshot(root)
161
+ if snapshot is not None:
162
+ self._record_process_start(snapshot, parent=session, relation="LAUNCHED")
163
+
164
+ while process.poll() is None:
165
+ self._sample_process_tree(root)
166
+ time.sleep(self.poll_interval)
167
+
168
+ self._sample_process_tree(root)
169
+ self._mark_disappeared_processes(set())
170
+ return_code = int(process.returncode or 0)
171
+ return return_code
172
+ finally:
173
+ if watcher is not None:
174
+ watcher.stop()
175
+ self.sink.emit(
176
+ RuntimeEvent.create(
177
+ session_id=self.session_id,
178
+ event_type="session.finished",
179
+ relation="FINISHED_SESSION",
180
+ source=session,
181
+ attributes={
182
+ "return_code": return_code,
183
+ "root_pid": process.pid if process is not None else None,
184
+ "backend": self.backend_name,
185
+ },
186
+ )
187
+ )
188
+
189
+ def _sample_process_tree(self, root: psutil.Process) -> None:
190
+ processes: list[psutil.Process] = []
191
+ try:
192
+ processes.append(root)
193
+ processes.extend(root.children(recursive=True))
194
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
195
+ pass
196
+
197
+ current: dict[int, ProcessSnapshot] = {}
198
+ process_objects: dict[int, psutil.Process] = {}
199
+ for proc in processes:
200
+ snapshot = _safe_process_snapshot(proc)
201
+ if snapshot is None:
202
+ continue
203
+ current[snapshot.pid] = snapshot
204
+ process_objects[snapshot.pid] = proc
205
+
206
+ for snapshot in current.values():
207
+ if snapshot.pid not in self._seen_processes:
208
+ parent_snapshot = current.get(snapshot.ppid) or self._seen_processes.get(
209
+ snapshot.ppid
210
+ )
211
+ parent = (
212
+ parent_snapshot.entity
213
+ if parent_snapshot is not None
214
+ else Entity(
215
+ type="process_reference",
216
+ id=f"process-pid:{snapshot.ppid}",
217
+ name=str(snapshot.ppid),
218
+ attributes={"pid": snapshot.ppid, "unresolved": True},
219
+ )
220
+ )
221
+ self._record_process_start(snapshot, parent=parent, relation="SPAWNED")
222
+ if self.collect_network:
223
+ self._sample_network(process_objects[snapshot.pid], snapshot)
224
+
225
+ self._mark_disappeared_processes(set(current))
226
+
227
+ def _record_process_start(
228
+ self,
229
+ snapshot: ProcessSnapshot,
230
+ *,
231
+ parent: Entity,
232
+ relation: str,
233
+ ) -> None:
234
+ if snapshot.pid in self._seen_processes:
235
+ return
236
+ self._seen_processes[snapshot.pid] = snapshot
237
+ self.sink.emit(
238
+ RuntimeEvent.create(
239
+ session_id=self.session_id,
240
+ event_type="process.started",
241
+ relation=relation,
242
+ source=parent,
243
+ target=snapshot.entity,
244
+ attributes={
245
+ "attribution": "polling",
246
+ "causal": relation == "LAUNCHED",
247
+ "backend": self.backend_name,
248
+ },
249
+ )
250
+ )
251
+
252
+ def _mark_disappeared_processes(self, active_pids: set[int]) -> None:
253
+ for pid in list(self._seen_processes):
254
+ if pid in active_pids or psutil.pid_exists(pid):
255
+ continue
256
+ snapshot = self._seen_processes.pop(pid)
257
+ self.sink.emit(
258
+ RuntimeEvent.create(
259
+ session_id=self.session_id,
260
+ event_type="process.exited",
261
+ relation="EXITED",
262
+ source=snapshot.entity,
263
+ attributes={"attribution": "polling", "backend": self.backend_name},
264
+ )
265
+ )
266
+
267
+ def _sample_network(self, proc: psutil.Process, snapshot: ProcessSnapshot) -> None:
268
+ try:
269
+ getter = getattr(proc, "net_connections", None)
270
+ connections = getter(kind="inet") if getter else proc.connections(kind="inet")
271
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError):
272
+ return
273
+
274
+ for connection in connections:
275
+ remote = _format_address(connection.raddr)
276
+ if remote is None:
277
+ continue
278
+ local = _format_address(connection.laddr)
279
+ status = str(getattr(connection, "status", ""))
280
+ key = (snapshot.entity.id, local, remote, status)
281
+ if key in self._seen_connections:
282
+ continue
283
+ self._seen_connections.add(key)
284
+ endpoint = Entity(type="network_endpoint", id=f"endpoint:{remote}", name=remote)
285
+ self.sink.emit(
286
+ RuntimeEvent.create(
287
+ session_id=self.session_id,
288
+ event_type="network.connection",
289
+ relation="CONNECTED_TO",
290
+ source=snapshot.entity,
291
+ target=endpoint,
292
+ attributes={
293
+ "local_address": local,
294
+ "remote_address": remote,
295
+ "status": status,
296
+ "attribution": "process_polling",
297
+ "causal": True,
298
+ "backend": self.backend_name,
299
+ },
300
+ )
301
+ )