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
traceforge/sinks/base.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Base storage sink interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
8
|
+
|
|
9
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from traceforge.governance.envelope import EnrichedEvent
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StorageSink(ABC):
|
|
18
|
+
# Sink classes that have already been warned about dropping title updates,
|
|
19
|
+
# so the warning fires once per class per process rather than per title.
|
|
20
|
+
_title_drop_warned: ClassVar[set[str]] = set()
|
|
21
|
+
# Sink classes already warned about dropping non-event envelope payloads
|
|
22
|
+
# (gap markers / governance-only records) they do not know how to persist.
|
|
23
|
+
_enriched_drop_warned: ClassVar[set[str]] = set()
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
27
|
+
"""Handle a session event. Required for all sinks."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
async def on_enriched_event(self, enriched: "EnrichedEvent") -> None:
|
|
31
|
+
"""Handle a governance-enriched event envelope.
|
|
32
|
+
|
|
33
|
+
This is the emission entry point used when governance is wired into the
|
|
34
|
+
pipeline: the envelope pairs an event with its ``SessionMeta``. The base
|
|
35
|
+
default keeps every existing sink working unchanged — a live
|
|
36
|
+
``SessionEvent`` (which already carries its ``metadata.governance`` stamp)
|
|
37
|
+
is forwarded to :meth:`on_event`, so a sink that only implements
|
|
38
|
+
``on_event`` sees byte-identical output whether or not governance is on.
|
|
39
|
+
|
|
40
|
+
Synthetic envelope payloads that are *not* live session events — a
|
|
41
|
+
:class:`~traceforge.governance.envelope.ContextGapEvent` emitted under
|
|
42
|
+
backpressure, or a governance-only record — cannot be expressed through
|
|
43
|
+
``on_event``. Rather than silently drop them (they signal audit gaps),
|
|
44
|
+
the base logs a one-time warning naming the sink class. Sinks that want
|
|
45
|
+
to persist gaps override this method (``JsonlSink``/``SqliteOutputSink``
|
|
46
|
+
do).
|
|
47
|
+
"""
|
|
48
|
+
event = enriched.event
|
|
49
|
+
if isinstance(event, SessionEvent):
|
|
50
|
+
await self.on_event(event)
|
|
51
|
+
return
|
|
52
|
+
cls = type(self).__name__
|
|
53
|
+
if cls not in StorageSink._enriched_drop_warned:
|
|
54
|
+
StorageSink._enriched_drop_warned.add(cls)
|
|
55
|
+
logger.warning(
|
|
56
|
+
"%s does not handle non-event envelope payloads (e.g. context-gap "
|
|
57
|
+
"markers); they will be dropped. Override on_enriched_event(enriched) "
|
|
58
|
+
"to persist or forward them.",
|
|
59
|
+
cls,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
63
|
+
"""Handle a telemetry span. Default no-op."""
|
|
64
|
+
|
|
65
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
66
|
+
"""Handle a usage record. Default no-op."""
|
|
67
|
+
|
|
68
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
69
|
+
"""Handle an out-of-band title for a closed activity/step segment.
|
|
70
|
+
|
|
71
|
+
Emitted after the segment's events (which already carry its
|
|
72
|
+
``activity_id``/``step_id``); keyed to them by ``segment_id``. See
|
|
73
|
+
:class:`traceforge.types.TitleUpdate`.
|
|
74
|
+
|
|
75
|
+
Titles are the structurer's primary output, so — unlike the
|
|
76
|
+
``on_span``/``on_usage`` no-ops — the base default does **not** silently
|
|
77
|
+
drop them: it logs a one-time warning naming the sink class. A custom
|
|
78
|
+
sink keeps working without overriding this, but the author is told that
|
|
79
|
+
titles are being discarded and should override to persist them. All
|
|
80
|
+
in-repo sinks override it.
|
|
81
|
+
"""
|
|
82
|
+
cls = type(self).__name__
|
|
83
|
+
if cls not in StorageSink._title_drop_warned:
|
|
84
|
+
StorageSink._title_drop_warned.add(cls)
|
|
85
|
+
logger.warning(
|
|
86
|
+
"%s does not handle title updates; activity/step titles will be "
|
|
87
|
+
"dropped. Override on_title_update(update) to persist or forward them.",
|
|
88
|
+
cls,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def flush(self) -> None:
|
|
92
|
+
"""Flush buffered writes. Default no-op."""
|
|
93
|
+
|
|
94
|
+
async def close(self) -> None:
|
|
95
|
+
"""Clean up resources. Default no-op."""
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Callback-based storage sink for custom event handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
8
|
+
|
|
9
|
+
from traceforge.sinks.base import StorageSink
|
|
10
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
11
|
+
|
|
12
|
+
#: Accepted shapes for a lightweight event subscriber: a coroutine function or a
|
|
13
|
+
#: plain sync callable (both taking a single :class:`SessionEvent`).
|
|
14
|
+
EventCallback = Callable[[SessionEvent], Awaitable[None] | None]
|
|
15
|
+
|
|
16
|
+
#: Accepted shapes for a per-subscriber kind filter. ``None`` means "all events".
|
|
17
|
+
#: A string is an exact kind, or a ``"prefix.*"`` wildcard matching that dotted
|
|
18
|
+
#: namespace (e.g. ``"tool.*"`` matches ``tool.call``/``tool.result`` but not
|
|
19
|
+
#: ``tool`` or ``toolbar``). An iterable of such strings matches if *any* does. A
|
|
20
|
+
#: callable is used verbatim as a predicate over the event.
|
|
21
|
+
KindFilter = str | Iterable[str] | Callable[[SessionEvent], bool] | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CallbackSink(StorageSink):
|
|
25
|
+
"""A sink that delegates to user-provided async callback functions."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
on_event: Callable[[SessionEvent], Awaitable[None]] | None = None,
|
|
30
|
+
on_span: Callable[[TelemetrySpan], Awaitable[None]] | None = None,
|
|
31
|
+
on_usage: Callable[[UsageRecord], Awaitable[None]] | None = None,
|
|
32
|
+
on_title_update: Callable[[TitleUpdate], Awaitable[None]] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._on_event = on_event
|
|
35
|
+
self._on_span = on_span
|
|
36
|
+
self._on_usage = on_usage
|
|
37
|
+
self._on_title_update = on_title_update
|
|
38
|
+
|
|
39
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
40
|
+
if self._on_event is not None:
|
|
41
|
+
await self._on_event(event)
|
|
42
|
+
|
|
43
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
44
|
+
if self._on_span is not None:
|
|
45
|
+
await self._on_span(span)
|
|
46
|
+
|
|
47
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
48
|
+
if self._on_usage is not None:
|
|
49
|
+
await self._on_usage(usage)
|
|
50
|
+
|
|
51
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
52
|
+
if self._on_title_update is not None:
|
|
53
|
+
await self._on_title_update(update)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _kind_predicate(kind: KindFilter) -> Callable[[SessionEvent], bool] | None:
|
|
57
|
+
"""Compile a :data:`KindFilter` into a predicate over :class:`SessionEvent`.
|
|
58
|
+
|
|
59
|
+
Returns ``None`` (meaning "no filtering") for ``None`` or an empty iterable,
|
|
60
|
+
so the dispatch wrapper can skip the check entirely in the common case.
|
|
61
|
+
"""
|
|
62
|
+
if kind is None:
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(kind, str):
|
|
65
|
+
patterns: tuple[str, ...] = (kind,)
|
|
66
|
+
elif callable(kind):
|
|
67
|
+
return kind
|
|
68
|
+
else:
|
|
69
|
+
patterns = tuple(kind)
|
|
70
|
+
if not patterns:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def predicate(event: SessionEvent) -> bool:
|
|
74
|
+
return any(_pattern_matches(pattern, event.kind) for pattern in patterns)
|
|
75
|
+
|
|
76
|
+
return predicate
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _pattern_matches(pattern: str, kind: str) -> bool:
|
|
80
|
+
"""Match one kind string against an exact or ``"prefix.*"`` wildcard pattern."""
|
|
81
|
+
if pattern.endswith(".*"):
|
|
82
|
+
# ``"tool.*"`` -> prefix ``"tool."`` -> matches ``tool.call`` but not
|
|
83
|
+
# bare ``tool`` or an unrelated ``toolbar``.
|
|
84
|
+
return kind.startswith(pattern[:-1])
|
|
85
|
+
return kind == pattern
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def as_async_event_callback(
|
|
89
|
+
callback: EventCallback,
|
|
90
|
+
*,
|
|
91
|
+
kind: KindFilter = None,
|
|
92
|
+
to_thread: bool = False,
|
|
93
|
+
) -> Callable[[SessionEvent], Awaitable[None]]:
|
|
94
|
+
"""Adapt a sync-or-async event callback into the ``async on_event`` shape.
|
|
95
|
+
|
|
96
|
+
This is the adapter behind :meth:`~traceforge.pipeline.EventPipeline.subscribe`:
|
|
97
|
+
it lets a plain ``Callable[[SessionEvent], None]`` act as a first-class event
|
|
98
|
+
subscriber without the caller writing ``async def`` or a full sink, and it
|
|
99
|
+
applies an optional per-subscriber ``kind`` filter *before* dispatch so a
|
|
100
|
+
filtered-out event never touches the callback.
|
|
101
|
+
|
|
102
|
+
- **async callables** (coroutine functions) are awaited directly.
|
|
103
|
+
- **sync callables** are called inline on the event loop by default — right
|
|
104
|
+
for the lightweight consumers this exists for (append to a list, put on a
|
|
105
|
+
queue). Pass ``to_thread=True`` to instead run a blocking sync callback via
|
|
106
|
+
:func:`asyncio.to_thread`, so it never stalls the loop.
|
|
107
|
+
- A sync callable that unexpectedly returns an awaitable (e.g. an object whose
|
|
108
|
+
``__call__`` is ``async`` and so is not flagged by
|
|
109
|
+
:func:`asyncio.iscoroutinefunction`) is awaited defensively.
|
|
110
|
+
|
|
111
|
+
Raises ``TypeError`` if ``callback`` is not callable.
|
|
112
|
+
"""
|
|
113
|
+
if not callable(callback):
|
|
114
|
+
raise TypeError(f"event callback must be callable, got {type(callback).__name__}")
|
|
115
|
+
|
|
116
|
+
predicate = _kind_predicate(kind)
|
|
117
|
+
is_async = asyncio.iscoroutinefunction(callback)
|
|
118
|
+
|
|
119
|
+
async def on_event(event: SessionEvent) -> None:
|
|
120
|
+
if predicate is not None and not predicate(event):
|
|
121
|
+
return
|
|
122
|
+
if is_async:
|
|
123
|
+
await callback(event)
|
|
124
|
+
return
|
|
125
|
+
if to_thread:
|
|
126
|
+
await asyncio.to_thread(callback, event)
|
|
127
|
+
return
|
|
128
|
+
result = callback(event)
|
|
129
|
+
if inspect.isawaitable(result):
|
|
130
|
+
await result
|
|
131
|
+
|
|
132
|
+
return on_event
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Console sink — pretty-prints governance results to terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from traceforge.sinks.base import StorageSink
|
|
9
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# ANSI color codes
|
|
14
|
+
_RESET = "\033[0m"
|
|
15
|
+
_RED = "\033[91m"
|
|
16
|
+
_YELLOW = "\033[93m"
|
|
17
|
+
_GREEN = "\033[92m"
|
|
18
|
+
_DIM = "\033[2m"
|
|
19
|
+
_BOLD = "\033[1m"
|
|
20
|
+
|
|
21
|
+
_ACTION_COLORS = {
|
|
22
|
+
"deny": _RED,
|
|
23
|
+
"escalate": _RED,
|
|
24
|
+
"warn": _YELLOW,
|
|
25
|
+
"allow": _GREEN,
|
|
26
|
+
"monitor": _DIM,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConsoleSink(StorageSink):
|
|
31
|
+
"""Prints governance-relevant events to the terminal.
|
|
32
|
+
|
|
33
|
+
Only emits events whose governance action matches the configured filter.
|
|
34
|
+
Designed for real-time human feedback during agent sessions.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
filter_actions: list[str] | None = None,
|
|
40
|
+
color: bool = True,
|
|
41
|
+
stream: object | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._filter = set(filter_actions or ["warn", "deny", "escalate"])
|
|
44
|
+
self._color = color and hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
|
|
45
|
+
self._stream = stream or sys.stderr
|
|
46
|
+
|
|
47
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
48
|
+
meta = event.metadata
|
|
49
|
+
if meta is None:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
classification = meta.classification
|
|
53
|
+
if classification is None:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Extract governance action from metadata
|
|
57
|
+
action = self._extract_action(event)
|
|
58
|
+
if action is None or action not in self._filter:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
self._print_event(event, action)
|
|
62
|
+
|
|
63
|
+
def _extract_action(self, event: SessionEvent) -> str | None:
|
|
64
|
+
"""Extract the recommended action from event metadata."""
|
|
65
|
+
gov = event.metadata.governance if event.metadata else None
|
|
66
|
+
if gov is None:
|
|
67
|
+
return None
|
|
68
|
+
rec = gov.recommendation
|
|
69
|
+
if rec is None:
|
|
70
|
+
return None
|
|
71
|
+
return rec.recommended_action.value
|
|
72
|
+
|
|
73
|
+
def _print_event(self, event: SessionEvent, action: str) -> None:
|
|
74
|
+
color = _ACTION_COLORS.get(action, _DIM) if self._color else ""
|
|
75
|
+
reset = _RESET if self._color else ""
|
|
76
|
+
bold = _BOLD if self._color else ""
|
|
77
|
+
dim = _DIM if self._color else ""
|
|
78
|
+
|
|
79
|
+
tool_name = event.payload.get("tool_name", event.kind) if event.payload else event.kind
|
|
80
|
+
args_preview = ""
|
|
81
|
+
if event.payload:
|
|
82
|
+
args = event.payload.get("arguments") or event.payload.get("command")
|
|
83
|
+
if args:
|
|
84
|
+
args_str = str(args)
|
|
85
|
+
args_preview = f" {dim}{args_str[:80]}{'...' if len(args_str) > 80 else ''}{reset}"
|
|
86
|
+
|
|
87
|
+
risk_score = ""
|
|
88
|
+
if event.metadata and event.metadata.governance:
|
|
89
|
+
gov = event.metadata.governance
|
|
90
|
+
if gov.risk_assessment is not None:
|
|
91
|
+
risk_score = f" [risk:{gov.risk_assessment.score}]"
|
|
92
|
+
|
|
93
|
+
ts = event.timestamp.strftime("%H:%M:%S") if event.timestamp else ""
|
|
94
|
+
line = f"{dim}{ts}{reset} {color}{bold}{action.upper()}{reset} {tool_name}{risk_score}{args_preview}"
|
|
95
|
+
|
|
96
|
+
print(line, file=self._stream)
|
|
97
|
+
|
|
98
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
105
|
+
"""Render a closed segment's title as an indented TOC line."""
|
|
106
|
+
color = (_GREEN if update.kind == "activity" else _DIM) if self._color else ""
|
|
107
|
+
reset = _RESET if self._color else ""
|
|
108
|
+
bold = _BOLD if self._color else ""
|
|
109
|
+
indent = "" if update.kind == "activity" else " "
|
|
110
|
+
label = update.kind.upper()
|
|
111
|
+
line = f"{indent}{color}{bold}{label}{reset} {update.title}"
|
|
112
|
+
print(line, file=self._stream)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Single factory mapping declarative ``SinkConfig`` unions to sink instances.
|
|
2
|
+
|
|
3
|
+
One place builds standard sinks from config so the CLI daemon
|
|
4
|
+
(:func:`traceforge.cli.watch._build_sinks`) and the SDK
|
|
5
|
+
(:meth:`traceforge.sdk.Pipeline.from_config`) stay in parity — the exact
|
|
6
|
+
asymmetry issue #116 closes.
|
|
7
|
+
|
|
8
|
+
Optional-dependency sinks import lazily *per branch* (e.g. ``boto3`` for S3),
|
|
9
|
+
so a config that never mentions S3 never imports boto3.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from traceforge.config.models import SinkConfig
|
|
19
|
+
from traceforge.sinks.base import StorageSink
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_sinks(sink_configs: "list[SinkConfig]") -> "list[StorageSink]":
|
|
25
|
+
"""Instantiate the standard sinks declared by a ``SinkConfig`` list.
|
|
26
|
+
|
|
27
|
+
Covers the full serializable union (``sqlite``, ``jsonl``, ``console``,
|
|
28
|
+
``webhook``, ``otel``, ``s3``); each config field maps onto its sink
|
|
29
|
+
constructor faithfully. Callback / custom :class:`StorageSink` subclasses are
|
|
30
|
+
code-only and intentionally have no ``SinkConfig`` variant, so they are not
|
|
31
|
+
built here.
|
|
32
|
+
|
|
33
|
+
Unknown ``type`` discriminators are logged and skipped. This cannot happen
|
|
34
|
+
for a validated :data:`~traceforge.config.models.SinkConfig` (the union is
|
|
35
|
+
exhaustive), but keeping the branch total means a malformed duck-typed config
|
|
36
|
+
degrades gracefully instead of raising.
|
|
37
|
+
"""
|
|
38
|
+
sinks: list = []
|
|
39
|
+
for sink_config in sink_configs:
|
|
40
|
+
sink_type = getattr(sink_config, "type", None)
|
|
41
|
+
|
|
42
|
+
if sink_type == "console":
|
|
43
|
+
from traceforge.sinks.console import ConsoleSink
|
|
44
|
+
|
|
45
|
+
sinks.append(ConsoleSink(filter_actions=sink_config.filter, color=sink_config.color))
|
|
46
|
+
elif sink_type == "sqlite":
|
|
47
|
+
from traceforge.sinks.sqlite_output import SqliteOutputSink
|
|
48
|
+
|
|
49
|
+
sinks.append(
|
|
50
|
+
SqliteOutputSink(path=sink_config.path, journal_mode=sink_config.journal_mode)
|
|
51
|
+
)
|
|
52
|
+
elif sink_type == "jsonl":
|
|
53
|
+
from traceforge.sinks.jsonl import JsonlSink
|
|
54
|
+
|
|
55
|
+
sinks.append(
|
|
56
|
+
JsonlSink(path=sink_config.path, rotate_size_mb=sink_config.rotate_size_mb)
|
|
57
|
+
)
|
|
58
|
+
elif sink_type == "webhook":
|
|
59
|
+
from traceforge.sinks.webhook import WebhookSink
|
|
60
|
+
|
|
61
|
+
sinks.append(
|
|
62
|
+
WebhookSink(
|
|
63
|
+
url=sink_config.url,
|
|
64
|
+
filter_actions=sink_config.filter,
|
|
65
|
+
timeout=sink_config.timeout,
|
|
66
|
+
max_retries=sink_config.max_retries,
|
|
67
|
+
headers=sink_config.headers,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
elif sink_type == "otel":
|
|
71
|
+
from traceforge.sinks.otel_exporter import OtelExporterSink
|
|
72
|
+
|
|
73
|
+
sinks.append(
|
|
74
|
+
OtelExporterSink(
|
|
75
|
+
endpoint=sink_config.endpoint,
|
|
76
|
+
service_name=sink_config.service_name,
|
|
77
|
+
headers=sink_config.headers,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
elif sink_type == "s3":
|
|
81
|
+
from traceforge.sinks.s3 import S3Sink
|
|
82
|
+
|
|
83
|
+
sinks.append(
|
|
84
|
+
S3Sink(
|
|
85
|
+
bucket=sink_config.bucket,
|
|
86
|
+
prefix=sink_config.prefix,
|
|
87
|
+
region=sink_config.region,
|
|
88
|
+
endpoint_url=sink_config.endpoint_url,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
logger.warning("Unknown sink type %r; skipping", sink_type)
|
|
93
|
+
|
|
94
|
+
return sinks
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""JSONL file sink — append one JSON line per event."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from traceforge.sinks.base import StorageSink
|
|
10
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JsonlSink(StorageSink):
|
|
16
|
+
"""Appends events as JSON lines to a file.
|
|
17
|
+
|
|
18
|
+
Supports ``{session_id}`` template in path for per-session output files.
|
|
19
|
+
Creates parent directories on first write.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: str | Path, rotate_size_mb: float | None = None) -> None:
|
|
23
|
+
self._path_template = str(path)
|
|
24
|
+
self._rotate_size_mb = rotate_size_mb
|
|
25
|
+
self._handles: dict[str, object] = {}
|
|
26
|
+
|
|
27
|
+
_SAFE_SESSION_RE = __import__("re").compile(r"[^a-zA-Z0-9_\-.]")
|
|
28
|
+
|
|
29
|
+
def _resolve_path(self, session_id: str) -> Path:
|
|
30
|
+
sanitized = self._SAFE_SESSION_RE.sub("_", session_id)[:128]
|
|
31
|
+
resolved = self._path_template.replace("{session_id}", sanitized)
|
|
32
|
+
path = Path(resolved).expanduser().resolve()
|
|
33
|
+
# Ensure resolved path stays under the template's parent directory
|
|
34
|
+
base = Path(self._path_template.split("{session_id}")[0]).expanduser().resolve()
|
|
35
|
+
if not str(path).startswith(str(base)):
|
|
36
|
+
raise ValueError(f"JsonlSink: resolved path escapes base directory: {path}")
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
40
|
+
path = self._resolve_path(event.session_id)
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
line = json.dumps(
|
|
44
|
+
{
|
|
45
|
+
"id": event.id,
|
|
46
|
+
"kind": event.kind,
|
|
47
|
+
"session_id": event.session_id,
|
|
48
|
+
"timestamp": event.timestamp.isoformat(),
|
|
49
|
+
"payload": event.payload,
|
|
50
|
+
"metadata": event.metadata.model_dump(exclude_none=True)
|
|
51
|
+
if event.metadata
|
|
52
|
+
else None,
|
|
53
|
+
},
|
|
54
|
+
default=str,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
59
|
+
f.write(line + "\n")
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
logger.error("JsonlSink: failed to write to %s: %s", path, exc)
|
|
62
|
+
|
|
63
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
async def on_enriched_event(self, enriched) -> None:
|
|
70
|
+
"""Persist a governance envelope. Live events keep byte-identical output;
|
|
71
|
+
context-gap markers are written as a tagged ``context_gap`` record."""
|
|
72
|
+
from traceforge.governance.envelope import ContextGapEvent
|
|
73
|
+
|
|
74
|
+
gap = enriched.event
|
|
75
|
+
if not isinstance(gap, ContextGapEvent):
|
|
76
|
+
await super().on_enriched_event(enriched)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
path = self._resolve_path(gap.session_id)
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
line = json.dumps(
|
|
83
|
+
{
|
|
84
|
+
"record": "context_gap",
|
|
85
|
+
"id": gap.id,
|
|
86
|
+
"session_id": gap.session_id,
|
|
87
|
+
"timestamp": gap.timestamp.isoformat(),
|
|
88
|
+
"dropped_count": gap.dropped_count,
|
|
89
|
+
"first_dropped_sequence": gap.first_dropped_sequence,
|
|
90
|
+
"last_dropped_sequence": gap.last_dropped_sequence,
|
|
91
|
+
"gap_ordinal": gap.gap_ordinal,
|
|
92
|
+
"reason": gap.reason,
|
|
93
|
+
"source_event_key": gap.source_event_key,
|
|
94
|
+
},
|
|
95
|
+
default=str,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
100
|
+
f.write(line + "\n")
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
logger.error("JsonlSink: failed to write to %s: %s", path, exc)
|
|
103
|
+
|
|
104
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
105
|
+
path = self._resolve_path(update.session_id)
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
line = json.dumps(
|
|
109
|
+
{
|
|
110
|
+
"record": "title_update",
|
|
111
|
+
"session_id": update.session_id,
|
|
112
|
+
"segment_id": update.segment_id,
|
|
113
|
+
"kind": update.kind,
|
|
114
|
+
"title": update.title,
|
|
115
|
+
"version": update.version,
|
|
116
|
+
"parent_id": update.parent_id,
|
|
117
|
+
},
|
|
118
|
+
default=str,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
123
|
+
f.write(line + "\n")
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
logger.error("JsonlSink: failed to write to %s: %s", path, exc)
|