traceforge-toolkit 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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Phase-aware behavioral drift detection with transition-specific bonuses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from traceforge.governance.persistence import SystemStore
|
|
11
|
+
from traceforge.governance.state import SessionStateSnapshot
|
|
12
|
+
from traceforge.governance.types import EnrichmentContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Suspicious transition bonuses (from → to: bonus)
|
|
16
|
+
_TRANSITION_BONUSES: dict[tuple[str, str], int] = {
|
|
17
|
+
("testing", "implementation"): 10, # Verification → destructive implementation
|
|
18
|
+
("verification", "implementation"): 10,
|
|
19
|
+
("testing", "destructive"): 18, # Verification → destructive
|
|
20
|
+
("verification", "destructive"): 18,
|
|
21
|
+
("exploration", "network"): 15, # Exploration → network write
|
|
22
|
+
("exploration", "deployment"): 12, # Exploration → deployment (skipping impl)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Oscillation threshold
|
|
26
|
+
_OSCILLATION_THRESHOLD = 5 # >5 transitions in window = +20 bonus
|
|
27
|
+
_OSCILLATION_BONUS = 20
|
|
28
|
+
_WARMUP_EVENTS = 5 # Minimum events before drift detection activates
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class DriftAssessment:
|
|
33
|
+
"""Full drift assessment per the design spec."""
|
|
34
|
+
|
|
35
|
+
phase_window: tuple[str, ...]
|
|
36
|
+
baseline_distribution: tuple[tuple[str, float], ...] # Sorted pairs (immutable)
|
|
37
|
+
current_phase: str
|
|
38
|
+
anomaly_score: float # 0.0–1.0 deviation from baseline
|
|
39
|
+
risk_bonus: int # 0–25 pts added to risk
|
|
40
|
+
transitions: int # Phase transitions in window
|
|
41
|
+
anomaly: bool # True if risk_bonus > 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DriftDetector:
|
|
45
|
+
"""Stateless drift detector. Phase window from session state, baseline from store."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, store: "SystemStore", window_size: int = 20, threshold: float = 0.3) -> None:
|
|
48
|
+
self._store = store
|
|
49
|
+
self._window_size = window_size
|
|
50
|
+
self._threshold = threshold
|
|
51
|
+
|
|
52
|
+
def detect(
|
|
53
|
+
self,
|
|
54
|
+
ctx: "EnrichmentContext",
|
|
55
|
+
state_snapshot: "SessionStateSnapshot",
|
|
56
|
+
cap: set[str],
|
|
57
|
+
) -> DriftAssessment | None:
|
|
58
|
+
"""Full drift detection: divergence + transition bonuses + oscillation."""
|
|
59
|
+
phase_window = state_snapshot.phase_window
|
|
60
|
+
if len(phase_window) < _WARMUP_EVENTS:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
current_phase = phase_window[-1] if phase_window else "unknown"
|
|
64
|
+
|
|
65
|
+
# Count transitions in window
|
|
66
|
+
transitions = self._count_transitions(phase_window)
|
|
67
|
+
|
|
68
|
+
# Compute distribution divergence
|
|
69
|
+
current_dist = self._compute_distribution(phase_window)
|
|
70
|
+
baseline = self._get_baseline(ctx)
|
|
71
|
+
|
|
72
|
+
divergence = 0.0
|
|
73
|
+
baseline_tuples: tuple[tuple[str, float], ...] = ()
|
|
74
|
+
|
|
75
|
+
if baseline:
|
|
76
|
+
baseline_dist = baseline["phase_counts"]
|
|
77
|
+
total = baseline["total_events"]
|
|
78
|
+
if total > 0:
|
|
79
|
+
norm_baseline = {k: v / total for k, v in baseline_dist.items()}
|
|
80
|
+
baseline_tuples = tuple(sorted(norm_baseline.items()))
|
|
81
|
+
divergence = self._js_divergence(current_dist, norm_baseline)
|
|
82
|
+
|
|
83
|
+
# Compute risk bonus from multiple sources
|
|
84
|
+
risk_bonus = 0
|
|
85
|
+
|
|
86
|
+
# 1. Transition-specific bonuses
|
|
87
|
+
if len(phase_window) >= 2:
|
|
88
|
+
prev_phase = phase_window[-2]
|
|
89
|
+
transition_key = (prev_phase, current_phase)
|
|
90
|
+
risk_bonus += _TRANSITION_BONUSES.get(transition_key, 0)
|
|
91
|
+
|
|
92
|
+
# 2. Oscillation bonus
|
|
93
|
+
if transitions > _OSCILLATION_THRESHOLD:
|
|
94
|
+
risk_bonus += _OSCILLATION_BONUS
|
|
95
|
+
|
|
96
|
+
# 3. Divergence-based bonus (scale anomaly_score to 0-15 pts)
|
|
97
|
+
if divergence > self._threshold:
|
|
98
|
+
risk_bonus += min(int(divergence * 25), 15)
|
|
99
|
+
|
|
100
|
+
# Cap total at 25
|
|
101
|
+
risk_bonus = min(risk_bonus, 25)
|
|
102
|
+
|
|
103
|
+
anomaly = risk_bonus > 0
|
|
104
|
+
|
|
105
|
+
if anomaly:
|
|
106
|
+
# phase_anomaly tracked via structure only (not capability)
|
|
107
|
+
# to avoid polluting canonical hash with session-contextual state
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
return DriftAssessment(
|
|
111
|
+
phase_window=phase_window,
|
|
112
|
+
baseline_distribution=baseline_tuples,
|
|
113
|
+
current_phase=current_phase,
|
|
114
|
+
anomaly_score=divergence,
|
|
115
|
+
risk_bonus=risk_bonus,
|
|
116
|
+
transitions=transitions,
|
|
117
|
+
anomaly=anomaly,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def check_drift(
|
|
121
|
+
self,
|
|
122
|
+
phase_window: tuple[str, ...],
|
|
123
|
+
current_phase: str,
|
|
124
|
+
baseline: tuple[tuple[str, float], ...] | None,
|
|
125
|
+
) -> DriftAssessment | None:
|
|
126
|
+
"""Pure function version — receives pre-loaded baseline."""
|
|
127
|
+
if len(phase_window) < _WARMUP_EVENTS:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
transitions = self._count_transitions(phase_window)
|
|
131
|
+
current_dist = self._compute_distribution(phase_window)
|
|
132
|
+
|
|
133
|
+
divergence = 0.0
|
|
134
|
+
if baseline:
|
|
135
|
+
norm_baseline = dict(baseline)
|
|
136
|
+
divergence = self._js_divergence(current_dist, norm_baseline)
|
|
137
|
+
|
|
138
|
+
risk_bonus = 0
|
|
139
|
+
if len(phase_window) >= 2:
|
|
140
|
+
prev_phase = phase_window[-2]
|
|
141
|
+
risk_bonus += _TRANSITION_BONUSES.get((prev_phase, current_phase), 0)
|
|
142
|
+
if transitions > _OSCILLATION_THRESHOLD:
|
|
143
|
+
risk_bonus += _OSCILLATION_BONUS
|
|
144
|
+
if divergence > self._threshold:
|
|
145
|
+
risk_bonus += min(int(divergence * 25), 15)
|
|
146
|
+
risk_bonus = min(risk_bonus, 25)
|
|
147
|
+
|
|
148
|
+
return DriftAssessment(
|
|
149
|
+
phase_window=phase_window,
|
|
150
|
+
baseline_distribution=baseline or (),
|
|
151
|
+
current_phase=current_phase,
|
|
152
|
+
anomaly_score=divergence,
|
|
153
|
+
risk_bonus=risk_bonus,
|
|
154
|
+
transitions=transitions,
|
|
155
|
+
anomaly=risk_bonus > 0,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _get_baseline(self, ctx: "EnrichmentContext") -> dict | None:
|
|
159
|
+
# Use pre-loaded baseline from context when available
|
|
160
|
+
if ctx.drift_baseline:
|
|
161
|
+
return {
|
|
162
|
+
"phase_counts": dict(ctx.drift_baseline),
|
|
163
|
+
"total_events": sum(v for _, v in ctx.drift_baseline),
|
|
164
|
+
}
|
|
165
|
+
repo = ctx.project_root or "unknown"
|
|
166
|
+
return self._store.get_drift_baseline("unknown", repo)
|
|
167
|
+
|
|
168
|
+
def _count_transitions(self, phases: tuple[str, ...]) -> int:
|
|
169
|
+
"""Count phase transitions (consecutive different phases) in window."""
|
|
170
|
+
count = 0
|
|
171
|
+
for i in range(1, len(phases)):
|
|
172
|
+
if phases[i] != phases[i - 1]:
|
|
173
|
+
count += 1
|
|
174
|
+
return count
|
|
175
|
+
|
|
176
|
+
def _compute_distribution(self, phases: tuple[str, ...] | list[str]) -> dict[str, float]:
|
|
177
|
+
counts: dict[str, int] = {}
|
|
178
|
+
for p in phases:
|
|
179
|
+
counts[p] = counts.get(p, 0) + 1
|
|
180
|
+
total = len(phases)
|
|
181
|
+
return {k: v / total for k, v in counts.items()}
|
|
182
|
+
|
|
183
|
+
def _js_divergence(self, p: dict[str, float], q: dict[str, float]) -> float:
|
|
184
|
+
"""Jensen-Shannon divergence (symmetric, bounded [0, ln(2)])."""
|
|
185
|
+
all_keys = set(p.keys()) | set(q.keys())
|
|
186
|
+
eps = 1e-10
|
|
187
|
+
divergence = 0.0
|
|
188
|
+
for k in all_keys:
|
|
189
|
+
pk = p.get(k, eps)
|
|
190
|
+
qk = q.get(k, eps)
|
|
191
|
+
m = (pk + qk) / 2
|
|
192
|
+
if pk > eps:
|
|
193
|
+
divergence += pk * math.log(pk / m)
|
|
194
|
+
if qk > eps:
|
|
195
|
+
divergence += qk * math.log(qk / m)
|
|
196
|
+
return divergence / 2
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Async emission actor for governance-enriched events, with backpressure.
|
|
2
|
+
|
|
3
|
+
The observer enriches **synchronously** (single-writer governance: ``observe_event``
|
|
4
|
+
runs exactly once per event and advances the tool-call budget) and hands an
|
|
5
|
+
already-scored ``(event, meta)`` pair to this actor via :meth:`EnrichedEmitter.submit`.
|
|
6
|
+
The actor owns *only* audit emission and backpressure — it never enriches, so it
|
|
7
|
+
can never re-run ``observe_event`` and double-count the budget.
|
|
8
|
+
|
|
9
|
+
A bounded :class:`asyncio.Queue` decouples the latency-sensitive enforcement path
|
|
10
|
+
(the observer hook returns ``SessionMeta`` to the host immediately) from
|
|
11
|
+
potentially slow sinks. When the queue is full the **oldest audit record** is
|
|
12
|
+
dropped — never an enforcement decision, which already took effect synchronously
|
|
13
|
+
in the observer — the drop is counted durably via an injected ``record_drop``
|
|
14
|
+
callback, and a **coalesced** :class:`ContextGapEvent` is emitted downstream so
|
|
15
|
+
consumers see an explicit gap marker instead of silent loss.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import TYPE_CHECKING, Callable, Iterable
|
|
25
|
+
|
|
26
|
+
from traceforge.governance.envelope import ContextGapEvent, EnrichedEvent
|
|
27
|
+
from traceforge.governance.results import SessionMeta
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import traceforge.types
|
|
31
|
+
from traceforge.sinks.base import StorageSink
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Synthetic gap markers bypass enrichment, so they carry an empty SessionMeta
|
|
36
|
+
# (serializes to ``"_governance": {}``). Reused — SessionMeta is immutable data.
|
|
37
|
+
_EMPTY_META = SessionMeta(classification=None, risk_assessment=None)
|
|
38
|
+
|
|
39
|
+
DEFAULT_CAPACITY = 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _GapAccumulator:
|
|
43
|
+
"""Coalesces consecutive dropped events for one session into one marker.
|
|
44
|
+
|
|
45
|
+
The durable drop *counter* is precise (``record_drop`` fires once per dropped
|
|
46
|
+
event); the emitted *marker* is coalesced so a burst of drops surfaces as a
|
|
47
|
+
single :class:`ContextGapEvent` spanning ``first``..``last`` sequence.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
__slots__ = ("session_id", "count", "first_sequence", "last_sequence", "gap_ordinal")
|
|
51
|
+
|
|
52
|
+
def __init__(self, session_id: str) -> None:
|
|
53
|
+
self.session_id = session_id
|
|
54
|
+
self.count = 0
|
|
55
|
+
self.first_sequence: int | None = None
|
|
56
|
+
self.last_sequence: int | None = None
|
|
57
|
+
self.gap_ordinal = 0
|
|
58
|
+
|
|
59
|
+
def add(self, sequence: int | None, gap_ordinal: int) -> None:
|
|
60
|
+
self.count += 1
|
|
61
|
+
self.gap_ordinal = gap_ordinal
|
|
62
|
+
if sequence is not None:
|
|
63
|
+
if self.first_sequence is None:
|
|
64
|
+
self.first_sequence = sequence
|
|
65
|
+
self.last_sequence = sequence
|
|
66
|
+
|
|
67
|
+
def to_event(self) -> ContextGapEvent:
|
|
68
|
+
key = ContextGapEvent.compute_source_event_key(
|
|
69
|
+
self.session_id,
|
|
70
|
+
self.first_sequence,
|
|
71
|
+
self.last_sequence,
|
|
72
|
+
self.gap_ordinal,
|
|
73
|
+
)
|
|
74
|
+
return ContextGapEvent(
|
|
75
|
+
id=f"gap-{uuid.uuid4().hex[:12]}",
|
|
76
|
+
session_id=self.session_id,
|
|
77
|
+
timestamp=datetime.now(timezone.utc),
|
|
78
|
+
source_event_key=key,
|
|
79
|
+
dropped_count=self.count,
|
|
80
|
+
first_dropped_sequence=self.first_sequence,
|
|
81
|
+
last_dropped_sequence=self.last_sequence,
|
|
82
|
+
gap_ordinal=self.gap_ordinal,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class EnrichedEmitter:
|
|
87
|
+
"""Bounded async actor: wraps ``(event, meta)`` in :class:`EnrichedEvent` and
|
|
88
|
+
fans it out to sinks via ``on_enriched_event``, dropping the oldest audit
|
|
89
|
+
record under backpressure.
|
|
90
|
+
|
|
91
|
+
Runs on a single event loop; :meth:`submit` is synchronous and non-blocking
|
|
92
|
+
(safe to call from inside the observer's async hooks). Because everything runs
|
|
93
|
+
single-threaded on the loop, ``submit`` executes atomically with respect to the
|
|
94
|
+
drain task — no locks are needed.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
sinks: "Iterable[StorageSink]",
|
|
100
|
+
*,
|
|
101
|
+
capacity: int = DEFAULT_CAPACITY,
|
|
102
|
+
record_drop: "Callable[[str, int], None] | None" = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
if capacity < 1:
|
|
105
|
+
raise ValueError("capacity must be >= 1")
|
|
106
|
+
self._sinks: list[StorageSink] = list(sinks)
|
|
107
|
+
self._capacity = capacity
|
|
108
|
+
self._record_drop = record_drop
|
|
109
|
+
self._queue: asyncio.Queue = asyncio.Queue(maxsize=capacity)
|
|
110
|
+
# Per-session coalescing of dropped events, flushed just before that
|
|
111
|
+
# session's next surviving event (and any trailing remainder at aclose).
|
|
112
|
+
self._pending_gaps: dict[str, _GapAccumulator] = {}
|
|
113
|
+
self._gap_ordinals: dict[str, int] = {}
|
|
114
|
+
self._drain_task: asyncio.Task | None = None
|
|
115
|
+
self._closed = False
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def capacity(self) -> int:
|
|
119
|
+
return self._capacity
|
|
120
|
+
|
|
121
|
+
async def start(self) -> None:
|
|
122
|
+
"""Begin draining. Idempotent; must be called inside the event loop that
|
|
123
|
+
will own emission."""
|
|
124
|
+
if self._drain_task is None and not self._closed:
|
|
125
|
+
self._drain_task = asyncio.create_task(self._drain_loop())
|
|
126
|
+
|
|
127
|
+
def submit(self, event: "traceforge.types.SessionEvent", meta: SessionMeta) -> None:
|
|
128
|
+
"""Enqueue an already-enriched ``(event, meta)`` for emission.
|
|
129
|
+
|
|
130
|
+
Synchronous and non-blocking. On a full queue, drop the **oldest**
|
|
131
|
+
surviving audit record (enforcement already happened in the observer, so
|
|
132
|
+
only audit emission is lost), record it durably, coalesce it into the
|
|
133
|
+
session's pending gap marker, then enqueue the new item.
|
|
134
|
+
"""
|
|
135
|
+
item = (event, meta)
|
|
136
|
+
try:
|
|
137
|
+
self._queue.put_nowait(item)
|
|
138
|
+
return
|
|
139
|
+
except asyncio.QueueFull:
|
|
140
|
+
pass
|
|
141
|
+
try:
|
|
142
|
+
dropped_event, _ = self._queue.get_nowait()
|
|
143
|
+
# Balance the unfinished-task count for the put() that enqueued the
|
|
144
|
+
# dropped item, so aclose()'s queue.join() cannot hang.
|
|
145
|
+
self._queue.task_done()
|
|
146
|
+
except asyncio.QueueEmpty:
|
|
147
|
+
dropped_event = None
|
|
148
|
+
if dropped_event is not None:
|
|
149
|
+
self._record_dropped(dropped_event)
|
|
150
|
+
try:
|
|
151
|
+
self._queue.put_nowait(item)
|
|
152
|
+
except asyncio.QueueFull:
|
|
153
|
+
# We just freed a slot, so this should not happen; if it somehow does,
|
|
154
|
+
# count the *new* item as dropped rather than block the caller.
|
|
155
|
+
self._record_dropped(event)
|
|
156
|
+
|
|
157
|
+
def _record_dropped(self, event: "traceforge.types.SessionEvent") -> None:
|
|
158
|
+
sid = getattr(event, "session_id", "") or ""
|
|
159
|
+
sequence = None
|
|
160
|
+
metadata = getattr(event, "metadata", None)
|
|
161
|
+
if metadata is not None:
|
|
162
|
+
sequence = getattr(metadata, "sequence", None)
|
|
163
|
+
ordinal = self._gap_ordinals.get(sid, 0) + 1
|
|
164
|
+
self._gap_ordinals[sid] = ordinal
|
|
165
|
+
acc = self._pending_gaps.get(sid)
|
|
166
|
+
if acc is None:
|
|
167
|
+
acc = _GapAccumulator(sid)
|
|
168
|
+
self._pending_gaps[sid] = acc
|
|
169
|
+
acc.add(sequence, ordinal)
|
|
170
|
+
if self._record_drop is not None:
|
|
171
|
+
try:
|
|
172
|
+
self._record_drop(sid, 1)
|
|
173
|
+
except Exception as exc: # never let a persistence hiccup break submit
|
|
174
|
+
logger.error("record_drop failed for session %s: %s", sid, exc)
|
|
175
|
+
|
|
176
|
+
async def _drain_loop(self) -> None:
|
|
177
|
+
while True:
|
|
178
|
+
event, meta = await self._queue.get()
|
|
179
|
+
try:
|
|
180
|
+
await self._emit(event, meta)
|
|
181
|
+
finally:
|
|
182
|
+
self._queue.task_done()
|
|
183
|
+
|
|
184
|
+
async def _emit(self, event: "traceforge.types.SessionEvent", meta: SessionMeta) -> None:
|
|
185
|
+
# Flush this session's coalesced gap (if any) *before* the surviving event,
|
|
186
|
+
# so downstream sees the gap marker in order ahead of the next real record.
|
|
187
|
+
sid = getattr(event, "session_id", "") or ""
|
|
188
|
+
acc = self._pending_gaps.pop(sid, None)
|
|
189
|
+
if acc is not None:
|
|
190
|
+
await self._fanout_enriched(EnrichedEvent(event=acc.to_event(), governance=_EMPTY_META))
|
|
191
|
+
await self._fanout_enriched(EnrichedEvent(event=event, governance=meta))
|
|
192
|
+
|
|
193
|
+
async def _fanout_enriched(self, enriched: EnrichedEvent) -> None:
|
|
194
|
+
results = await asyncio.gather(
|
|
195
|
+
*(sink.on_enriched_event(enriched) for sink in self._sinks),
|
|
196
|
+
return_exceptions=True,
|
|
197
|
+
)
|
|
198
|
+
self._log_sink_errors(results, "on_enriched_event")
|
|
199
|
+
|
|
200
|
+
def _log_sink_errors(self, results: list, op: str) -> None:
|
|
201
|
+
for sink, result in zip(self._sinks, results):
|
|
202
|
+
if isinstance(result, BaseException):
|
|
203
|
+
logger.error(
|
|
204
|
+
"Sink %s failed during %s: %s",
|
|
205
|
+
type(sink).__name__,
|
|
206
|
+
op,
|
|
207
|
+
result,
|
|
208
|
+
exc_info=(type(result), result, result.__traceback__),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def aclose(self) -> None:
|
|
212
|
+
"""Drain everything already submitted, flush trailing gap markers, then
|
|
213
|
+
flush sinks. Idempotent."""
|
|
214
|
+
if self._closed:
|
|
215
|
+
return
|
|
216
|
+
self._closed = True
|
|
217
|
+
if self._drain_task is not None:
|
|
218
|
+
# Wait for all queued items to be emitted, then stop the loop.
|
|
219
|
+
await self._queue.join()
|
|
220
|
+
self._drain_task.cancel()
|
|
221
|
+
try:
|
|
222
|
+
await self._drain_task
|
|
223
|
+
except asyncio.CancelledError:
|
|
224
|
+
pass
|
|
225
|
+
self._drain_task = None
|
|
226
|
+
# Any coalesced gaps whose surviving event never arrived still deserve a
|
|
227
|
+
# downstream marker.
|
|
228
|
+
for sid in list(self._pending_gaps.keys()):
|
|
229
|
+
acc = self._pending_gaps.pop(sid)
|
|
230
|
+
await self._fanout_enriched(EnrichedEvent(event=acc.to_event(), governance=_EMPTY_META))
|
|
231
|
+
results = await asyncio.gather(
|
|
232
|
+
*(sink.flush() for sink in self._sinks), return_exceptions=True
|
|
233
|
+
)
|
|
234
|
+
self._log_sink_errors(results, "flush")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Enriched event envelope, ContextGapEvent, and sink emission types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Literal
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import traceforge.types
|
|
11
|
+
|
|
12
|
+
from traceforge.governance.pipeline import SessionMeta
|
|
13
|
+
from traceforge.governance.types import SessionEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ContextGapEvent:
|
|
18
|
+
"""Synthetic marker emitted when events are dropped due to backpressure.
|
|
19
|
+
|
|
20
|
+
Does NOT flow through full enrichment pipeline — bypasses Phase 2/3.
|
|
21
|
+
Serialized directly to sinks as-is.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
session_id: str
|
|
26
|
+
timestamp: datetime
|
|
27
|
+
source_event_key: str
|
|
28
|
+
kind: Literal["context_gap"] = "context_gap"
|
|
29
|
+
dropped_count: int = 0
|
|
30
|
+
first_dropped_sequence: int | None = None
|
|
31
|
+
last_dropped_sequence: int | None = None
|
|
32
|
+
gap_ordinal: int = 0
|
|
33
|
+
reason: str = "backpressure"
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def compute_source_event_key(
|
|
37
|
+
session_id: str,
|
|
38
|
+
first_sequence: int | None,
|
|
39
|
+
last_sequence: int | None,
|
|
40
|
+
gap_ordinal: int,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Deterministic key derivation for gap events."""
|
|
43
|
+
if first_sequence is not None and last_sequence is not None:
|
|
44
|
+
return f"gap:{session_id}:{first_sequence}:{last_sequence}"
|
|
45
|
+
return f"gap:{session_id}:ord:{gap_ordinal}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class EnrichedEvent:
|
|
50
|
+
"""Immutable envelope for sink emission. Event is unmodified; governance is attached alongside.
|
|
51
|
+
|
|
52
|
+
Sinks serialize this as {"event": {...}, "_governance": {...}} or equivalent per sink type.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
event: "traceforge.types.SessionEvent | SessionEvent | ContextGapEvent"
|
|
56
|
+
governance: "SessionMeta"
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
"""Serialize for sink emission."""
|
|
60
|
+
event_dict: dict
|
|
61
|
+
if isinstance(self.event, ContextGapEvent):
|
|
62
|
+
event_dict = {
|
|
63
|
+
"kind": self.event.kind,
|
|
64
|
+
"id": self.event.id,
|
|
65
|
+
"session_id": self.event.session_id,
|
|
66
|
+
"timestamp": self.event.timestamp.isoformat(),
|
|
67
|
+
"dropped_count": self.event.dropped_count,
|
|
68
|
+
"first_dropped_sequence": self.event.first_dropped_sequence,
|
|
69
|
+
"last_dropped_sequence": self.event.last_dropped_sequence,
|
|
70
|
+
"gap_ordinal": self.event.gap_ordinal,
|
|
71
|
+
"reason": self.event.reason,
|
|
72
|
+
}
|
|
73
|
+
elif hasattr(self.event, "model_dump"):
|
|
74
|
+
# Live traceforge.types.SessionEvent (pydantic). Mirror JsonlSink's
|
|
75
|
+
# event body, but lift governance out of metadata into the top-level
|
|
76
|
+
# ``_governance`` slot so the event stays the raw structural record
|
|
77
|
+
# and governance is not duplicated inside it.
|
|
78
|
+
live = self.event
|
|
79
|
+
metadata_dict = (
|
|
80
|
+
live.metadata.model_dump(exclude_none=True) if live.metadata is not None else None
|
|
81
|
+
)
|
|
82
|
+
if isinstance(metadata_dict, dict):
|
|
83
|
+
metadata_dict.pop("governance", None)
|
|
84
|
+
event_dict = {
|
|
85
|
+
"id": live.id,
|
|
86
|
+
"kind": live.kind,
|
|
87
|
+
"session_id": live.session_id,
|
|
88
|
+
"timestamp": live.timestamp.isoformat(),
|
|
89
|
+
"payload": live.payload,
|
|
90
|
+
"metadata": metadata_dict,
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
event_dict = {
|
|
94
|
+
"event_id": self.event.event_id,
|
|
95
|
+
"session_id": self.event.session_id,
|
|
96
|
+
"timestamp": self.event.timestamp.isoformat(),
|
|
97
|
+
"source_event_key": self.event.source_event_key,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
governance_dict = {}
|
|
101
|
+
if self.governance.classification is not None:
|
|
102
|
+
governance_dict["classification"] = self.governance.classification.to_dict()
|
|
103
|
+
if self.governance.risk_assessment is not None:
|
|
104
|
+
ra = self.governance.risk_assessment
|
|
105
|
+
governance_dict["risk_assessment"] = {
|
|
106
|
+
"score": ra.score,
|
|
107
|
+
"level": ra.level,
|
|
108
|
+
"confidence": ra.confidence,
|
|
109
|
+
"factors": list(ra.factors),
|
|
110
|
+
"mitre": list(ra.mitre),
|
|
111
|
+
}
|
|
112
|
+
if self.governance.recommendation is not None:
|
|
113
|
+
rec = self.governance.recommendation
|
|
114
|
+
governance_dict["recommendation"] = {
|
|
115
|
+
"action": rec.recommended_action.value,
|
|
116
|
+
"reason_code": rec.reason_code,
|
|
117
|
+
"canonical_id": rec.canonical_id,
|
|
118
|
+
}
|
|
119
|
+
if self.governance.evidence is not None:
|
|
120
|
+
ev = self.governance.evidence
|
|
121
|
+
governance_dict["evidence"] = {
|
|
122
|
+
"canonical_id": ev.canonical_id,
|
|
123
|
+
"recommended_action": ev.recommended_action.value,
|
|
124
|
+
"risk_score": ev.risk_score,
|
|
125
|
+
"mechanism": ev.mechanism,
|
|
126
|
+
"effect": ev.effect,
|
|
127
|
+
"scope": list(ev.scope),
|
|
128
|
+
"capability": list(ev.capability),
|
|
129
|
+
"structure": list(ev.structure),
|
|
130
|
+
}
|
|
131
|
+
if self.governance.budget_snapshot is not None:
|
|
132
|
+
bs = self.governance.budget_snapshot
|
|
133
|
+
governance_dict["budget"] = {
|
|
134
|
+
"total_tool_calls": bs.total_tool_calls,
|
|
135
|
+
"pressure": bs.pressure,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {"event": event_dict, "_governance": governance_dict}
|