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/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")
|