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