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,276 @@
|
|
|
1
|
+
"""TraceforgeObserver protocol + concrete GovernanceObserver adapter.
|
|
2
|
+
|
|
3
|
+
``TraceforgeObserver`` is the integration point host frameworks call around tool
|
|
4
|
+
calls and session lifecycle. ``GovernanceObserver`` is the concrete
|
|
5
|
+
implementation: it runs governance enrichment **synchronously** inside each hook
|
|
6
|
+
(so it can return real ``SessionMeta`` to the host, and so ``observe_event`` — the
|
|
7
|
+
single writer that advances the tool-call budget — runs exactly once per event),
|
|
8
|
+
then hands the enriched record to an :class:`~traceforge.governance.emitter.EnrichedEmitter`
|
|
9
|
+
for asynchronous, backpressure-bounded audit fan-out to sinks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from collections import defaultdict, deque
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from traceforge.governance.emitter import DEFAULT_CAPACITY, EnrichedEmitter
|
|
21
|
+
from traceforge.governance.pipeline import SessionMeta
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from traceforge.classify.core import Classification
|
|
25
|
+
from traceforge.governance.pipeline import GovernancePipeline
|
|
26
|
+
from traceforge.sinks.base import StorageSink
|
|
27
|
+
from traceforge.types import SessionEvent
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class AgentContext:
|
|
34
|
+
"""Context provided by the host framework on session lifecycle events."""
|
|
35
|
+
|
|
36
|
+
session_id: str
|
|
37
|
+
agent_model: str | None = None
|
|
38
|
+
repo: str | None = None
|
|
39
|
+
project_root: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class TraceforgeObserver(Protocol):
|
|
44
|
+
"""Protocol for observing agent tool calls with governance enrichment.
|
|
45
|
+
|
|
46
|
+
Implementations receive pre/post tool call events and session lifecycle events.
|
|
47
|
+
Each method returns SessionMeta with full governance analysis.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
async def on_pre_tool_call(self, tool_name: str, args: dict) -> SessionMeta:
|
|
51
|
+
"""Primary classification point."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
async def on_post_tool_call(self, tool_name: str, result: dict) -> SessionMeta:
|
|
55
|
+
"""IFC propagation, integrity checks, PII scan of output."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
async def on_session_start(self, context: AgentContext) -> SessionMeta:
|
|
59
|
+
"""Called when a new agent session begins."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
async def on_session_end(self, context: AgentContext) -> SessionMeta:
|
|
63
|
+
"""Called when an agent session ends."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GovernanceObserver:
|
|
68
|
+
"""Concrete :class:`TraceforgeObserver`: sync single-writer enrichment in the
|
|
69
|
+
hooks, async audit emission via :class:`EnrichedEmitter`.
|
|
70
|
+
|
|
71
|
+
**Session-scoped.** Construct one per agent session (or let
|
|
72
|
+
:meth:`on_session_start` set the session id). A monotonic per-session sequence
|
|
73
|
+
is stamped on ``metadata.sequence`` so dropped events can be summarized in a
|
|
74
|
+
:class:`~traceforge.governance.envelope.ContextGapEvent`.
|
|
75
|
+
|
|
76
|
+
**Enrichment cardinality** (the load-bearing invariant):
|
|
77
|
+
|
|
78
|
+
* ``on_session_start`` / ``on_session_end`` → ``observe_event`` routes to
|
|
79
|
+
``process_lifecycle`` (Phase 1 only; does **not** advance the tool-call
|
|
80
|
+
budget). Stamped + submitted for audit.
|
|
81
|
+
* ``on_pre_tool_call`` → ``score_tool_call_event`` — a **read-only** preflight
|
|
82
|
+
preview (transient state copy; does **not** advance the budget). Its
|
|
83
|
+
``SessionMeta`` is **returned to the host** for immediate
|
|
84
|
+
classification/enforcement but is **not** emitted to sinks (avoids doubling
|
|
85
|
+
audit output per tool call).
|
|
86
|
+
* ``on_post_tool_call`` → ``observe_event`` routes to ``process_event`` — the
|
|
87
|
+
**single writer** that advances the tool-call budget **exactly once**.
|
|
88
|
+
Stamped + submitted for audit.
|
|
89
|
+
|
|
90
|
+
So across a full ``start → pre → post → end`` cycle the budget advances
|
|
91
|
+
exactly once (at ``post``).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
governance: "GovernancePipeline",
|
|
97
|
+
emitter: EnrichedEmitter,
|
|
98
|
+
*,
|
|
99
|
+
session_id: str | None = None,
|
|
100
|
+
project_root: str | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self._governance = governance
|
|
103
|
+
self._emitter = emitter
|
|
104
|
+
self._session_id = session_id
|
|
105
|
+
self._project_root = project_root
|
|
106
|
+
self._sequence = 0
|
|
107
|
+
# Positional pre→post argument correlation: the post hook receives only a
|
|
108
|
+
# result, but building the completed event's payload (and shell command
|
|
109
|
+
# analysis) needs the original args. Agents call tools sequentially per
|
|
110
|
+
# session, so a per-tool FIFO of pending args pairs them up.
|
|
111
|
+
self._pending_args: dict[str, deque] = defaultdict(deque)
|
|
112
|
+
|
|
113
|
+
# ── internals ──────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def _next_sequence(self) -> int:
|
|
116
|
+
self._sequence += 1
|
|
117
|
+
return self._sequence
|
|
118
|
+
|
|
119
|
+
def _require_session(self) -> str:
|
|
120
|
+
if not self._session_id:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"GovernanceObserver has no session_id; call on_session_start first "
|
|
123
|
+
"or pass session_id to the constructor."
|
|
124
|
+
)
|
|
125
|
+
return self._session_id
|
|
126
|
+
|
|
127
|
+
def _classify(
|
|
128
|
+
self, tool_name: str, arguments: dict, server_namespace: str | None
|
|
129
|
+
) -> "Classification | None":
|
|
130
|
+
"""Reuse the blessed self-classifying governance path so the observed
|
|
131
|
+
event carries a real ``Classification`` rather than the UNKNOWN fallback
|
|
132
|
+
``from_session_event`` would otherwise apply."""
|
|
133
|
+
from traceforge.governance.types import ToolCallEvent
|
|
134
|
+
|
|
135
|
+
gov_event = ToolCallEvent.from_dict(
|
|
136
|
+
{
|
|
137
|
+
"tool_name": tool_name,
|
|
138
|
+
"session_id": self._session_id,
|
|
139
|
+
"tool_input": arguments if isinstance(arguments, dict) else {},
|
|
140
|
+
"server_namespace": server_namespace,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
return self._governance.enrich_event(gov_event).base_classification
|
|
145
|
+
except Exception as exc: # classification is best-effort; never break the hook
|
|
146
|
+
logger.error("governance classification failed for tool %s: %s", tool_name, exc)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def _build_event(
|
|
150
|
+
self, kind: str, payload: dict, *, classification: "Classification | None" = None
|
|
151
|
+
) -> "SessionEvent":
|
|
152
|
+
from traceforge.types import EventMetadata, SessionEvent
|
|
153
|
+
|
|
154
|
+
metadata = EventMetadata(sequence=self._next_sequence(), classification=classification)
|
|
155
|
+
return SessionEvent(
|
|
156
|
+
kind=kind,
|
|
157
|
+
session_id=self._require_session(),
|
|
158
|
+
timestamp=datetime.now(timezone.utc),
|
|
159
|
+
payload=payload,
|
|
160
|
+
metadata=metadata,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _stamp(event: "SessionEvent", meta: SessionMeta) -> "SessionEvent":
|
|
165
|
+
"""Return a copy of ``event`` carrying ``meta`` under ``metadata.governance``.
|
|
166
|
+
|
|
167
|
+
Stamping keeps the backward-compat sink path correct: a sink that only
|
|
168
|
+
implements ``on_event`` still receives governance (the base
|
|
169
|
+
``on_enriched_event`` forwards the stamped event to ``on_event``)."""
|
|
170
|
+
stamped_meta = event.metadata.model_copy(update={"governance": meta})
|
|
171
|
+
return event.model_copy(update={"metadata": stamped_meta})
|
|
172
|
+
|
|
173
|
+
def _empty_meta(self) -> SessionMeta:
|
|
174
|
+
return SessionMeta(classification=None, risk_assessment=None)
|
|
175
|
+
|
|
176
|
+
# ── TraceforgeObserver hooks ─────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
async def on_session_start(self, context: AgentContext) -> SessionMeta:
|
|
179
|
+
self._session_id = context.session_id
|
|
180
|
+
if context.project_root:
|
|
181
|
+
self._project_root = context.project_root
|
|
182
|
+
from traceforge.types import EventKind
|
|
183
|
+
|
|
184
|
+
event = self._build_event(
|
|
185
|
+
EventKind.SESSION_STARTED,
|
|
186
|
+
{"agent_model": context.agent_model, "repo": context.repo},
|
|
187
|
+
)
|
|
188
|
+
meta = self._governance.observe_event(event) or self._empty_meta()
|
|
189
|
+
self._emitter.submit(self._stamp(event, meta), meta)
|
|
190
|
+
return meta
|
|
191
|
+
|
|
192
|
+
async def on_session_end(self, context: AgentContext) -> SessionMeta:
|
|
193
|
+
if context.session_id:
|
|
194
|
+
self._session_id = context.session_id
|
|
195
|
+
if context.project_root:
|
|
196
|
+
self._project_root = context.project_root
|
|
197
|
+
from traceforge.types import EventKind
|
|
198
|
+
|
|
199
|
+
event = self._build_event(
|
|
200
|
+
EventKind.SESSION_ENDED,
|
|
201
|
+
{"agent_model": context.agent_model, "repo": context.repo},
|
|
202
|
+
)
|
|
203
|
+
meta = self._governance.observe_event(event) or self._empty_meta()
|
|
204
|
+
self._emitter.submit(self._stamp(event, meta), meta)
|
|
205
|
+
return meta
|
|
206
|
+
|
|
207
|
+
async def on_pre_tool_call(self, tool_name: str, args: dict) -> SessionMeta:
|
|
208
|
+
self._require_session()
|
|
209
|
+
from traceforge.types import EventKind
|
|
210
|
+
|
|
211
|
+
server_namespace = args.get("server_namespace") if isinstance(args, dict) else None
|
|
212
|
+
classification = self._classify(tool_name, args, server_namespace)
|
|
213
|
+
event = self._build_event(
|
|
214
|
+
EventKind.TOOL_CALL_STARTED,
|
|
215
|
+
{"tool_name": tool_name, "arguments": args, "server_namespace": server_namespace},
|
|
216
|
+
classification=classification,
|
|
217
|
+
)
|
|
218
|
+
# Remember args so the paired post hook can rebuild the completed event.
|
|
219
|
+
self._pending_args[tool_name].append(args)
|
|
220
|
+
# Read-only preflight preview — advances no budget, returned to host only.
|
|
221
|
+
return self._governance.score_tool_call_event(event)
|
|
222
|
+
|
|
223
|
+
async def on_post_tool_call(self, tool_name: str, result: dict) -> SessionMeta:
|
|
224
|
+
self._require_session()
|
|
225
|
+
from traceforge.types import EventKind
|
|
226
|
+
|
|
227
|
+
pending = self._pending_args.get(tool_name)
|
|
228
|
+
args = pending.popleft() if pending else {}
|
|
229
|
+
server_namespace = args.get("server_namespace") if isinstance(args, dict) else None
|
|
230
|
+
classification = self._classify(tool_name, args, server_namespace)
|
|
231
|
+
event = self._build_event(
|
|
232
|
+
EventKind.TOOL_CALL_COMPLETED,
|
|
233
|
+
{
|
|
234
|
+
"tool_name": tool_name,
|
|
235
|
+
"arguments": args,
|
|
236
|
+
"server_namespace": server_namespace,
|
|
237
|
+
"result": result,
|
|
238
|
+
},
|
|
239
|
+
classification=classification,
|
|
240
|
+
)
|
|
241
|
+
# Single writer: advances the tool-call budget exactly once.
|
|
242
|
+
meta = self._governance.observe_event(event) or self._empty_meta()
|
|
243
|
+
self._emitter.submit(self._stamp(event, meta), meta)
|
|
244
|
+
return meta
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def create_observer(
|
|
248
|
+
governance: "GovernancePipeline",
|
|
249
|
+
sinks: "list[StorageSink]",
|
|
250
|
+
*,
|
|
251
|
+
capacity: int = DEFAULT_CAPACITY,
|
|
252
|
+
session_id: str | None = None,
|
|
253
|
+
project_root: str | None = None,
|
|
254
|
+
) -> "tuple[GovernanceObserver, EnrichedEmitter]":
|
|
255
|
+
"""Wire a :class:`GovernanceObserver` + :class:`EnrichedEmitter` against a
|
|
256
|
+
governance pipeline and sinks.
|
|
257
|
+
|
|
258
|
+
The emitter's ``record_drop`` is bound to durable session state so a
|
|
259
|
+
backpressure drop is persisted (``SessionState._dropped_events``) via the
|
|
260
|
+
existing raw-SQLite write-through — no new migration needed.
|
|
261
|
+
|
|
262
|
+
The returned emitter is **not** started; the caller owns its lifecycle and
|
|
263
|
+
must ``await emitter.start()`` inside the event loop that will drive the
|
|
264
|
+
observer, and ``await emitter.aclose()`` at shutdown.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def _record_drop(sid: str, n: int) -> None:
|
|
268
|
+
state = governance.get_or_create_state(sid)
|
|
269
|
+
state.record_drop(n)
|
|
270
|
+
state.persist()
|
|
271
|
+
|
|
272
|
+
emitter = EnrichedEmitter(sinks, capacity=capacity, record_drop=_record_drop)
|
|
273
|
+
observer = GovernanceObserver(
|
|
274
|
+
governance, emitter, session_id=session_id, project_root=project_root
|
|
275
|
+
)
|
|
276
|
+
return observer, emitter
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""SQLite persistence layer for governance session state.
|
|
2
|
+
|
|
3
|
+
Schema is managed by Alembic migrations (src/traceforge/migrations/).
|
|
4
|
+
On initialization, SystemStore runs ``alembic upgrade head`` to apply
|
|
5
|
+
any pending migrations, creating the full normalized schema on a fresh
|
|
6
|
+
database.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
|
|
21
|
+
_LRU_CACHE_SIZE = 10_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run_alembic(conn: sqlite3.Connection) -> None:
|
|
25
|
+
"""Apply pending Alembic migrations using a SQLAlchemy connection wrapper.
|
|
26
|
+
|
|
27
|
+
Skips the heavy SQLAlchemy/Alembic import if the database is already at
|
|
28
|
+
the latest known revision (fast path for the common case).
|
|
29
|
+
"""
|
|
30
|
+
from traceforge.migrations.versions import LATEST_REVISION
|
|
31
|
+
|
|
32
|
+
# Fast path: check if already at head without importing SQLAlchemy
|
|
33
|
+
try:
|
|
34
|
+
cursor = conn.execute("SELECT version_num FROM alembic_version LIMIT 1")
|
|
35
|
+
row = cursor.fetchone()
|
|
36
|
+
if row and row[0] == LATEST_REVISION:
|
|
37
|
+
return # Already at HEAD, skip heavy imports
|
|
38
|
+
except sqlite3.OperationalError:
|
|
39
|
+
pass # Table doesn't exist yet — need full migration
|
|
40
|
+
|
|
41
|
+
# Slow path: need to run migrations
|
|
42
|
+
from sqlalchemy import create_engine, event, pool
|
|
43
|
+
|
|
44
|
+
from traceforge.migrations.runner import run_migrations
|
|
45
|
+
|
|
46
|
+
# Wrap the existing sqlite3 connection in a SQLAlchemy engine so Alembic
|
|
47
|
+
# can drive the migration context without opening a second connection.
|
|
48
|
+
engine = create_engine(
|
|
49
|
+
"sqlite://",
|
|
50
|
+
creator=lambda: conn,
|
|
51
|
+
poolclass=pool.StaticPool,
|
|
52
|
+
)
|
|
53
|
+
# Disable pysqlite's implicit transaction handling so Alembic controls it.
|
|
54
|
+
# We must restore the original isolation_level afterward — otherwise the
|
|
55
|
+
# raw connection is left in permanent autocommit mode, breaking the
|
|
56
|
+
# pipeline's atomic commit/rollback patterns.
|
|
57
|
+
original_isolation_level = conn.isolation_level
|
|
58
|
+
|
|
59
|
+
@event.listens_for(engine, "connect")
|
|
60
|
+
def _set_raw_connection(dbapi_conn, connection_record):
|
|
61
|
+
dbapi_conn.isolation_level = None
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with engine.connect() as sa_conn:
|
|
65
|
+
run_migrations(sa_conn)
|
|
66
|
+
finally:
|
|
67
|
+
conn.isolation_level = original_isolation_level
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SystemStore:
|
|
71
|
+
"""SQLite-backed persistence for governance state.
|
|
72
|
+
|
|
73
|
+
On construction, runs Alembic migrations to ensure the schema is at HEAD.
|
|
74
|
+
|
|
75
|
+
Single-writer guarantee
|
|
76
|
+
-----------------------
|
|
77
|
+
The store is a *synchronous* durability layer over a single ``sqlite3``
|
|
78
|
+
connection opened with ``check_same_thread=False``. The pipeline is asyncio
|
|
79
|
+
single-threaded and offloads store writes to a worker thread (via
|
|
80
|
+
``asyncio.to_thread``), so writes can originate on *different* OS threads even
|
|
81
|
+
though they are never logically concurrent per session. An ``asyncio.Queue``
|
|
82
|
+
or ``asyncio.Lock`` cannot serialize work that runs off-loop on arbitrary
|
|
83
|
+
threads, so the correct primitive for this sync store is a
|
|
84
|
+
:class:`threading.Lock` — not a second, async concurrency primitive.
|
|
85
|
+
|
|
86
|
+
Every mutation is serialized by :attr:`_lock`. Single-statement writes take
|
|
87
|
+
the lock around ``execute`` + ``commit``. Multi-statement transactions MUST
|
|
88
|
+
go through :meth:`write_transaction`, which holds the lock for the *entire*
|
|
89
|
+
transaction (not just the terminal ``commit``), guaranteeing that no two
|
|
90
|
+
writers ever interleave statements on the shared connection. This is the one
|
|
91
|
+
and only write-serialization mechanism; callers must never open a second one.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
95
|
+
self._db_path = Path(db_path) if str(db_path) != ":memory:" else Path(":memory:")
|
|
96
|
+
if str(db_path) != ":memory:":
|
|
97
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
self._processed_cache: dict[str, str | None] = {}
|
|
101
|
+
|
|
102
|
+
# Apply WAL and pragmas before migrations
|
|
103
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
104
|
+
self._conn.execute("PRAGMA busy_timeout = 5000")
|
|
105
|
+
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
106
|
+
|
|
107
|
+
# Run migrations to HEAD
|
|
108
|
+
_run_alembic(self._conn)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def connection(self) -> sqlite3.Connection:
|
|
112
|
+
return self._conn
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def lock(self):
|
|
116
|
+
"""Threading lock for callers needing multi-statement transactions."""
|
|
117
|
+
return self._lock
|
|
118
|
+
|
|
119
|
+
@contextmanager
|
|
120
|
+
def write_transaction(self) -> "Iterator[sqlite3.Connection]":
|
|
121
|
+
"""Serialize a full multi-statement write transaction under the writer lock.
|
|
122
|
+
|
|
123
|
+
This is the formal single-writer entry point for any write that spans more
|
|
124
|
+
than one statement (state persist + idempotency reservation, deferred MCP
|
|
125
|
+
writes + finalization, etc.). It:
|
|
126
|
+
|
|
127
|
+
* acquires :attr:`_lock` for the whole transaction — every statement the
|
|
128
|
+
body issues on the connection is covered, not merely the final commit —
|
|
129
|
+
so concurrent writers can never interleave on the shared connection;
|
|
130
|
+
* commits on clean exit; and
|
|
131
|
+
* rolls back and re-raises on *any* exception, leaving no dangling
|
|
132
|
+
transaction behind.
|
|
133
|
+
|
|
134
|
+
The body MUST issue its statements without re-acquiring the lock (use
|
|
135
|
+
:meth:`execute_in_transaction` / the ``*_no_commit`` helpers, which do not
|
|
136
|
+
lock). ``_lock`` is a non-reentrant :class:`threading.Lock`, so calling a
|
|
137
|
+
self-locking method (e.g. :meth:`commit`) from inside the body would
|
|
138
|
+
deadlock.
|
|
139
|
+
"""
|
|
140
|
+
with self._lock:
|
|
141
|
+
try:
|
|
142
|
+
yield self._conn
|
|
143
|
+
except BaseException:
|
|
144
|
+
self._conn.rollback()
|
|
145
|
+
raise
|
|
146
|
+
else:
|
|
147
|
+
self._conn.commit()
|
|
148
|
+
|
|
149
|
+
def is_duplicate(self, source_event_key: str) -> str | None:
|
|
150
|
+
"""Check if event was already processed. Returns cached meta JSON or None."""
|
|
151
|
+
if source_event_key in self._processed_cache:
|
|
152
|
+
return self._processed_cache[source_event_key]
|
|
153
|
+
with self._lock:
|
|
154
|
+
row = self._conn.execute(
|
|
155
|
+
"SELECT session_meta_json FROM processed_events WHERE source_event_key = ?",
|
|
156
|
+
(source_event_key,),
|
|
157
|
+
).fetchone()
|
|
158
|
+
if row:
|
|
159
|
+
self._processed_cache[source_event_key] = row[0]
|
|
160
|
+
self._evict_cache()
|
|
161
|
+
return row[0]
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def record_processed(
|
|
165
|
+
self, source_event_key: str, session_id: str, meta_json: str, processed_at: str
|
|
166
|
+
) -> None:
|
|
167
|
+
with self._lock:
|
|
168
|
+
self._conn.execute(
|
|
169
|
+
"INSERT OR IGNORE INTO processed_events (source_event_key, session_id, session_meta_json, processed_at) VALUES (?, ?, ?, ?)",
|
|
170
|
+
(source_event_key, session_id, meta_json, processed_at),
|
|
171
|
+
)
|
|
172
|
+
self._conn.commit()
|
|
173
|
+
self._processed_cache[source_event_key] = meta_json
|
|
174
|
+
self._evict_cache()
|
|
175
|
+
|
|
176
|
+
def reserve_event(self, source_event_key: str, session_id: str, processed_at: str) -> None:
|
|
177
|
+
"""Reserve an event key before state mutation to prevent double-processing on crash."""
|
|
178
|
+
with self._lock:
|
|
179
|
+
self._conn.execute(
|
|
180
|
+
"INSERT OR IGNORE INTO processed_events (source_event_key, session_id, session_meta_json, processed_at) VALUES (?, ?, ?, ?)",
|
|
181
|
+
(source_event_key, session_id, '{"reserved":true}', processed_at),
|
|
182
|
+
)
|
|
183
|
+
self._conn.commit()
|
|
184
|
+
# Mark as reserved in cache (will be overwritten by finalize)
|
|
185
|
+
self._processed_cache[source_event_key] = '{"reserved":true}'
|
|
186
|
+
self._evict_cache()
|
|
187
|
+
|
|
188
|
+
def finalize_processed(self, source_event_key: str, meta_json: str) -> None:
|
|
189
|
+
"""Update reserved event with full meta after Phase 3 completes."""
|
|
190
|
+
with self._lock:
|
|
191
|
+
self._conn.execute(
|
|
192
|
+
"UPDATE processed_events SET session_meta_json = ? WHERE source_event_key = ?",
|
|
193
|
+
(meta_json, source_event_key),
|
|
194
|
+
)
|
|
195
|
+
self._conn.commit()
|
|
196
|
+
self._processed_cache[source_event_key] = meta_json
|
|
197
|
+
|
|
198
|
+
def get_mcp_profile(self, server: str, tool_name: str) -> dict | None:
|
|
199
|
+
"""Read an MCP tool profile from the normalized tables.
|
|
200
|
+
|
|
201
|
+
Returns the scalar fingerprint fields plus ``role``/``capability``/
|
|
202
|
+
``scope`` as sorted lists reconstructed from ``mcp_profile_attributes``,
|
|
203
|
+
or ``None`` when the tool has never been registered.
|
|
204
|
+
"""
|
|
205
|
+
with self._lock:
|
|
206
|
+
row = self._conn.execute(
|
|
207
|
+
"""SELECT server, tool_name, description_hash, schema_hash, registered_effect,
|
|
208
|
+
clearance, first_seen, last_seen
|
|
209
|
+
FROM mcp_profiles WHERE server = ? AND tool_name = ?""",
|
|
210
|
+
(server, tool_name),
|
|
211
|
+
).fetchone()
|
|
212
|
+
if not row:
|
|
213
|
+
return None
|
|
214
|
+
attr_rows = self._conn.execute(
|
|
215
|
+
"SELECT attr_type, attr_value FROM mcp_profile_attributes "
|
|
216
|
+
"WHERE server = ? AND tool_name = ?",
|
|
217
|
+
(server, tool_name),
|
|
218
|
+
).fetchall()
|
|
219
|
+
attributes: dict[str, list[str]] = {"role": [], "capability": [], "scope": []}
|
|
220
|
+
for attr_type, attr_value in attr_rows:
|
|
221
|
+
attributes.setdefault(attr_type, []).append(attr_value)
|
|
222
|
+
return {
|
|
223
|
+
"server": row[0],
|
|
224
|
+
"tool_name": row[1],
|
|
225
|
+
"description_hash": row[2],
|
|
226
|
+
"schema_hash": row[3],
|
|
227
|
+
"registered_effect": row[4],
|
|
228
|
+
"clearance": row[5],
|
|
229
|
+
"first_seen": row[6],
|
|
230
|
+
"last_seen": row[7],
|
|
231
|
+
"role": sorted(attributes["role"]),
|
|
232
|
+
"capability": sorted(attributes["capability"]),
|
|
233
|
+
"scope": sorted(attributes["scope"]),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def upsert_mcp_profile(self, server: str, tool_name: str, profile: dict) -> None:
|
|
237
|
+
"""Insert MCP profile only if not already registered (preserves first-seen baseline)."""
|
|
238
|
+
with self._lock:
|
|
239
|
+
self.write_mcp_profile_no_commit(server, tool_name, profile)
|
|
240
|
+
self._conn.commit()
|
|
241
|
+
|
|
242
|
+
def write_mcp_profile_no_commit(self, server: str, tool_name: str, profile: dict) -> None:
|
|
243
|
+
"""Write an MCP profile within the caller's transaction (no lock, no commit).
|
|
244
|
+
|
|
245
|
+
Writes the scalar ``mcp_profiles`` row plus one ``mcp_profile_attributes``
|
|
246
|
+
row per role/capability/scope value. ``INSERT OR IGNORE`` on every table
|
|
247
|
+
preserves the first-seen baseline (a re-registration of an existing tool
|
|
248
|
+
is a no-op).
|
|
249
|
+
"""
|
|
250
|
+
self._conn.execute(
|
|
251
|
+
"""INSERT OR IGNORE INTO mcp_profiles
|
|
252
|
+
(server, tool_name, description_hash, schema_hash, registered_effect,
|
|
253
|
+
clearance, first_seen, last_seen)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
255
|
+
(
|
|
256
|
+
server,
|
|
257
|
+
tool_name,
|
|
258
|
+
profile["description_hash"],
|
|
259
|
+
profile["schema_hash"],
|
|
260
|
+
profile.get("registered_effect"),
|
|
261
|
+
profile.get("clearance"),
|
|
262
|
+
profile["first_seen"],
|
|
263
|
+
profile["last_seen"],
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
for attr_type in ("role", "capability", "scope"):
|
|
267
|
+
for value in profile.get(attr_type) or ():
|
|
268
|
+
self._conn.execute(
|
|
269
|
+
"""INSERT OR IGNORE INTO mcp_profile_attributes
|
|
270
|
+
(server, tool_name, attr_type, attr_value) VALUES (?, ?, ?, ?)""",
|
|
271
|
+
(server, tool_name, attr_type, value),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def update_mcp_last_seen(self, server: str, tool_name: str, last_seen: str) -> None:
|
|
275
|
+
"""Update only last_seen timestamp — preserves registered baseline."""
|
|
276
|
+
with self._lock:
|
|
277
|
+
self.write_mcp_last_seen_no_commit(server, tool_name, last_seen)
|
|
278
|
+
self._conn.commit()
|
|
279
|
+
|
|
280
|
+
def write_mcp_last_seen_no_commit(self, server: str, tool_name: str, last_seen: str) -> None:
|
|
281
|
+
"""Bump last_seen on the profile within the caller's transaction."""
|
|
282
|
+
self._conn.execute(
|
|
283
|
+
"UPDATE mcp_profiles SET last_seen = ? WHERE server = ? AND tool_name = ?",
|
|
284
|
+
(last_seen, server, tool_name),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def get_budget_counters(self, session_id: str) -> dict[str, dict[str, int]]:
|
|
288
|
+
"""Read a session's normalized dimensional budget counters.
|
|
289
|
+
|
|
290
|
+
Returns ``{dimension: {key: count}}`` reconstructed from
|
|
291
|
+
``budget_counters``. The atomic scalar budget fields (total_tool_calls,
|
|
292
|
+
etc.) are columns on ``session_state``, not part of this projection.
|
|
293
|
+
"""
|
|
294
|
+
with self._lock:
|
|
295
|
+
rows = self._conn.execute(
|
|
296
|
+
"SELECT dimension, key, count FROM budget_counters WHERE session_id = ?",
|
|
297
|
+
(session_id,),
|
|
298
|
+
).fetchall()
|
|
299
|
+
counters: dict[str, dict[str, int]] = {}
|
|
300
|
+
for dimension, key, count in rows:
|
|
301
|
+
counters.setdefault(dimension, {})[key] = count
|
|
302
|
+
return counters
|
|
303
|
+
|
|
304
|
+
def get_taint_entries(self, session_id: str) -> list[dict]:
|
|
305
|
+
"""Read a session's normalized taint ledger, ordered by append ordinal."""
|
|
306
|
+
with self._lock:
|
|
307
|
+
rows = self._conn.execute(
|
|
308
|
+
"""SELECT event_id, source_event_key, clearance, source, payload_pointer
|
|
309
|
+
FROM taint_entries WHERE session_id = ? ORDER BY ordinal""",
|
|
310
|
+
(session_id,),
|
|
311
|
+
).fetchall()
|
|
312
|
+
return [
|
|
313
|
+
{
|
|
314
|
+
"event_id": r[0],
|
|
315
|
+
"source_event_key": r[1],
|
|
316
|
+
"clearance": r[2],
|
|
317
|
+
"source": r[3],
|
|
318
|
+
"payload_pointer": r[4],
|
|
319
|
+
}
|
|
320
|
+
for r in rows
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
def get_content_hash(self, repo: str, file_path: str) -> str | None:
|
|
324
|
+
with self._lock:
|
|
325
|
+
row = self._conn.execute(
|
|
326
|
+
"SELECT sha256 FROM content_hashes WHERE repo = ? AND file_path = ?",
|
|
327
|
+
(repo, file_path),
|
|
328
|
+
).fetchone()
|
|
329
|
+
return row[0] if row else None
|
|
330
|
+
|
|
331
|
+
def store_content_hash_no_commit(
|
|
332
|
+
self, repo: str, file_path: str, sha256: str, session_id: str, updated_at: str
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Upsert a content-hash baseline within the caller's transaction (no commit).
|
|
335
|
+
|
|
336
|
+
Participates in the caller's open transaction, matching
|
|
337
|
+
:meth:`execute_in_transaction`. Used by the monitor's finalization commit so a
|
|
338
|
+
content-integrity baseline update lands atomically with the idempotency record.
|
|
339
|
+
"""
|
|
340
|
+
self._conn.execute(
|
|
341
|
+
"""INSERT OR REPLACE INTO content_hashes (repo, file_path, sha256, updated_at, updated_by_session)
|
|
342
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
343
|
+
(repo, file_path, sha256, updated_at, session_id),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def store_content_hash(
|
|
347
|
+
self, repo: str, file_path: str, sha256: str, session_id: str, updated_at: str
|
|
348
|
+
) -> None:
|
|
349
|
+
with self._lock:
|
|
350
|
+
self.store_content_hash_no_commit(repo, file_path, sha256, session_id, updated_at)
|
|
351
|
+
self._conn.commit()
|
|
352
|
+
|
|
353
|
+
def get_drift_baseline(self, agent_model: str, repo: str) -> dict | None:
|
|
354
|
+
with self._lock:
|
|
355
|
+
row = self._conn.execute(
|
|
356
|
+
"SELECT phase_counts_json, total_events FROM drift_baselines WHERE agent_model = ? AND repo = ?",
|
|
357
|
+
(agent_model, repo),
|
|
358
|
+
).fetchone()
|
|
359
|
+
if not row:
|
|
360
|
+
return None
|
|
361
|
+
return {"phase_counts": json.loads(row[0]), "total_events": row[1]}
|
|
362
|
+
|
|
363
|
+
def execute_in_transaction(self, sql: str, params: tuple = ()) -> None:
|
|
364
|
+
"""Execute SQL within the current transaction (no auto-commit). Caller must hold lock."""
|
|
365
|
+
self._conn.execute(sql, params)
|
|
366
|
+
|
|
367
|
+
def commit(self) -> None:
|
|
368
|
+
"""Commit the current transaction."""
|
|
369
|
+
with self._lock:
|
|
370
|
+
self._conn.commit()
|
|
371
|
+
|
|
372
|
+
def rollback(self) -> None:
|
|
373
|
+
"""Rollback the current transaction."""
|
|
374
|
+
with self._lock:
|
|
375
|
+
self._conn.rollback()
|
|
376
|
+
|
|
377
|
+
def cache_processed(self, source_event_key: str, meta_json: str | None) -> None:
|
|
378
|
+
"""Add entry to processed events cache."""
|
|
379
|
+
self._processed_cache[source_event_key] = meta_json
|
|
380
|
+
self._evict_cache()
|
|
381
|
+
|
|
382
|
+
def commit_deferred_mcp_writes(self, writes: "tuple") -> None:
|
|
383
|
+
"""Commit deferred MCP profile writes after pipeline finalization.
|
|
384
|
+
|
|
385
|
+
Accepts tuple[MCPDeferredWrite, ...] from MCPScanResult.
|
|
386
|
+
"""
|
|
387
|
+
for write in writes:
|
|
388
|
+
if write.kind == "upsert":
|
|
389
|
+
self.upsert_mcp_profile(write.server, write.tool_name, json.loads(write.payload))
|
|
390
|
+
elif write.kind == "last_seen":
|
|
391
|
+
self.update_mcp_last_seen(write.server, write.tool_name, write.payload)
|
|
392
|
+
|
|
393
|
+
def close(self) -> None:
|
|
394
|
+
self._conn.close()
|
|
395
|
+
|
|
396
|
+
def _evict_cache(self) -> None:
|
|
397
|
+
if len(self._processed_cache) > _LRU_CACHE_SIZE:
|
|
398
|
+
# Simple eviction: remove oldest half
|
|
399
|
+
keys = list(self._processed_cache.keys())
|
|
400
|
+
for k in keys[: len(keys) // 2]:
|
|
401
|
+
del self._processed_cache[k]
|