batho 0.1.4__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.
- batho/__init__.py +55 -0
- batho/bridge/__init__.py +42 -0
- batho/bridge/artifact_loader.py +214 -0
- batho/bridge/constants.py +79 -0
- batho/bridge/http_api.py +383 -0
- batho/bridge/mcp_server.py +159 -0
- batho/bridge/models.py +102 -0
- batho/bridge/registry_client.py +221 -0
- batho/bsg/__init__.py +31 -0
- batho/bsg/plugins/foundation/bsg_detection_cicd.yaml +87 -0
- batho/bsg/plugins/foundation/bsg_detection_cloud_providers.yaml +100 -0
- batho/bsg/plugins/foundation/bsg_detection_cpp.yaml +110 -0
- batho/bsg/plugins/foundation/bsg_detection_csharp.yaml +130 -0
- batho/bsg/plugins/foundation/bsg_detection_dart.yaml +89 -0
- batho/bsg/plugins/foundation/bsg_detection_elixir.yaml +92 -0
- batho/bsg/plugins/foundation/bsg_detection_foundation.yaml +268 -0
- batho/bsg/plugins/foundation/bsg_detection_kotlin.yaml +79 -0
- batho/bsg/plugins/foundation/bsg_detection_php.yaml +135 -0
- batho/bsg/plugins/foundation/bsg_detection_ruby.yaml +100 -0
- batho/bsg/plugins/foundation/bsg_detection_scala.yaml +93 -0
- batho/bsg/plugins/foundation/bsg_detection_swift.yaml +119 -0
- batho/bsg/plugins/foundation/bsg_detection_test_frameworks.yaml +130 -0
- batho/bsg/plugins/foundation/bsg_file_categorization.yaml +320 -0
- batho/bsg/plugins/foundation/bsg_framework_angular.yaml +40 -0
- batho/bsg/plugins/foundation/bsg_framework_django.yaml +44 -0
- batho/bsg/plugins/foundation/bsg_framework_flask.yaml +44 -0
- batho/bsg/plugins/foundation/bsg_framework_nodejs.yaml +378 -0
- batho/bsg/plugins/foundation/bsg_framework_other.yaml +278 -0
- batho/bsg/plugins/foundation/bsg_framework_python.yaml +249 -0
- batho/bsg/plugins/foundation/bsg_framework_react.yaml +92 -0
- batho/bsg/plugins/foundation/bsg_framework_vue.yaml +58 -0
- batho/bsg/plugins/foundation/bsg_graph_foundation.yaml +109 -0
- batho/bsg/plugins/foundation/bsg_token_optimization.yaml +184 -0
- batho/bsg/plugins/interceptors/bsg_api_contract_guardian.yaml +27 -0
- batho/bsg/plugins/interceptors/bsg_auth_boundary_shield.yaml +29 -0
- batho/bsg/plugins/interceptors/bsg_dependency_blast_radius.yaml +28 -0
- batho/bsg/plugins/interceptors/bsg_hardcoded_secret_catcher.yaml +30 -0
- batho/bsg/plugins/interceptors/bsg_iac_drift_sentinel.yaml +29 -0
- batho/bsg/plugins/interceptors/bsg_nplus1_query_catcher.yaml +29 -0
- batho/bsg/plugins/interceptors/bsg_resource_leak_preventer.yaml +27 -0
- batho/bsg/plugins/interceptors/bsg_schema_migration_enforcer.yaml +23 -0
- batho/bsg/plugins/interceptors/bsg_silent_failure_catcher.yaml +23 -0
- batho/bsg/plugins_cli.py +263 -0
- batho/bsg/rules.py +3268 -0
- batho/bsg/schemas/bsg-plugin-schema-v1.json +429 -0
- batho/bsg/testing.py +519 -0
- batho/cli/bridge.py +295 -0
- batho/cli/dashboard.py +280 -0
- batho/cloud_sync/__init__.py +11 -0
- batho/cloud_sync/client.py +358 -0
- batho/cloud_sync/config.py +55 -0
- batho/cloud_sync/uploader.py +195 -0
- batho/config.py +1021 -0
- batho/context/__init__.py +22 -0
- batho/context/bsg.py +8 -0
- batho/context/bsg_map.py +1835 -0
- batho/context/cache.py +473 -0
- batho/context/codegraph.py +1179 -0
- batho/context/extractor.py +1004 -0
- batho/context/graph_cache.py +99 -0
- batho/context/incremental.py +198 -0
- batho/context/languages/__init__.py +148 -0
- batho/context/languages/_common.py +271 -0
- batho/context/languages/bash.py +72 -0
- batho/context/languages/c.py +61 -0
- batho/context/languages/cpp.py +87 -0
- batho/context/languages/csharp.py +86 -0
- batho/context/languages/css.py +242 -0
- batho/context/languages/dart.py +84 -0
- batho/context/languages/detector.py +578 -0
- batho/context/languages/erlang.py +76 -0
- batho/context/languages/factory.py +500 -0
- batho/context/languages/go.py +67 -0
- batho/context/languages/hack.py +83 -0
- batho/context/languages/haskell.py +87 -0
- batho/context/languages/hcl.py +357 -0
- batho/context/languages/html.py +225 -0
- batho/context/languages/java.py +69 -0
- batho/context/languages/javascript.py +78 -0
- batho/context/languages/json.py +243 -0
- batho/context/languages/julia.py +83 -0
- batho/context/languages/kotlin.py +76 -0
- batho/context/languages/lua.py +70 -0
- batho/context/languages/markdown.py +357 -0
- batho/context/languages/objectivec.py +107 -0
- batho/context/languages/ocaml.py +87 -0
- batho/context/languages/perl.py +75 -0
- batho/context/languages/php.py +74 -0
- batho/context/languages/python.py +106 -0
- batho/context/languages/r.py +63 -0
- batho/context/languages/registry.py +635 -0
- batho/context/languages/ruby.py +67 -0
- batho/context/languages/rust.py +75 -0
- batho/context/languages/scala.py +99 -0
- batho/context/languages/swift.py +94 -0
- batho/context/languages/toml.py +262 -0
- batho/context/languages/typescript.py +88 -0
- batho/context/languages/verilog.py +100 -0
- batho/context/languages/yaml.py +285 -0
- batho/context/languages/zig.py +88 -0
- batho/context/mmap_storage.py +63 -0
- batho/context/pipeline.py +381 -0
- batho/context/query.py +313 -0
- batho/context/schema.py +227 -0
- batho/context/storage.py +1727 -0
- batho/context/symbol_index.py +124 -0
- batho/dashboard/DESIGN.md +8 -0
- batho/dashboard/README.md +51 -0
- batho/dashboard/assets/css/animations.css +35 -0
- batho/dashboard/assets/css/base.css +139 -0
- batho/dashboard/assets/css/components.css +326 -0
- batho/dashboard/assets/css/graph.css +267 -0
- batho/dashboard/assets/css/tokens.css +119 -0
- batho/dashboard/assets/fonts/inter-500.woff2 +11 -0
- batho/dashboard/assets/fonts/inter-500.woff2.LICENSE +2 -0
- batho/dashboard/assets/fonts/inter-600.woff2 +11 -0
- batho/dashboard/assets/fonts/space-grotesk-500.woff2 +11 -0
- batho/dashboard/assets/fonts/space-grotesk-500.woff2.LICENSE +2 -0
- batho/dashboard/assets/fonts/space-grotesk-700.woff2 +11 -0
- batho/dashboard/assets/js/ctn-loader.js +424 -0
- batho/dashboard/assets/js/cy-import.js +34 -0
- batho/dashboard/assets/js/cy-stylesheet.js +156 -0
- batho/dashboard/assets/js/format.js +60 -0
- batho/dashboard/assets/js/glob.js +94 -0
- batho/dashboard/assets/js/graph-stream.js +158 -0
- batho/dashboard/assets/js/main.js +175 -0
- batho/dashboard/assets/js/router.js +101 -0
- batho/dashboard/assets/js/store.js +53 -0
- batho/dashboard/index.html +88 -0
- batho/dashboard/pages/files.js +275 -0
- batho/dashboard/pages/hypergraph.js +564 -0
- batho/dashboard/pages/metrics.js +6 -0
- batho/dashboard/pages/overview.js +752 -0
- batho/dashboard/pages/relationships.js +192 -0
- batho/dashboard/pages/rules.js +6 -0
- batho/dashboard/pages/search.js +6 -0
- batho/dashboard/pages/snapshots.js +234 -0
- batho/dashboard/shared/components/audit-export.js +127 -0
- batho/dashboard/shared/components/chip-filter.js +67 -0
- batho/dashboard/shared/components/code-pane.js +47 -0
- batho/dashboard/shared/components/data-table.js +206 -0
- batho/dashboard/shared/components/drawer.js +134 -0
- batho/dashboard/shared/components/entity-chip.js +75 -0
- batho/dashboard/shared/components/header-bar.js +230 -0
- batho/dashboard/shared/components/kpi-row.js +28 -0
- batho/dashboard/shared/components/latency-bar.js +69 -0
- batho/dashboard/shared/components/side-rail.js +73 -0
- batho/dashboard/shared/components/staleness-gauge.js +69 -0
- batho/dashboard/shared/components/stat-tile.js +41 -0
- batho/dashboard/shared/components/tab-bar.js +70 -0
- batho/dashboard/vendor/cytoscape/LICENSE +19 -0
- batho/dashboard/vendor/cytoscape/VERSION.txt +1 -0
- batho/dashboard/vendor/cytoscape/cytoscape.min.js +32 -0
- batho/dashboard/vendor/marked/LICENSE +1 -0
- batho/dashboard/vendor/marked/VERSION.txt +1 -0
- batho/dashboard/vendor/marked/marked.min.js +6 -0
- batho/hooks/__init__.py +43 -0
- batho/hooks/bootstrap.py +18 -0
- batho/hooks/constants.py +97 -0
- batho/hooks/installer.py +159 -0
- batho/hooks/loader.py +63 -0
- batho/hooks/models.py +78 -0
- batho/hooks/planner.py +128 -0
- batho/hooks/runner.py +147 -0
- batho/synthesizer.py +249 -0
- batho/time_machine.py +1667 -0
- batho/utils/__init__.py +54 -0
- batho/utils/cli_output.py +165 -0
- batho/utils/dependencies.py +618 -0
- batho/utils/encoding.py +106 -0
- batho/utils/file_io.py +194 -0
- batho/utils/file_lock.py +294 -0
- batho/utils/hash.py +214 -0
- batho/utils/ignore.py +379 -0
- batho/utils/logging.py +160 -0
- batho/utils/memory_monitor.py +304 -0
- batho/utils/patch_errors.py +279 -0
- batho/utils/path_sanitizer.py +237 -0
- batho-0.1.4.dist-info/METADATA +1378 -0
- batho-0.1.4.dist-info/RECORD +184 -0
- batho-0.1.4.dist-info/WHEEL +4 -0
- batho-0.1.4.dist-info/entry_points.txt +2 -0
- batho-0.1.4.dist-info/licenses/LICENSE +201 -0
- batho_cli.py +3922 -0
batho/bridge/http_api.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""REST HTTP API for the Batho bridge, mountable into the dashboard server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.server
|
|
6
|
+
import json
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from batho.bridge.artifact_loader import (
|
|
12
|
+
ArtifactLoader,
|
|
13
|
+
ArtifactNotFoundError,
|
|
14
|
+
ArtifactParseError,
|
|
15
|
+
ChecksumMismatchError,
|
|
16
|
+
)
|
|
17
|
+
from batho.bridge.constants import DEFAULT_BRIDGE_HTTP_PORT, KNOWN_ARTIFACT_TYPES
|
|
18
|
+
from batho.bridge.models import BridgeErrorResponse, BridgeResponse, IndexListResponse
|
|
19
|
+
from batho.bridge.registry_client import ArtifactRegistryBridge
|
|
20
|
+
from batho.utils.logging import get_logger
|
|
21
|
+
|
|
22
|
+
LOGGER = get_logger(__name__, component="bridge")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json_response(data: Any, status: int = 200) -> tuple[bytes, int, dict[str, str]]:
|
|
26
|
+
body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
|
27
|
+
headers = {
|
|
28
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
29
|
+
"Access-Control-Allow-Origin": "*",
|
|
30
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
31
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
32
|
+
}
|
|
33
|
+
return body, status, headers
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ok(data: Any, meta: dict[str, Any] | None = None) -> tuple[bytes, int, dict[str, str]]:
|
|
37
|
+
return _json_response(BridgeResponse(data=data, meta=meta or {}).model_dump(exclude_none=True))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _err(code: str, message: str, detail: Any = None, status: int = 400) -> tuple[bytes, int, dict[str, str]]:
|
|
41
|
+
payload = BridgeErrorResponse(
|
|
42
|
+
error={"code": code, "message": message, "detail": detail}
|
|
43
|
+
).model_dump(exclude_none=True)
|
|
44
|
+
return _json_response(payload, status=status)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BridgeAPIHandler:
|
|
48
|
+
"""HTTP request handler for bridge REST endpoints.
|
|
49
|
+
|
|
50
|
+
This is designed to be invoked from a ``DualRootHandler`` or standalone
|
|
51
|
+
server. It does *not* subclass ``BaseHTTPRequestHandler``; instead it
|
|
52
|
+
exposes a ``dispatch(path, query)`` method that returns a response
|
|
53
|
+
triple ``(body_bytes, status, headers)``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, ctn_dir: Path) -> None:
|
|
57
|
+
self.ctn_dir = ctn_dir.resolve()
|
|
58
|
+
self._bridge = ArtifactRegistryBridge(self.ctn_dir)
|
|
59
|
+
self._loader = ArtifactLoader(self.ctn_dir)
|
|
60
|
+
|
|
61
|
+
def dispatch(self, path: str, query: dict[str, list[str]]) -> tuple[bytes, int, dict[str, str]]:
|
|
62
|
+
"""Dispatch a request to the appropriate handler."""
|
|
63
|
+
# Strip leading /api/v1/bridge/
|
|
64
|
+
prefix = "/api/v1/bridge/"
|
|
65
|
+
if not path.startswith(prefix):
|
|
66
|
+
return _err("invalid_path", f"Path must start with {prefix}", status=404)
|
|
67
|
+
|
|
68
|
+
route = path[len(prefix):].strip("/")
|
|
69
|
+
segments = [s for s in route.split("/") if s]
|
|
70
|
+
|
|
71
|
+
if not segments:
|
|
72
|
+
return _ok({"endpoints": [
|
|
73
|
+
"GET /indexes",
|
|
74
|
+
"GET /indexes/{index_id}",
|
|
75
|
+
"GET /artifacts?type=&index_id=&limit=",
|
|
76
|
+
"GET /artifacts/{artifact_type}?index_id=",
|
|
77
|
+
"GET /artifacts/content?path=",
|
|
78
|
+
"GET /stats",
|
|
79
|
+
"GET /patches",
|
|
80
|
+
"GET /patches/{operation_id}",
|
|
81
|
+
"GET /snapshots/diff?base=&new=",
|
|
82
|
+
]})
|
|
83
|
+
|
|
84
|
+
if segments[0] == "indexes":
|
|
85
|
+
return self._handle_indexes(segments, query)
|
|
86
|
+
if segments[0] == "index-meta":
|
|
87
|
+
return self._handle_index_meta()
|
|
88
|
+
if segments[0] == "artifacts":
|
|
89
|
+
return self._handle_artifacts(segments, query)
|
|
90
|
+
if segments[0] == "stats":
|
|
91
|
+
return self._handle_stats()
|
|
92
|
+
if segments[0] == "patches":
|
|
93
|
+
return self._handle_patches(segments, query)
|
|
94
|
+
if segments[0] == "snapshots" and len(segments) >= 2 and segments[1] == "diff":
|
|
95
|
+
return self._handle_snapshot_diff(query)
|
|
96
|
+
|
|
97
|
+
return _err("unknown_endpoint", f"Unknown endpoint: {route}", status=404)
|
|
98
|
+
|
|
99
|
+
def _handle_indexes(
|
|
100
|
+
self, segments: list[str], query: dict[str, list[str]]
|
|
101
|
+
) -> tuple[bytes, int, dict[str, str]]:
|
|
102
|
+
if len(segments) == 1:
|
|
103
|
+
# GET /indexes
|
|
104
|
+
entries, current_index_id, persistence_model, schema_version = self._bridge.list_indexes()
|
|
105
|
+
resp = IndexListResponse(
|
|
106
|
+
data=[e.model_dump(exclude_none=True) for e in entries],
|
|
107
|
+
current_index_id=current_index_id,
|
|
108
|
+
persistence_model=persistence_model,
|
|
109
|
+
schema_version=schema_version,
|
|
110
|
+
meta={"count": len(entries)},
|
|
111
|
+
)
|
|
112
|
+
return _json_response(resp.model_dump(exclude_none=True))
|
|
113
|
+
if len(segments) == 2:
|
|
114
|
+
# GET /indexes/{index_id}
|
|
115
|
+
index_id = segments[1]
|
|
116
|
+
entries, _, _, _ = self._bridge.list_indexes()
|
|
117
|
+
for entry in entries:
|
|
118
|
+
if entry.index_id == index_id:
|
|
119
|
+
return _ok(entry.model_dump(exclude_none=True))
|
|
120
|
+
return _err("index_not_found", f"Index not found: {index_id}", status=404)
|
|
121
|
+
return _err("invalid_path", "Invalid indexes path", status=404)
|
|
122
|
+
|
|
123
|
+
def _handle_artifacts(
|
|
124
|
+
self, segments: list[str], query: dict[str, list[str]]
|
|
125
|
+
) -> tuple[bytes, int, dict[str, str]]:
|
|
126
|
+
if len(segments) == 1:
|
|
127
|
+
# GET /artifacts?type=&index_id=&limit=
|
|
128
|
+
artifact_type = _first(query.get("type"))
|
|
129
|
+
limit_str = _first(query.get("limit"))
|
|
130
|
+
limit = int(limit_str) if limit_str and limit_str.isdigit() else 50
|
|
131
|
+
|
|
132
|
+
if artifact_type:
|
|
133
|
+
if artifact_type not in KNOWN_ARTIFACT_TYPES:
|
|
134
|
+
return _err(
|
|
135
|
+
"unknown_artifact_type",
|
|
136
|
+
f"Unknown artifact type: {artifact_type}. Known: {sorted(KNOWN_ARTIFACT_TYPES)}",
|
|
137
|
+
)
|
|
138
|
+
records = self._bridge.get_artifacts_by_type(artifact_type, limit=limit)
|
|
139
|
+
else:
|
|
140
|
+
# Return a summary of all types
|
|
141
|
+
types = self._bridge.list_artifact_types()
|
|
142
|
+
summary: dict[str, Any] = {}
|
|
143
|
+
for t in types[:limit]:
|
|
144
|
+
summary[t] = len(self._bridge.get_artifacts_by_type(t, limit=1))
|
|
145
|
+
return _ok(summary, meta={"types": len(types)})
|
|
146
|
+
|
|
147
|
+
return _ok(
|
|
148
|
+
[r.model_dump(exclude_none=True) for r in records],
|
|
149
|
+
meta={"count": len(records), "artifact_type": artifact_type},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if len(segments) == 2:
|
|
153
|
+
# GET /artifacts/{artifact_type}?index_id=
|
|
154
|
+
artifact_type = segments[1]
|
|
155
|
+
if artifact_type not in KNOWN_ARTIFACT_TYPES:
|
|
156
|
+
return _err(
|
|
157
|
+
"unknown_artifact_type",
|
|
158
|
+
f"Unknown artifact type: {artifact_type}. Known: {sorted(KNOWN_ARTIFACT_TYPES)}",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
index_id = _first(query.get("index_id"))
|
|
162
|
+
try:
|
|
163
|
+
data = self._loader.load_json(artifact_type, index_id=index_id)
|
|
164
|
+
except ArtifactNotFoundError as exc:
|
|
165
|
+
return _err("artifact_not_found", str(exc), status=404)
|
|
166
|
+
except ChecksumMismatchError as exc:
|
|
167
|
+
return _err("checksum_mismatch", str(exc), status=409)
|
|
168
|
+
except ArtifactParseError as exc:
|
|
169
|
+
return _err("parse_error", str(exc), status=500)
|
|
170
|
+
|
|
171
|
+
return _ok(
|
|
172
|
+
data,
|
|
173
|
+
meta={"artifact_type": artifact_type, "index_id": index_id or "current"},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if len(segments) == 3 and segments[2] == "content":
|
|
177
|
+
# GET /artifacts/{artifact_type}/content?path=
|
|
178
|
+
logical_path = _first(query.get("path"))
|
|
179
|
+
if not logical_path:
|
|
180
|
+
return _err("missing_param", "Query parameter 'path' is required")
|
|
181
|
+
|
|
182
|
+
record = self._bridge.get_artifact_by_logical_path(logical_path)
|
|
183
|
+
if not record:
|
|
184
|
+
return _err("artifact_not_found", f"No artifact at path: {logical_path}", status=404)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
content = self._loader.load_artifact(record)
|
|
188
|
+
except (ArtifactNotFoundError, ChecksumMismatchError, ArtifactParseError) as exc:
|
|
189
|
+
return _err(type(exc).__name__.replace("Error", "").lower(), str(exc), status=404)
|
|
190
|
+
|
|
191
|
+
return _ok(
|
|
192
|
+
content.data,
|
|
193
|
+
meta={
|
|
194
|
+
"artifact_type": record.artifact_type,
|
|
195
|
+
"logical_path": record.logical_path,
|
|
196
|
+
"resolved_path": content.resolved_path,
|
|
197
|
+
"checksum_verified": content.checksum_verified,
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return _err("invalid_path", "Invalid artifacts path", status=404)
|
|
202
|
+
|
|
203
|
+
def _handle_patches(
|
|
204
|
+
self, segments: list[str], query: dict[str, list[str]]
|
|
205
|
+
) -> tuple[bytes, int, dict[str, str]]:
|
|
206
|
+
"""Handle /patches and /patches/{operation_id} endpoints."""
|
|
207
|
+
if len(segments) == 1:
|
|
208
|
+
# GET /patches — return patches/index.json
|
|
209
|
+
try:
|
|
210
|
+
data = self._loader.load_json("patches_index")
|
|
211
|
+
except ArtifactNotFoundError as exc:
|
|
212
|
+
return _err("artifact_not_found", str(exc), status=404)
|
|
213
|
+
except ChecksumMismatchError as exc:
|
|
214
|
+
return _err("checksum_mismatch", str(exc), status=409)
|
|
215
|
+
except ArtifactParseError as exc:
|
|
216
|
+
return _err("parse_error", str(exc), status=500)
|
|
217
|
+
return _ok(data, meta={"artifact_type": "patches_index"})
|
|
218
|
+
|
|
219
|
+
if len(segments) == 2:
|
|
220
|
+
# GET /patches/{operation_id} — return individual patch detail
|
|
221
|
+
operation_id = segments[1]
|
|
222
|
+
patch_path = self.ctn_dir / "patches" / f"patch_{operation_id}.json"
|
|
223
|
+
if not patch_path.exists():
|
|
224
|
+
return _err(
|
|
225
|
+
"patch_not_found",
|
|
226
|
+
f"Patch detail not found: {operation_id}",
|
|
227
|
+
status=404,
|
|
228
|
+
)
|
|
229
|
+
try:
|
|
230
|
+
raw = patch_path.read_text(encoding="utf-8")
|
|
231
|
+
data = json.loads(raw)
|
|
232
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
233
|
+
return _err("parse_error", str(exc), status=500)
|
|
234
|
+
return _ok(data, meta={"operation_id": operation_id})
|
|
235
|
+
|
|
236
|
+
return _err("invalid_path", "Invalid patches path", status=404)
|
|
237
|
+
|
|
238
|
+
def _handle_snapshot_diff(
|
|
239
|
+
self, query: dict[str, list[str]]
|
|
240
|
+
) -> tuple[bytes, int, dict[str, str]]:
|
|
241
|
+
"""Handle GET /snapshots/diff?base={id}&new={id}.
|
|
242
|
+
|
|
243
|
+
Computes a lightweight entity delta between two snapshot files.
|
|
244
|
+
"""
|
|
245
|
+
base_id = _first(query.get("base"))
|
|
246
|
+
new_id = _first(query.get("new"))
|
|
247
|
+
if not base_id or not new_id:
|
|
248
|
+
return _err(
|
|
249
|
+
"missing_param",
|
|
250
|
+
"Both 'base' and 'new' snapshot IDs are required",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
base_path = self.ctn_dir / "snapshots" / f"{base_id}.json"
|
|
254
|
+
new_path = self.ctn_dir / "snapshots" / f"{new_id}.json"
|
|
255
|
+
|
|
256
|
+
if not base_path.exists():
|
|
257
|
+
return _err(
|
|
258
|
+
"snapshot_not_found",
|
|
259
|
+
f"Base snapshot not found: {base_id}",
|
|
260
|
+
status=404,
|
|
261
|
+
)
|
|
262
|
+
if not new_path.exists():
|
|
263
|
+
return _err(
|
|
264
|
+
"snapshot_not_found",
|
|
265
|
+
f"New snapshot not found: {new_id}",
|
|
266
|
+
status=404,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
base_data = json.loads(base_path.read_text(encoding="utf-8"))
|
|
271
|
+
new_data = json.loads(new_path.read_text(encoding="utf-8"))
|
|
272
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
273
|
+
return _err("parse_error", str(exc), status=500)
|
|
274
|
+
|
|
275
|
+
# Compute entity delta
|
|
276
|
+
base_entities = {}
|
|
277
|
+
new_entities = {}
|
|
278
|
+
for ent in base_data.get("entities", []):
|
|
279
|
+
eid = ent.get("id") or ent.get("fqn")
|
|
280
|
+
if eid:
|
|
281
|
+
base_entities[eid] = ent.get("hash", "")
|
|
282
|
+
for ent in new_data.get("entities", []):
|
|
283
|
+
eid = ent.get("id") or ent.get("fqn")
|
|
284
|
+
if eid:
|
|
285
|
+
new_entities[eid] = ent.get("hash", "")
|
|
286
|
+
|
|
287
|
+
added = [eid for eid in new_entities if eid not in base_entities]
|
|
288
|
+
removed = [eid for eid in base_entities if eid not in new_entities]
|
|
289
|
+
modified = [
|
|
290
|
+
eid for eid in new_entities
|
|
291
|
+
if eid in base_entities and new_entities[eid] != base_entities[eid]
|
|
292
|
+
]
|
|
293
|
+
unchanged = [
|
|
294
|
+
eid for eid in new_entities
|
|
295
|
+
if eid in base_entities and new_entities[eid] == base_entities[eid]
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
base_file_count = len(base_data.get("files", base_data.get("file_entries", [])))
|
|
299
|
+
new_file_count = len(new_data.get("files", new_data.get("file_entries", [])))
|
|
300
|
+
base_loc = base_data.get("stats", {}).get("loc_total", 0)
|
|
301
|
+
new_loc = new_data.get("stats", {}).get("loc_total", 0)
|
|
302
|
+
|
|
303
|
+
diff = {
|
|
304
|
+
"base_snapshot_id": base_id,
|
|
305
|
+
"new_snapshot_id": new_id,
|
|
306
|
+
"entities": {
|
|
307
|
+
"added": len(added),
|
|
308
|
+
"removed": len(removed),
|
|
309
|
+
"modified": len(modified),
|
|
310
|
+
"unchanged": len(unchanged),
|
|
311
|
+
"added_ids": added[:50],
|
|
312
|
+
"removed_ids": removed[:50],
|
|
313
|
+
"modified_ids": modified[:50],
|
|
314
|
+
},
|
|
315
|
+
"files": {
|
|
316
|
+
"base_count": base_file_count,
|
|
317
|
+
"new_count": new_file_count,
|
|
318
|
+
"delta": new_file_count - base_file_count,
|
|
319
|
+
},
|
|
320
|
+
"loc": {
|
|
321
|
+
"base": base_loc,
|
|
322
|
+
"new": new_loc,
|
|
323
|
+
"delta": new_loc - base_loc,
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
return _ok(diff)
|
|
327
|
+
|
|
328
|
+
def _handle_stats(self) -> tuple[bytes, int, dict[str, str]]:
|
|
329
|
+
stats = self._bridge.stats()
|
|
330
|
+
return _ok(stats.model_dump(exclude_none=True))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _first(values: list[str] | None) -> str | None:
|
|
334
|
+
if values:
|
|
335
|
+
return values[0]
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class BridgeHTTPRequestHandler(http.server.BaseHTTPRequestHandler):
|
|
340
|
+
"""Standalone HTTP request handler that serves bridge API endpoints."""
|
|
341
|
+
|
|
342
|
+
ctn_dir: Path
|
|
343
|
+
|
|
344
|
+
def __init__(self, *args, ctn_dir: Path | None = None, **kwargs):
|
|
345
|
+
if ctn_dir is not None:
|
|
346
|
+
self.ctn_dir = ctn_dir
|
|
347
|
+
super().__init__(*args, **kwargs)
|
|
348
|
+
|
|
349
|
+
def do_GET(self):
|
|
350
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
351
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
352
|
+
handler = BridgeAPIHandler(self.ctn_dir)
|
|
353
|
+
body, status, headers = handler.dispatch(parsed.path, query)
|
|
354
|
+
self.send_response(status)
|
|
355
|
+
for key, value in headers.items():
|
|
356
|
+
self.send_header(key, value)
|
|
357
|
+
self.send_header("Content-Length", str(len(body)))
|
|
358
|
+
self.end_headers()
|
|
359
|
+
self.wfile.write(body)
|
|
360
|
+
|
|
361
|
+
def do_OPTIONS(self):
|
|
362
|
+
self.send_response(204)
|
|
363
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
364
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
365
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
366
|
+
self.end_headers()
|
|
367
|
+
|
|
368
|
+
def log_message(self, format, *args):
|
|
369
|
+
LOGGER.info("bridge_http_request", method=self.command, path=self.path, status=args[1] if len(args) > 1 else "-")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def create_bridge_server(ctn_dir: Path, host: str = "127.0.0.1", port: int = DEFAULT_BRIDGE_HTTP_PORT):
|
|
373
|
+
"""Create a standalone bridge HTTP server."""
|
|
374
|
+
from functools import partial
|
|
375
|
+
handler = partial(BridgeHTTPRequestHandler, ctn_dir=ctn_dir)
|
|
376
|
+
return http.server.ThreadingHTTPServer((host, port), handler)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
__all__ = [
|
|
380
|
+
"BridgeAPIHandler",
|
|
381
|
+
"BridgeHTTPRequestHandler",
|
|
382
|
+
"create_bridge_server",
|
|
383
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""MCP server for the Batho bridge using FastMCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
except ImportError as exc: # pragma: no cover
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"The 'mcp' package is required for the bridge MCP server. "
|
|
15
|
+
"Install it with: pip install mcp>=1.0.0"
|
|
16
|
+
) from exc
|
|
17
|
+
|
|
18
|
+
from batho.bridge.artifact_loader import (
|
|
19
|
+
ArtifactLoader,
|
|
20
|
+
ArtifactNotFoundError,
|
|
21
|
+
ArtifactParseError,
|
|
22
|
+
ChecksumMismatchError,
|
|
23
|
+
)
|
|
24
|
+
from batho.bridge.constants import DEFAULT_MCP_SSE_PORT, KNOWN_ARTIFACT_TYPES
|
|
25
|
+
from batho.bridge.registry_client import ArtifactRegistryBridge
|
|
26
|
+
from batho.utils.logging import get_logger
|
|
27
|
+
|
|
28
|
+
LOGGER = get_logger(__name__, component="bridge")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_mcp_server(ctn_dir: Path) -> FastMCP:
|
|
32
|
+
"""Create and configure a FastMCP server bound to *ctn_dir*."""
|
|
33
|
+
bridge = ArtifactRegistryBridge(ctn_dir)
|
|
34
|
+
loader = ArtifactLoader(ctn_dir)
|
|
35
|
+
mcp = FastMCP("batho-bridge")
|
|
36
|
+
|
|
37
|
+
@mcp.tool()
|
|
38
|
+
def bridge_list_indexes() -> str:
|
|
39
|
+
"""List all available index IDs and timestamps."""
|
|
40
|
+
entries, current_index_id, _, _ = bridge.list_indexes()
|
|
41
|
+
result = {
|
|
42
|
+
"current_index_id": current_index_id,
|
|
43
|
+
"indexes": [
|
|
44
|
+
{"index_id": e.index_id, "timestamp": e.timestamp, "root": e.root}
|
|
45
|
+
for e in entries
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
return json.dumps(result, indent=2)
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
def bridge_get_index(index_id: str) -> str:
|
|
52
|
+
"""Get metadata for a specific index."""
|
|
53
|
+
entries, _, _, _ = bridge.list_indexes()
|
|
54
|
+
for entry in entries:
|
|
55
|
+
if entry.index_id == index_id:
|
|
56
|
+
return json.dumps(entry.model_dump(exclude_none=True), indent=2)
|
|
57
|
+
return json.dumps({"error": f"Index not found: {index_id}"})
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def bridge_list_artifacts(index_id: str | None = None, artifact_type: str | None = None) -> str:
|
|
61
|
+
"""List artifact records, optionally filtered by type or index."""
|
|
62
|
+
if artifact_type and artifact_type not in KNOWN_ARTIFACT_TYPES:
|
|
63
|
+
return json.dumps(
|
|
64
|
+
{
|
|
65
|
+
"error": f"Unknown artifact type: {artifact_type}",
|
|
66
|
+
"known_types": sorted(KNOWN_ARTIFACT_TYPES),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if artifact_type:
|
|
71
|
+
records = bridge.get_artifacts_by_type(artifact_type, limit=200)
|
|
72
|
+
else:
|
|
73
|
+
records = []
|
|
74
|
+
for t in bridge.list_artifact_types()[:20]:
|
|
75
|
+
records.extend(bridge.get_artifacts_by_type(t, limit=10))
|
|
76
|
+
|
|
77
|
+
result = [r.model_dump(exclude_none=True) for r in records]
|
|
78
|
+
return json.dumps(result, indent=2)
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def bridge_get_artifact(artifact_type: str, index_id: str | None = None) -> str:
|
|
82
|
+
"""Load and return full JSON content for an artifact type."""
|
|
83
|
+
if artifact_type not in KNOWN_ARTIFACT_TYPES:
|
|
84
|
+
return json.dumps(
|
|
85
|
+
{
|
|
86
|
+
"error": f"Unknown artifact type: {artifact_type}",
|
|
87
|
+
"known_types": sorted(KNOWN_ARTIFACT_TYPES),
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
data = loader.load_json(artifact_type, index_id=index_id)
|
|
93
|
+
except ArtifactNotFoundError as exc:
|
|
94
|
+
return json.dumps({"error": str(exc)})
|
|
95
|
+
except ChecksumMismatchError as exc:
|
|
96
|
+
return json.dumps({"error": str(exc), "warning": "checksum_mismatch"})
|
|
97
|
+
except ArtifactParseError as exc:
|
|
98
|
+
return json.dumps({"error": str(exc)})
|
|
99
|
+
|
|
100
|
+
return json.dumps({"ok": True, "artifact_type": artifact_type, "data": data}, indent=2, default=str)
|
|
101
|
+
|
|
102
|
+
@mcp.tool()
|
|
103
|
+
def bridge_get_artifact_by_path(logical_path: str) -> str:
|
|
104
|
+
"""Load artifact content by its exact logical path."""
|
|
105
|
+
record = bridge.get_artifact_by_logical_path(logical_path)
|
|
106
|
+
if not record:
|
|
107
|
+
return json.dumps({"error": f"No artifact at path: {logical_path}"})
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
content = loader.load_artifact(record)
|
|
111
|
+
except (ArtifactNotFoundError, ChecksumMismatchError, ArtifactParseError) as exc:
|
|
112
|
+
return json.dumps({"error": str(exc)})
|
|
113
|
+
|
|
114
|
+
return json.dumps(
|
|
115
|
+
{
|
|
116
|
+
"ok": True,
|
|
117
|
+
"record": record.model_dump(exclude_none=True),
|
|
118
|
+
"data": content.data,
|
|
119
|
+
"resolved_path": content.resolved_path,
|
|
120
|
+
"checksum_verified": content.checksum_verified,
|
|
121
|
+
},
|
|
122
|
+
indent=2,
|
|
123
|
+
default=str,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
def bridge_search_artifacts(query: str, artifact_type: str | None = None) -> str:
|
|
128
|
+
"""Fuzzy search artifacts by logical path."""
|
|
129
|
+
records = bridge.search_artifacts(query, artifact_type=artifact_type)
|
|
130
|
+
result = [r.model_dump(exclude_none=True) for r in records]
|
|
131
|
+
return json.dumps(result, indent=2)
|
|
132
|
+
|
|
133
|
+
@mcp.tool()
|
|
134
|
+
def bridge_get_stats() -> str:
|
|
135
|
+
"""Return registry statistics."""
|
|
136
|
+
stats = bridge.stats()
|
|
137
|
+
return json.dumps(stats.model_dump(exclude_none=True), indent=2)
|
|
138
|
+
|
|
139
|
+
return mcp
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_mcp_stdio(ctn_dir: Path) -> None:
|
|
143
|
+
"""Run the MCP server over stdio (default for IDE integrations)."""
|
|
144
|
+
mcp = create_mcp_server(ctn_dir)
|
|
145
|
+
mcp.run(transport="stdio")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_mcp_sse(ctn_dir: Path, host: str = "127.0.0.1", port: int = DEFAULT_MCP_SSE_PORT) -> None:
|
|
149
|
+
"""Run the MCP server over SSE transport."""
|
|
150
|
+
mcp = create_mcp_server(ctn_dir)
|
|
151
|
+
LOGGER.info("mcp_sse_starting", host=host, port=port)
|
|
152
|
+
mcp.run(transport="sse", host=host, port=port)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
"create_mcp_server",
|
|
157
|
+
"run_mcp_stdio",
|
|
158
|
+
"run_mcp_sse",
|
|
159
|
+
]
|
batho/bridge/models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Pydantic models for the Batho bridge API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ArtifactRecord(BaseModel):
|
|
12
|
+
"""A single artifact as returned by the registry."""
|
|
13
|
+
|
|
14
|
+
artifact_id: str
|
|
15
|
+
artifact_type: str
|
|
16
|
+
logical_path: str
|
|
17
|
+
physical_path: str
|
|
18
|
+
checksum: str = ""
|
|
19
|
+
size_bytes: int = 0
|
|
20
|
+
schema_version: str = ""
|
|
21
|
+
producer: str = ""
|
|
22
|
+
run_id: str = ""
|
|
23
|
+
sync_status: str = "local_only"
|
|
24
|
+
cloud_content_id: str | None = None
|
|
25
|
+
last_sync_at: str | None = None
|
|
26
|
+
sync_error: str | None = None
|
|
27
|
+
retry_count: int = 0
|
|
28
|
+
retention_class: str = "default"
|
|
29
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
30
|
+
created_at: str = ""
|
|
31
|
+
updated_at: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IndexEntry(BaseModel):
|
|
35
|
+
"""A single index entry from .ctn/index.json."""
|
|
36
|
+
|
|
37
|
+
index_id: str
|
|
38
|
+
timestamp: str
|
|
39
|
+
root: str
|
|
40
|
+
file_count: int = 0
|
|
41
|
+
entity_count: int = 0
|
|
42
|
+
relationship_count: int = 0
|
|
43
|
+
repo_hash: str = ""
|
|
44
|
+
staleness_score: float = 1.0
|
|
45
|
+
stack: dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
outputs: dict[str, str] = Field(default_factory=dict)
|
|
47
|
+
stats: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
metrics: dict[str, Any] = Field(default_factory=dict)
|
|
49
|
+
build: dict[str, Any] = Field(default_factory=dict)
|
|
50
|
+
schemas: dict[str, Any] = Field(default_factory=dict)
|
|
51
|
+
persistence: dict[str, Any] = Field(default_factory=dict)
|
|
52
|
+
snapshot_id: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class IndexListResponse(BaseModel):
|
|
56
|
+
"""Response envelope for the indexes list endpoint."""
|
|
57
|
+
|
|
58
|
+
ok: bool = True
|
|
59
|
+
data: list[IndexEntry] = Field(default_factory=list)
|
|
60
|
+
current_index_id: str = ""
|
|
61
|
+
persistence_model: str | None = None
|
|
62
|
+
schema_version: str | None = None
|
|
63
|
+
meta: dict[str, Any] = Field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BridgeResponse(BaseModel):
|
|
67
|
+
"""Unified envelope for all bridge HTTP responses."""
|
|
68
|
+
|
|
69
|
+
ok: bool = True
|
|
70
|
+
data: Any = None
|
|
71
|
+
meta: dict[str, Any] = Field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BridgeErrorResponse(BaseModel):
|
|
75
|
+
"""Error envelope for bridge HTTP responses."""
|
|
76
|
+
|
|
77
|
+
ok: bool = False
|
|
78
|
+
error: dict[str, Any] = Field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RegistryStats(BaseModel):
|
|
82
|
+
"""Registry statistics."""
|
|
83
|
+
|
|
84
|
+
enabled: bool = False
|
|
85
|
+
registry_path: str = ""
|
|
86
|
+
backend: str = "sqlite"
|
|
87
|
+
artifact_count: int = 0
|
|
88
|
+
deleted_artifact_count: int = 0
|
|
89
|
+
content_blob_count: int = 0
|
|
90
|
+
artifact_types: dict[str, int] = Field(default_factory=dict)
|
|
91
|
+
sync_status: dict[str, int] = Field(default_factory=dict)
|
|
92
|
+
db_size_bytes: int = 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"ArtifactRecord",
|
|
97
|
+
"IndexEntry",
|
|
98
|
+
"IndexListResponse",
|
|
99
|
+
"BridgeResponse",
|
|
100
|
+
"BridgeErrorResponse",
|
|
101
|
+
"RegistryStats",
|
|
102
|
+
]
|