codevigil 0.1.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.
codevigil/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """codevigil — local, privacy-preserving observability for Claude Code sessions.
2
+
3
+ Importing the top-level package installs the privacy import hook so that any
4
+ subsequent ``import socket`` / ``import subprocess`` / etc. from inside a
5
+ codevigil module raises ``PrivacyViolationError`` immediately. This must
6
+ happen before any other codevigil submodule loads, so the hook install is
7
+ the first statement after ``__future__`` in this file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from codevigil.privacy import PrivacyViolationError
13
+ from codevigil.privacy import install as _install_privacy_hook
14
+
15
+ _install_privacy_hook()
16
+
17
+ __version__: str = "0.1.0"
18
+
19
+ __all__ = ["PrivacyViolationError", "__version__"]
codevigil/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Package entry point: re-exports ``codevigil.cli.main`` for the console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codevigil.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
9
+
10
+ __all__ = ["main"]
@@ -0,0 +1,506 @@
1
+ """Session orchestration: source → parser → collectors → snapshots.
2
+
3
+ The :class:`SessionAggregator` is the only subsystem that touches every
4
+ other subsystem. It owns one :class:`SessionParser` per session, instantiates
5
+ the active collectors from the registry per session, drives lifecycle
6
+ transitions (ACTIVE → STALE → EVICTED) on a monotonic clock, and is the
7
+ single owner of the error channel: every :class:`CodevigilError` raised by a
8
+ collector is caught here, recorded with ``source=COLLECTOR`` plus the
9
+ offending collector name, and the loop moves on. Peer collectors and other
10
+ sessions are unaffected — see ``docs/design.md`` §Error Non-Swallowing Rule.
11
+
12
+ Wiring the parse_health collector
13
+ ---------------------------------
14
+
15
+ The :class:`~codevigil.types.Collector` protocol is frozen and exposes no
16
+ "give me the parser stats" hook. The aggregator works around this with a
17
+ duck-typed bind: at session creation time it constructs the parser, then for
18
+ each collector instance whose class declares a ``bind_stats`` method it calls
19
+ ``collector.bind_stats(parser.stats)``. Today only
20
+ :class:`~codevigil.collectors.parse_health.ParseHealthCollector` declares
21
+ that method; future drift-aware collectors that need the same handle just
22
+ need to grow the same one-method protocol. This keeps the registry
23
+ collector-shape clean while still letting the always-on integrity collector
24
+ read live parser counters without going through the error channel.
25
+
26
+ The "always on, never disableable" rule for ``parse_health`` is enforced
27
+ here in addition to ``codevigil.config``: even if a future code path managed
28
+ to drop ``parse_health`` from ``collectors.enabled``, the aggregator still
29
+ instantiates it for every session.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import time
35
+ from collections.abc import Callable, Iterator
36
+ from dataclasses import dataclass, field
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from codevigil.bootstrap import BootstrapManager
42
+ from codevigil.collectors import COLLECTORS
43
+ from codevigil.errors import CodevigilError, ErrorLevel, ErrorSource, record
44
+ from codevigil.parser import SessionParser
45
+ from codevigil.projects import ProjectRegistry
46
+ from codevigil.types import (
47
+ Collector,
48
+ Event,
49
+ EventKind,
50
+ MetricSnapshot,
51
+ SessionMeta,
52
+ SessionState,
53
+ Severity,
54
+ )
55
+ from codevigil.watcher import Source, SourceEvent, SourceEventKind
56
+
57
+ _PARSE_HEALTH_NAME: str = "parse_health"
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class _SessionContext:
62
+ """Per-session bookkeeping owned by :class:`SessionAggregator`.
63
+
64
+ ``last_monotonic`` is updated from the aggregator's clock callable on
65
+ every observed APPEND so lifecycle transitions are deterministic in
66
+ tests. ``last_event_time`` is the wall-clock timestamp from the last
67
+ emitted :class:`Event` and is what the renderer surfaces in
68
+ :class:`SessionMeta`.
69
+ """
70
+
71
+ session_id: str
72
+ file_path: Path
73
+ project_hash: str
74
+ parser: SessionParser
75
+ collectors: dict[str, Collector]
76
+ first_event_time: datetime
77
+ last_event_time: datetime
78
+ last_monotonic: float
79
+ event_count: int = 0
80
+ state: SessionState = SessionState.ACTIVE
81
+ last_snapshots: dict[str, MetricSnapshot] = field(default_factory=dict)
82
+
83
+
84
+ _ClockFn = Callable[[], float]
85
+
86
+
87
+ class SessionAggregator:
88
+ """Drive a :class:`Source` through parser, collectors, and lifecycle.
89
+
90
+ The aggregator does not block, does not schedule its own ticks, and does
91
+ not own any threads. The CLI watch loop calls :meth:`tick` on whatever
92
+ cadence ``watch.tick_interval`` dictates and consumes the yielded
93
+ ``(meta, snapshots)`` pairs. Tests drive ``tick()`` directly with a
94
+ scripted fake source and a controllable clock — see
95
+ ``tests/_aggregator_helpers.py``.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ source: Source,
101
+ *,
102
+ config: dict[str, Any],
103
+ project_registry: ProjectRegistry | None = None,
104
+ clock: _ClockFn = time.monotonic,
105
+ registry: dict[str, type[Collector]] | None = None,
106
+ bootstrap: BootstrapManager | None = None,
107
+ ) -> None:
108
+ self._source: Source = source
109
+ self._config: dict[str, Any] = config
110
+ self._registry: dict[str, type[Collector]] = (
111
+ registry if registry is not None else COLLECTORS
112
+ )
113
+ self._project_registry: ProjectRegistry = (
114
+ project_registry if project_registry is not None else ProjectRegistry()
115
+ )
116
+ self._clock: _ClockFn = clock
117
+ self._sessions: dict[str, _SessionContext] = {}
118
+ self._bootstrap: BootstrapManager | None = bootstrap
119
+
120
+ watch_cfg = config.get("watch", {})
121
+ self._stale_after: float = float(watch_cfg.get("stale_after_seconds", 300))
122
+ self._evict_after: float = float(watch_cfg.get("evict_after_seconds", 2100))
123
+ collectors_cfg = config.get("collectors", {})
124
+ enabled = collectors_cfg.get("enabled", [])
125
+ self._enabled_collectors: tuple[str, ...] = tuple(enabled)
126
+
127
+ # --------------------------------------------------------------- properties
128
+
129
+ @property
130
+ def sessions(self) -> dict[str, _SessionContext]:
131
+ """Read-only-ish accessor used by tests; do not mutate externally."""
132
+
133
+ return self._sessions
134
+
135
+ # ----------------------------------------------------------------- tick API
136
+
137
+ def tick(self) -> Iterator[tuple[SessionMeta, list[MetricSnapshot]]]:
138
+ """Consume one batch of source events and yield current snapshots.
139
+
140
+ Order of operations: drain ``source.poll()`` first, then run the
141
+ lifecycle pass over every known session. Doing the source pass first
142
+ means an APPEND received in the same tick that would have crossed
143
+ the STALE threshold counts as activity and keeps the session ACTIVE.
144
+ """
145
+
146
+ for source_event in self._source.poll():
147
+ self._dispatch_source_event(source_event)
148
+ self._run_lifecycle_pass()
149
+
150
+ results: list[tuple[SessionMeta, list[MetricSnapshot]]] = []
151
+ for ctx in self._sessions.values():
152
+ if ctx.state is SessionState.EVICTED:
153
+ continue
154
+ snapshots = self._snapshot_session(ctx)
155
+ results.append((self._build_meta(ctx), snapshots))
156
+ return iter(results)
157
+
158
+ def close(self) -> None:
159
+ """Tear down the source and every live collector."""
160
+
161
+ try:
162
+ self._source.close()
163
+ except CodevigilError as err:
164
+ self._record_collector_error(err, collector_name="source", session_id="*")
165
+ for ctx in list(self._sessions.values()):
166
+ # Take a final snapshot pass so sessions that never reached
167
+ # EVICTED still contribute to the bootstrap distribution.
168
+ if self._bootstrap is not None and self._bootstrap.is_active():
169
+ self._snapshot_session(ctx)
170
+ self._observe_for_bootstrap(ctx)
171
+ self._reset_collectors(ctx)
172
+ self._sessions.clear()
173
+
174
+ # ---------------------------------------------------------- source dispatch
175
+
176
+ def _dispatch_source_event(self, source_event: SourceEvent) -> None:
177
+ kind = source_event.kind
178
+ if kind is SourceEventKind.NEW_SESSION:
179
+ self._ensure_session(source_event)
180
+ return
181
+ if kind is SourceEventKind.APPEND:
182
+ ctx = self._ensure_session(source_event)
183
+ if source_event.line is None:
184
+ return
185
+ self._ingest_line(ctx, source_event.line)
186
+ return
187
+ if kind is SourceEventKind.ROTATE or kind is SourceEventKind.TRUNCATE:
188
+ # The watcher resets its file cursor and re-reads from byte 0,
189
+ # so a fresh stream of APPEND events will follow. The *session*
190
+ # is the same logical session, so we deliberately preserve the
191
+ # collector state and the parser instance — clearing them would
192
+ # erase the very degradation history the metrics exist to
193
+ # surface. The watcher is the source of truth for line replay;
194
+ # the aggregator just keeps consuming.
195
+ return
196
+ if kind is SourceEventKind.DELETE:
197
+ self._evict_session(source_event.session_id)
198
+ return
199
+
200
+ def _ensure_session(self, source_event: SourceEvent) -> _SessionContext:
201
+ sid = source_event.session_id
202
+ existing = self._sessions.get(sid)
203
+ if existing is not None:
204
+ return existing
205
+ parser = SessionParser(session_id=sid)
206
+ collectors = self._instantiate_collectors(parser)
207
+ now_clock = self._clock()
208
+ now_wall = source_event.timestamp
209
+ ctx = _SessionContext(
210
+ session_id=sid,
211
+ file_path=source_event.path,
212
+ project_hash=self._extract_project_hash(source_event.path),
213
+ parser=parser,
214
+ collectors=collectors,
215
+ first_event_time=now_wall,
216
+ last_event_time=now_wall,
217
+ last_monotonic=now_clock,
218
+ )
219
+ self._sessions[sid] = ctx
220
+ return ctx
221
+
222
+ @staticmethod
223
+ def _extract_project_hash(path: Path) -> str:
224
+ """Pull the project-hash directory from the canonical path layout.
225
+
226
+ Layout is ``~/.claude/projects/<project-hash>/sessions/<id>.jsonl``;
227
+ we walk parents until we find one named ``projects`` and return the
228
+ directory immediately under it. Anything else falls back to the
229
+ empty string, which the registry resolves to ``""[:8] == ""``.
230
+ """
231
+
232
+ parts = path.parts
233
+ for index, part in enumerate(parts):
234
+ if part == "projects" and index + 1 < len(parts):
235
+ return parts[index + 1]
236
+ return ""
237
+
238
+ def _instantiate_collectors(self, parser: SessionParser) -> dict[str, Collector]:
239
+ """Build the per-session collector dict from the registry.
240
+
241
+ ``parse_health`` is always instantiated, regardless of whether it
242
+ appears in the enabled list — it is the only un-disableable
243
+ collector and the validator already refuses configs that try to
244
+ turn it off, but enforcing it here too means a buggy code path that
245
+ bypasses the validator still cannot drop the integrity collector.
246
+ """
247
+
248
+ instances: dict[str, Collector] = {}
249
+ names: list[str] = []
250
+ if _PARSE_HEALTH_NAME in self._registry:
251
+ names.append(_PARSE_HEALTH_NAME)
252
+ for name in self._enabled_collectors:
253
+ if name == _PARSE_HEALTH_NAME:
254
+ continue
255
+ if name in self._registry:
256
+ names.append(name)
257
+ for name in names:
258
+ cls = self._registry[name]
259
+ instance = cls()
260
+ bind = getattr(instance, "bind_stats", None)
261
+ if callable(bind):
262
+ bind(parser.stats)
263
+ instances[name] = instance
264
+ return instances
265
+
266
+ # -------------------------------------------------------------- ingest path
267
+
268
+ def _ingest_line(self, ctx: _SessionContext, line: str) -> None:
269
+ for event in ctx.parser.parse([line]):
270
+ ctx.event_count += 1
271
+ ctx.last_event_time = event.timestamp
272
+ ctx.last_monotonic = self._clock()
273
+ if ctx.state is SessionState.STALE:
274
+ # STALE → ACTIVE on a new APPEND (the "coffee break" rule).
275
+ # Collector state is intentionally preserved.
276
+ ctx.state = SessionState.ACTIVE
277
+ if event.kind is EventKind.SYSTEM:
278
+ self._project_registry.observe_system_event(ctx.project_hash, event)
279
+ self._fan_out_event(ctx, event)
280
+
281
+ def _fan_out_event(self, ctx: _SessionContext, event: Event) -> None:
282
+ for collector_name, collector in ctx.collectors.items():
283
+ try:
284
+ collector.ingest(event)
285
+ except CodevigilError as err:
286
+ # One collector raising must not poison its peers. We log
287
+ # the failure with source=COLLECTOR and continue — both the
288
+ # remaining collectors for this event and the next event
289
+ # for the same collector keep flowing.
290
+ self._record_collector_error(
291
+ err,
292
+ collector_name=collector_name,
293
+ session_id=ctx.session_id,
294
+ )
295
+ except Exception as exc:
296
+ # A collector raising a non-CodevigilError is a bug, but
297
+ # the design's non-swallowing rule still requires we route
298
+ # it through the error channel rather than crash the loop.
299
+ self._record_collector_error(
300
+ CodevigilError(
301
+ level=ErrorLevel.ERROR,
302
+ source=ErrorSource.COLLECTOR,
303
+ code="aggregator.collector_unexpected_exception",
304
+ message=(
305
+ f"collector {collector_name!r} raised {type(exc).__name__}: {exc}"
306
+ ),
307
+ context={
308
+ "collector": collector_name,
309
+ "session_id": ctx.session_id,
310
+ "exception_type": type(exc).__name__,
311
+ },
312
+ ),
313
+ collector_name=collector_name,
314
+ session_id=ctx.session_id,
315
+ )
316
+
317
+ def _record_collector_error(
318
+ self,
319
+ err: CodevigilError,
320
+ *,
321
+ collector_name: str,
322
+ session_id: str,
323
+ ) -> None:
324
+ ctx_payload = dict(err.context)
325
+ ctx_payload.setdefault("collector", collector_name)
326
+ ctx_payload.setdefault("session_id", session_id)
327
+ record(
328
+ CodevigilError(
329
+ level=err.level if err.level is not ErrorLevel.INFO else ErrorLevel.ERROR,
330
+ source=ErrorSource.COLLECTOR,
331
+ code=err.code or "aggregator.collector_error",
332
+ message=err.message,
333
+ context=ctx_payload,
334
+ )
335
+ )
336
+
337
+ # ------------------------------------------------------------------ snapshot
338
+
339
+ def _snapshot_session(self, ctx: _SessionContext) -> list[MetricSnapshot]:
340
+ snapshots: list[MetricSnapshot] = []
341
+ for collector_name, collector in ctx.collectors.items():
342
+ try:
343
+ raw = collector.snapshot()
344
+ ctx.last_snapshots[collector_name] = raw
345
+ snapshots.append(self._apply_bootstrap_clamp(collector_name, raw))
346
+ except CodevigilError as err:
347
+ self._record_collector_error(
348
+ err,
349
+ collector_name=collector_name,
350
+ session_id=ctx.session_id,
351
+ )
352
+ except Exception as exc:
353
+ self._record_collector_error(
354
+ CodevigilError(
355
+ level=ErrorLevel.ERROR,
356
+ source=ErrorSource.COLLECTOR,
357
+ code="aggregator.snapshot_unexpected_exception",
358
+ message=(
359
+ f"collector {collector_name!r} snapshot raised "
360
+ f"{type(exc).__name__}: {exc}"
361
+ ),
362
+ context={
363
+ "collector": collector_name,
364
+ "session_id": ctx.session_id,
365
+ },
366
+ ),
367
+ collector_name=collector_name,
368
+ session_id=ctx.session_id,
369
+ )
370
+ return snapshots
371
+
372
+ def _apply_bootstrap_clamp(
373
+ self,
374
+ collector_name: str,
375
+ snap: MetricSnapshot,
376
+ ) -> MetricSnapshot:
377
+ """Pin severity to OK and tag the label while bootstrap runs.
378
+
379
+ ``parse_health`` is the only integrity signal and must keep its
380
+ real severity; every other collector is still experimental until
381
+ the bootstrap window closes, so we refuse to let them drive
382
+ alerts during calibration. The raw snapshot the collector
383
+ produced is preserved in ``ctx.last_snapshots`` for observation;
384
+ only the user-visible copy is rewritten.
385
+ """
386
+
387
+ bootstrap = self._bootstrap
388
+ if bootstrap is None or not bootstrap.is_active():
389
+ return snap
390
+ if collector_name == _PARSE_HEALTH_NAME:
391
+ return snap
392
+ tag = f"[bootstrap {bootstrap.sessions_observed() + 1}/{bootstrap.target}]"
393
+ label = f"{snap.label} {tag}" if snap.label else tag
394
+ return MetricSnapshot(
395
+ name=snap.name,
396
+ value=snap.value,
397
+ label=label,
398
+ severity=Severity.OK,
399
+ detail=snap.detail,
400
+ )
401
+
402
+ def _observe_for_bootstrap(self, ctx: _SessionContext) -> None:
403
+ """Hand the final per-collector snapshots to the bootstrap manager."""
404
+
405
+ bootstrap = self._bootstrap
406
+ if bootstrap is None or not bootstrap.is_active():
407
+ return
408
+ payload: dict[str, MetricSnapshot] = {}
409
+ for collector_name, snap in ctx.last_snapshots.items():
410
+ if collector_name == _PARSE_HEALTH_NAME:
411
+ continue
412
+ payload[collector_name] = snap
413
+ if not payload:
414
+ return
415
+ bootstrap.observe_session(ctx.session_id, payload)
416
+ if bootstrap.finalize_if_ready():
417
+ record(
418
+ CodevigilError(
419
+ level=ErrorLevel.INFO,
420
+ source=ErrorSource.AGGREGATOR,
421
+ code="aggregator.bootstrap_complete",
422
+ message=(
423
+ f"bootstrap window closed after {bootstrap.sessions_observed()} "
424
+ f"sessions; derived thresholds persisted to "
425
+ f"{bootstrap.state_path!s}"
426
+ ),
427
+ context={
428
+ "state_path": str(bootstrap.state_path),
429
+ "sessions_observed": bootstrap.sessions_observed(),
430
+ },
431
+ )
432
+ )
433
+
434
+ def _build_meta(self, ctx: _SessionContext) -> SessionMeta:
435
+ confidence = ctx.parser.stats.parse_confidence
436
+ return SessionMeta(
437
+ session_id=ctx.session_id,
438
+ project_hash=ctx.project_hash,
439
+ project_name=self._project_registry.resolve(ctx.project_hash),
440
+ file_path=ctx.file_path,
441
+ start_time=ctx.first_event_time,
442
+ last_event_time=ctx.last_event_time,
443
+ event_count=ctx.event_count,
444
+ parse_confidence=float(confidence),
445
+ state=ctx.state,
446
+ )
447
+
448
+ # ----------------------------------------------------------------- lifecycle
449
+
450
+ def _run_lifecycle_pass(self) -> None:
451
+ now = self._clock()
452
+ to_evict: list[str] = []
453
+ for sid, ctx in self._sessions.items():
454
+ silence = now - ctx.last_monotonic
455
+ if silence >= self._evict_after:
456
+ to_evict.append(sid)
457
+ continue
458
+ if silence >= self._stale_after and ctx.state is SessionState.ACTIVE:
459
+ ctx.state = SessionState.STALE
460
+ for sid in to_evict:
461
+ self._evict_session(sid)
462
+
463
+ def _evict_session(self, session_id: str) -> None:
464
+ ctx = self._sessions.pop(session_id, None)
465
+ if ctx is None:
466
+ return
467
+ ctx.state = SessionState.EVICTED
468
+ self._observe_for_bootstrap(ctx)
469
+ self._reset_collectors(ctx)
470
+
471
+ def _reset_collectors(self, ctx: _SessionContext) -> None:
472
+ for collector_name, collector in ctx.collectors.items():
473
+ try:
474
+ collector.reset()
475
+ except CodevigilError as err:
476
+ self._record_collector_error(
477
+ err,
478
+ collector_name=collector_name,
479
+ session_id=ctx.session_id,
480
+ )
481
+ except Exception as exc:
482
+ self._record_collector_error(
483
+ CodevigilError(
484
+ level=ErrorLevel.ERROR,
485
+ source=ErrorSource.COLLECTOR,
486
+ code="aggregator.reset_unexpected_exception",
487
+ message=(
488
+ f"collector {collector_name!r} reset raised {type(exc).__name__}: {exc}"
489
+ ),
490
+ context={
491
+ "collector": collector_name,
492
+ "session_id": ctx.session_id,
493
+ },
494
+ ),
495
+ collector_name=collector_name,
496
+ session_id=ctx.session_id,
497
+ )
498
+
499
+
500
+ def _utc_now() -> datetime: # pragma: no cover - convenience for callers
501
+ return datetime.now(tz=UTC)
502
+
503
+
504
+ # Re-export ``field`` so downstream phases that grow ``_SessionContext`` can
505
+ # reach the dataclasses helper without a second import.
506
+ __all__ = ["SessionAggregator", "field"]