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/cli/runner.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Pipeline runner — resolves detected frameworks into runnable pipelines and multiplexes sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import AsyncIterator
|
|
10
|
+
|
|
11
|
+
from traceforge.config.models import (
|
|
12
|
+
ConsoleSinkConfig,
|
|
13
|
+
MappedJsonAdapterConfig,
|
|
14
|
+
SqliteSinkConfig,
|
|
15
|
+
)
|
|
16
|
+
from traceforge.sources.auto_detect import DetectedFramework
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ─── Mapping loader ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
_BUNDLED_MAPPINGS_DIR = Path(__file__).resolve().parent.parent / "mappings"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_mapping_path(name: str) -> Path:
|
|
27
|
+
"""Resolve a mapping name to its YAML file path.
|
|
28
|
+
|
|
29
|
+
Searches bundled mappings directory. Raises FileNotFoundError if not found.
|
|
30
|
+
"""
|
|
31
|
+
path = _BUNDLED_MAPPINGS_DIR / f"{name}.yaml"
|
|
32
|
+
if path.exists():
|
|
33
|
+
return path
|
|
34
|
+
raise FileNotFoundError(f"No mapping found for {name!r} (searched {_BUNDLED_MAPPINGS_DIR})")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ─── Adapter resolution ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
ADAPTER_MAP: dict[str, MappedJsonAdapterConfig] = {
|
|
40
|
+
"claude": MappedJsonAdapterConfig(type="mapped_json", mapping="claude"),
|
|
41
|
+
"codex": MappedJsonAdapterConfig(type="mapped_json", mapping="codex"),
|
|
42
|
+
"continue": MappedJsonAdapterConfig(type="mapped_json", mapping="continue_dev"),
|
|
43
|
+
"cline": MappedJsonAdapterConfig(type="mapped_json", mapping="cline"),
|
|
44
|
+
"goose": MappedJsonAdapterConfig(type="mapped_json", mapping="goose"),
|
|
45
|
+
"amazonq": MappedJsonAdapterConfig(type="mapped_json", mapping="amazonq"),
|
|
46
|
+
"aider_markdown": MappedJsonAdapterConfig(type="mapped_json", mapping="aider"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ─── Resolved pipeline config ───────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ResolvedPipeline:
|
|
55
|
+
"""A fully-resolved pipeline ready to run."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
source_path: Path
|
|
59
|
+
ingestion_mode: str
|
|
60
|
+
adapter: MappedJsonAdapterConfig
|
|
61
|
+
sinks: list = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_pipelines(
|
|
65
|
+
detected: list[DetectedFramework],
|
|
66
|
+
explicit_source_paths: set[str] | None = None,
|
|
67
|
+
default_sinks: list | None = None,
|
|
68
|
+
) -> list[ResolvedPipeline]:
|
|
69
|
+
"""Map detected frameworks to runnable pipeline configs.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
detected: Frameworks found by auto-detection.
|
|
73
|
+
explicit_source_paths: Paths already covered by explicit pipeline configs.
|
|
74
|
+
Auto-detected frameworks at these paths are skipped (explicit wins).
|
|
75
|
+
default_sinks: Sink configs to attach when none specified.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of resolved pipelines ready to instantiate.
|
|
79
|
+
"""
|
|
80
|
+
explicit = explicit_source_paths or set()
|
|
81
|
+
sinks = default_sinks if default_sinks is not None else _default_sinks()
|
|
82
|
+
pipelines: list[ResolvedPipeline] = []
|
|
83
|
+
|
|
84
|
+
for fw in detected:
|
|
85
|
+
path_key = str(fw.path)
|
|
86
|
+
if path_key in explicit:
|
|
87
|
+
logger.debug("Skipping auto-detected %s (explicit pipeline exists)", fw.name)
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
adapter = ADAPTER_MAP.get(fw.adapter)
|
|
91
|
+
if adapter is None:
|
|
92
|
+
logger.warning("No adapter mapping for framework %r (adapter=%r)", fw.name, fw.adapter)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
pipelines.append(
|
|
96
|
+
ResolvedPipeline(
|
|
97
|
+
name=fw.name,
|
|
98
|
+
source_path=fw.path,
|
|
99
|
+
ingestion_mode=fw.ingestion_mode,
|
|
100
|
+
adapter=adapter,
|
|
101
|
+
sinks=sinks,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return pipelines
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _default_sinks() -> list:
|
|
109
|
+
"""Default sinks when user hasn't configured any."""
|
|
110
|
+
return [
|
|
111
|
+
SqliteSinkConfig(type="sqlite", path=Path.home() / ".traceforge" / "traceforge.db"),
|
|
112
|
+
ConsoleSinkConfig(type="console"),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ─── Source multiplexer ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def watch_jsonl_file(path: Path, start_at: str = "end") -> AsyncIterator[str]:
|
|
120
|
+
"""Watch a JSONL file for new lines using polling.
|
|
121
|
+
|
|
122
|
+
Yields raw JSON line strings as they appear.
|
|
123
|
+
"""
|
|
124
|
+
if not path.exists():
|
|
125
|
+
logger.info("Waiting for file to appear: %s", path)
|
|
126
|
+
while not path.exists():
|
|
127
|
+
await asyncio.sleep(1.0)
|
|
128
|
+
|
|
129
|
+
offset = path.stat().st_size if start_at == "end" else 0
|
|
130
|
+
|
|
131
|
+
while True:
|
|
132
|
+
current_size = path.stat().st_size
|
|
133
|
+
if current_size > offset:
|
|
134
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
135
|
+
f.seek(offset)
|
|
136
|
+
for line in f:
|
|
137
|
+
stripped = line.strip()
|
|
138
|
+
if stripped:
|
|
139
|
+
yield stripped
|
|
140
|
+
offset = f.tell()
|
|
141
|
+
await asyncio.sleep(0.5)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def watch_directory(
|
|
145
|
+
directory: Path, pattern: str = "*.jsonl", start_at: str = "end"
|
|
146
|
+
) -> AsyncIterator[tuple[Path, str]]:
|
|
147
|
+
"""Watch a directory for new JSONL files and yield (path, line) tuples.
|
|
148
|
+
|
|
149
|
+
Monitors existing files for growth and picks up new files as they appear.
|
|
150
|
+
"""
|
|
151
|
+
known_files: dict[Path, int] = {}
|
|
152
|
+
|
|
153
|
+
# Initial scan
|
|
154
|
+
if directory.exists():
|
|
155
|
+
for f in directory.rglob(pattern):
|
|
156
|
+
if f.is_file():
|
|
157
|
+
known_files[f] = f.stat().st_size if start_at == "end" else 0
|
|
158
|
+
|
|
159
|
+
while True:
|
|
160
|
+
# Check for new files
|
|
161
|
+
if directory.exists():
|
|
162
|
+
for f in directory.rglob(pattern):
|
|
163
|
+
if f.is_file() and f not in known_files:
|
|
164
|
+
known_files[f] = 0
|
|
165
|
+
logger.info("Discovered new file: %s", f)
|
|
166
|
+
|
|
167
|
+
# Read new content from all tracked files
|
|
168
|
+
for fpath, offset in list(known_files.items()):
|
|
169
|
+
if not fpath.exists():
|
|
170
|
+
continue
|
|
171
|
+
current_size = fpath.stat().st_size
|
|
172
|
+
if current_size > offset:
|
|
173
|
+
with open(fpath, "r", encoding="utf-8") as fh:
|
|
174
|
+
fh.seek(offset)
|
|
175
|
+
for line in fh:
|
|
176
|
+
stripped = line.strip()
|
|
177
|
+
if stripped:
|
|
178
|
+
yield (fpath, stripped)
|
|
179
|
+
known_files[fpath] = fh.tell()
|
|
180
|
+
|
|
181
|
+
await asyncio.sleep(1.0)
|
traceforge/cli/score.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Score API — lightweight HTTP server for preflight tool-call scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from traceforge.governance.pipeline import GovernancePipeline
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ScoreHandler(BaseHTTPRequestHandler):
|
|
20
|
+
"""HTTP handler for POST /score endpoint."""
|
|
21
|
+
|
|
22
|
+
pipeline: "GovernancePipeline" # set on class before server starts
|
|
23
|
+
|
|
24
|
+
def do_POST(self) -> None:
|
|
25
|
+
if self.path != "/score":
|
|
26
|
+
self._respond(404, {"error": "not found"})
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
30
|
+
if content_length == 0:
|
|
31
|
+
self._respond(400, {"error": "empty body"})
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
body = json.loads(self.rfile.read(content_length))
|
|
36
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
37
|
+
self._respond(400, {"error": f"invalid JSON: {exc}"})
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Required fields
|
|
41
|
+
tool_name = body.get("tool_name")
|
|
42
|
+
arguments = body.get("arguments")
|
|
43
|
+
session_id = body.get("session_id", "anonymous")
|
|
44
|
+
|
|
45
|
+
if not tool_name:
|
|
46
|
+
self._respond(400, {"error": "missing required field: tool_name"})
|
|
47
|
+
return
|
|
48
|
+
if arguments is None:
|
|
49
|
+
self._respond(400, {"error": "missing required field: arguments"})
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
# Ensure arguments is a dict for tool_input
|
|
53
|
+
if isinstance(arguments, str):
|
|
54
|
+
try:
|
|
55
|
+
arguments = json.loads(arguments)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
arguments = {"raw": arguments}
|
|
58
|
+
|
|
59
|
+
# Build payload dict matching score_tool_call's expected shape
|
|
60
|
+
score_payload = {
|
|
61
|
+
"tool_name": tool_name,
|
|
62
|
+
"tool_input": arguments,
|
|
63
|
+
"session_id": session_id,
|
|
64
|
+
}
|
|
65
|
+
if body.get("server"):
|
|
66
|
+
score_payload["server"] = body["server"]
|
|
67
|
+
if body.get("effect"):
|
|
68
|
+
score_payload["effect"] = body["effect"]
|
|
69
|
+
if body.get("role"):
|
|
70
|
+
score_payload["role"] = body["role"]
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
result = self.pipeline.score_tool_call(score_payload)
|
|
74
|
+
self._respond(200, _serialize_session_meta(result))
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
logger.exception("Score API error")
|
|
77
|
+
self._respond(500, {"error": str(exc)})
|
|
78
|
+
|
|
79
|
+
def do_GET(self) -> None:
|
|
80
|
+
if self.path == "/health":
|
|
81
|
+
self._respond(200, {"status": "ok"})
|
|
82
|
+
else:
|
|
83
|
+
self._respond(404, {"error": "not found"})
|
|
84
|
+
|
|
85
|
+
def _respond(self, status: int, body: dict) -> None:
|
|
86
|
+
payload = json.dumps(body, default=str).encode("utf-8")
|
|
87
|
+
self.send_response(status)
|
|
88
|
+
self.send_header("Content-Type", "application/json")
|
|
89
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(payload)
|
|
92
|
+
|
|
93
|
+
def log_message(self, format: str, *args) -> None: # noqa: A002
|
|
94
|
+
logger.debug("ScoreAPI: %s", format % args)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _serialize_session_meta(meta) -> dict:
|
|
98
|
+
"""Serialize SessionMeta or EventTrace to JSON-safe dict."""
|
|
99
|
+
from traceforge.governance.results import SessionMeta
|
|
100
|
+
from traceforge.trace import EventTrace
|
|
101
|
+
|
|
102
|
+
if isinstance(meta, EventTrace):
|
|
103
|
+
result: dict = {}
|
|
104
|
+
if meta.risk_score is not None:
|
|
105
|
+
result["risk_assessment"] = {
|
|
106
|
+
"score": meta.risk_score,
|
|
107
|
+
"level": meta.risk_band,
|
|
108
|
+
}
|
|
109
|
+
if meta.suggested_action is not None:
|
|
110
|
+
result["recommendation"] = {
|
|
111
|
+
"action": meta.suggested_action,
|
|
112
|
+
"reason_code": meta.reason,
|
|
113
|
+
}
|
|
114
|
+
if meta.mechanism or meta.effect:
|
|
115
|
+
result["evidence"] = {
|
|
116
|
+
"canonical_tool": meta.canonical_tool,
|
|
117
|
+
"mechanism": meta.mechanism,
|
|
118
|
+
"effect": meta.effect,
|
|
119
|
+
"scope": list(meta.scope) if meta.scope else [],
|
|
120
|
+
"role": list(meta.role) if meta.role else [],
|
|
121
|
+
}
|
|
122
|
+
result["stage"] = meta.stage
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
if isinstance(meta, SessionMeta):
|
|
126
|
+
result = {}
|
|
127
|
+
if meta.risk_assessment is not None:
|
|
128
|
+
result["risk_assessment"] = {
|
|
129
|
+
"score": meta.risk_assessment.score,
|
|
130
|
+
"level": meta.risk_assessment.level,
|
|
131
|
+
"confidence": meta.risk_assessment.confidence,
|
|
132
|
+
}
|
|
133
|
+
if meta.recommendation is not None:
|
|
134
|
+
result["recommendation"] = {
|
|
135
|
+
"action": meta.recommendation.recommended_action.value,
|
|
136
|
+
"reason_code": meta.recommendation.reason_code,
|
|
137
|
+
"canonical_id": meta.recommendation.canonical_id,
|
|
138
|
+
}
|
|
139
|
+
if meta.evidence is not None:
|
|
140
|
+
e = meta.evidence
|
|
141
|
+
result["evidence"] = {
|
|
142
|
+
"canonical_id": e.canonical_id,
|
|
143
|
+
"mechanism": e.mechanism,
|
|
144
|
+
"effect": e.effect,
|
|
145
|
+
"scope": list(e.scope) if e.scope else [],
|
|
146
|
+
"role": list(e.role) if e.role else [],
|
|
147
|
+
}
|
|
148
|
+
return result
|
|
149
|
+
# Fallback
|
|
150
|
+
return {"raw": str(meta)}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ScoreServer:
|
|
154
|
+
"""Wraps ThreadingHTTPServer for the Score API."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, pipeline: "GovernancePipeline", listen: str = "localhost:7331") -> None:
|
|
157
|
+
self._pipeline = pipeline
|
|
158
|
+
host, _, port_str = listen.rpartition(":")
|
|
159
|
+
self._host = host or "localhost"
|
|
160
|
+
self._port = int(port_str) if port_str else 7331
|
|
161
|
+
self._server: ThreadingHTTPServer | None = None
|
|
162
|
+
self._thread: threading.Thread | None = None
|
|
163
|
+
|
|
164
|
+
def start_background(self) -> None:
|
|
165
|
+
"""Start the Score API in a daemon thread."""
|
|
166
|
+
_ScoreHandler.pipeline = self._pipeline
|
|
167
|
+
self._server = ThreadingHTTPServer((self._host, self._port), _ScoreHandler)
|
|
168
|
+
self._thread = threading.Thread(
|
|
169
|
+
target=self._server.serve_forever,
|
|
170
|
+
name="traceforge-score-api",
|
|
171
|
+
daemon=True,
|
|
172
|
+
)
|
|
173
|
+
self._thread.start()
|
|
174
|
+
logger.info("Score API listening on %s:%d", self._host, self._port)
|
|
175
|
+
|
|
176
|
+
def stop(self) -> None:
|
|
177
|
+
"""Shut down the Score API server."""
|
|
178
|
+
if self._server:
|
|
179
|
+
self._server.shutdown()
|
|
180
|
+
self._server = None
|
|
181
|
+
if self._thread:
|
|
182
|
+
self._thread.join(timeout=5.0)
|
|
183
|
+
self._thread = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ─── CLI command ─────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@click.command()
|
|
190
|
+
@click.option(
|
|
191
|
+
"--listen", default="localhost:7331", help="Host:port to bind (default: localhost:7331)."
|
|
192
|
+
)
|
|
193
|
+
@click.option("--config", "config_path", type=click.Path(exists=True), default=None)
|
|
194
|
+
def score(listen: str, config_path: str | None) -> None:
|
|
195
|
+
"""Run the Score API server standalone (no file watching)."""
|
|
196
|
+
from pathlib import Path
|
|
197
|
+
|
|
198
|
+
from traceforge.cli.factory import create_default_pipeline
|
|
199
|
+
from traceforge.governance.persistence import SystemStore
|
|
200
|
+
|
|
201
|
+
db_path = Path.home() / ".traceforge" / "system.db"
|
|
202
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
store = SystemStore(db_path)
|
|
204
|
+
|
|
205
|
+
pipeline = create_default_pipeline(store)
|
|
206
|
+
|
|
207
|
+
server = ScoreServer(pipeline, listen=listen)
|
|
208
|
+
click.echo(f"Starting Score API on {listen} ...")
|
|
209
|
+
server.start_background()
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
# Block until Ctrl+C
|
|
213
|
+
threading.Event().wait()
|
|
214
|
+
except KeyboardInterrupt:
|
|
215
|
+
click.echo("\nShutting down...")
|
|
216
|
+
server.stop()
|
|
217
|
+
store.close()
|
traceforge/cli/status.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Status command — show traceforge system state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_DB_PATH = Path.home() / ".traceforge" / "system.db"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.option("--json-output", "as_json", is_flag=True, help="Output as JSON.")
|
|
18
|
+
@click.option("--db", "db_path", type=click.Path(), default=None, help="Override DB path.")
|
|
19
|
+
def status(as_json: bool, db_path: str | None) -> None:
|
|
20
|
+
"""Show traceforge system state from the governance database."""
|
|
21
|
+
path = Path(db_path) if db_path else _DB_PATH
|
|
22
|
+
if not path.exists():
|
|
23
|
+
click.echo("No system database found. Has traceforge run yet?")
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
conn = sqlite3.connect(str(path), check_same_thread=False)
|
|
27
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
28
|
+
|
|
29
|
+
stats: dict = {}
|
|
30
|
+
|
|
31
|
+
# Session count
|
|
32
|
+
row = conn.execute("SELECT COUNT(*) FROM session_state").fetchone()
|
|
33
|
+
stats["active_sessions"] = row[0] if row else 0
|
|
34
|
+
|
|
35
|
+
# Processed events
|
|
36
|
+
row = conn.execute("SELECT COUNT(*) FROM processed_events").fetchone()
|
|
37
|
+
stats["processed_events"] = row[0] if row else 0
|
|
38
|
+
|
|
39
|
+
# MCP tool profiles
|
|
40
|
+
row = conn.execute("SELECT COUNT(*) FROM mcp_profiles").fetchone()
|
|
41
|
+
stats["mcp_profiles"] = row[0] if row else 0
|
|
42
|
+
|
|
43
|
+
# Session summaries
|
|
44
|
+
row = conn.execute("SELECT COUNT(*) FROM session_summaries").fetchone()
|
|
45
|
+
stats["completed_sessions"] = row[0] if row else 0
|
|
46
|
+
|
|
47
|
+
# Recent recommendations breakdown
|
|
48
|
+
row = conn.execute(
|
|
49
|
+
"SELECT recommendation_counts_json FROM session_summaries ORDER BY ended_at DESC LIMIT 1"
|
|
50
|
+
).fetchone()
|
|
51
|
+
stats["last_session_recommendations"] = json.loads(row[0]) if row and row[0] else None
|
|
52
|
+
|
|
53
|
+
conn.close()
|
|
54
|
+
|
|
55
|
+
if as_json:
|
|
56
|
+
click.echo(json.dumps(stats, indent=2))
|
|
57
|
+
else:
|
|
58
|
+
click.echo("Traceforge System Status")
|
|
59
|
+
click.echo("─" * 40)
|
|
60
|
+
click.echo(f" Active sessions: {stats['active_sessions']}")
|
|
61
|
+
click.echo(f" Processed events: {stats['processed_events']}")
|
|
62
|
+
click.echo(f" MCP profiles: {stats['mcp_profiles']}")
|
|
63
|
+
click.echo(f" Completed sessions: {stats['completed_sessions']}")
|
|
64
|
+
if stats["last_session_recommendations"]:
|
|
65
|
+
click.echo(f" Last session recs: {stats['last_session_recommendations']}")
|