codexa 0.4.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.
- codexa-0.4.0.dist-info/METADATA +650 -0
- codexa-0.4.0.dist-info/RECORD +189 -0
- codexa-0.4.0.dist-info/WHEEL +5 -0
- codexa-0.4.0.dist-info/entry_points.txt +2 -0
- codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
- codexa-0.4.0.dist-info/top_level.txt +1 -0
- semantic_code_intelligence/__init__.py +5 -0
- semantic_code_intelligence/analysis/__init__.py +21 -0
- semantic_code_intelligence/analysis/ai_features.py +351 -0
- semantic_code_intelligence/bridge/__init__.py +28 -0
- semantic_code_intelligence/bridge/context_provider.py +245 -0
- semantic_code_intelligence/bridge/protocol.py +167 -0
- semantic_code_intelligence/bridge/server.py +348 -0
- semantic_code_intelligence/bridge/vscode.py +271 -0
- semantic_code_intelligence/ci/__init__.py +13 -0
- semantic_code_intelligence/ci/hooks.py +98 -0
- semantic_code_intelligence/ci/hotspots.py +272 -0
- semantic_code_intelligence/ci/impact.py +246 -0
- semantic_code_intelligence/ci/metrics.py +591 -0
- semantic_code_intelligence/ci/pr.py +412 -0
- semantic_code_intelligence/ci/quality.py +557 -0
- semantic_code_intelligence/ci/templates.py +164 -0
- semantic_code_intelligence/ci/trace.py +224 -0
- semantic_code_intelligence/cli/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
- semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
- semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
- semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
- semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
- semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
- semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
- semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
- semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
- semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
- semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
- semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
- semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
- semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
- semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
- semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
- semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
- semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
- semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
- semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
- semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
- semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
- semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
- semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
- semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
- semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
- semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
- semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
- semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
- semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
- semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
- semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
- semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
- semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
- semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
- semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
- semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
- semantic_code_intelligence/cli/main.py +65 -0
- semantic_code_intelligence/cli/router.py +92 -0
- semantic_code_intelligence/config/__init__.py +0 -0
- semantic_code_intelligence/config/settings.py +260 -0
- semantic_code_intelligence/context/__init__.py +19 -0
- semantic_code_intelligence/context/engine.py +429 -0
- semantic_code_intelligence/context/memory.py +253 -0
- semantic_code_intelligence/daemon/__init__.py +1 -0
- semantic_code_intelligence/daemon/watcher.py +515 -0
- semantic_code_intelligence/docs/__init__.py +1080 -0
- semantic_code_intelligence/embeddings/__init__.py +0 -0
- semantic_code_intelligence/embeddings/enhanced.py +131 -0
- semantic_code_intelligence/embeddings/generator.py +149 -0
- semantic_code_intelligence/embeddings/model_registry.py +100 -0
- semantic_code_intelligence/evolution/__init__.py +1 -0
- semantic_code_intelligence/evolution/budget_guard.py +111 -0
- semantic_code_intelligence/evolution/commit_manager.py +88 -0
- semantic_code_intelligence/evolution/context_builder.py +131 -0
- semantic_code_intelligence/evolution/engine.py +249 -0
- semantic_code_intelligence/evolution/patch_generator.py +229 -0
- semantic_code_intelligence/evolution/task_selector.py +214 -0
- semantic_code_intelligence/evolution/test_runner.py +111 -0
- semantic_code_intelligence/indexing/__init__.py +0 -0
- semantic_code_intelligence/indexing/chunker.py +174 -0
- semantic_code_intelligence/indexing/parallel.py +86 -0
- semantic_code_intelligence/indexing/scanner.py +146 -0
- semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
- semantic_code_intelligence/llm/__init__.py +62 -0
- semantic_code_intelligence/llm/cache.py +219 -0
- semantic_code_intelligence/llm/cached_provider.py +145 -0
- semantic_code_intelligence/llm/conversation.py +190 -0
- semantic_code_intelligence/llm/cross_refactor.py +272 -0
- semantic_code_intelligence/llm/investigation.py +274 -0
- semantic_code_intelligence/llm/mock_provider.py +77 -0
- semantic_code_intelligence/llm/ollama_provider.py +122 -0
- semantic_code_intelligence/llm/openai_provider.py +100 -0
- semantic_code_intelligence/llm/provider.py +92 -0
- semantic_code_intelligence/llm/rate_limiter.py +164 -0
- semantic_code_intelligence/llm/reasoning.py +438 -0
- semantic_code_intelligence/llm/safety.py +110 -0
- semantic_code_intelligence/llm/streaming.py +251 -0
- semantic_code_intelligence/lsp/__init__.py +609 -0
- semantic_code_intelligence/mcp/__init__.py +393 -0
- semantic_code_intelligence/parsing/__init__.py +19 -0
- semantic_code_intelligence/parsing/parser.py +375 -0
- semantic_code_intelligence/plugins/__init__.py +255 -0
- semantic_code_intelligence/plugins/examples/__init__.py +1 -0
- semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
- semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
- semantic_code_intelligence/scalability/__init__.py +205 -0
- semantic_code_intelligence/search/__init__.py +0 -0
- semantic_code_intelligence/search/formatter.py +123 -0
- semantic_code_intelligence/search/grep.py +361 -0
- semantic_code_intelligence/search/hybrid_search.py +170 -0
- semantic_code_intelligence/search/keyword_search.py +311 -0
- semantic_code_intelligence/search/section_expander.py +103 -0
- semantic_code_intelligence/services/__init__.py +0 -0
- semantic_code_intelligence/services/indexing_service.py +630 -0
- semantic_code_intelligence/services/search_service.py +269 -0
- semantic_code_intelligence/storage/__init__.py +0 -0
- semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
- semantic_code_intelligence/storage/hash_store.py +66 -0
- semantic_code_intelligence/storage/index_manifest.py +85 -0
- semantic_code_intelligence/storage/index_stats.py +138 -0
- semantic_code_intelligence/storage/query_history.py +160 -0
- semantic_code_intelligence/storage/symbol_registry.py +209 -0
- semantic_code_intelligence/storage/vector_store.py +297 -0
- semantic_code_intelligence/tests/__init__.py +0 -0
- semantic_code_intelligence/tests/test_ai_features.py +351 -0
- semantic_code_intelligence/tests/test_chunker.py +119 -0
- semantic_code_intelligence/tests/test_cli.py +188 -0
- semantic_code_intelligence/tests/test_config.py +154 -0
- semantic_code_intelligence/tests/test_context.py +381 -0
- semantic_code_intelligence/tests/test_embeddings.py +73 -0
- semantic_code_intelligence/tests/test_endtoend.py +1142 -0
- semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
- semantic_code_intelligence/tests/test_hash_store.py +79 -0
- semantic_code_intelligence/tests/test_logging.py +55 -0
- semantic_code_intelligence/tests/test_new_cli.py +138 -0
- semantic_code_intelligence/tests/test_parser.py +495 -0
- semantic_code_intelligence/tests/test_phase10.py +355 -0
- semantic_code_intelligence/tests/test_phase11.py +593 -0
- semantic_code_intelligence/tests/test_phase12.py +375 -0
- semantic_code_intelligence/tests/test_phase13.py +663 -0
- semantic_code_intelligence/tests/test_phase14.py +568 -0
- semantic_code_intelligence/tests/test_phase15.py +814 -0
- semantic_code_intelligence/tests/test_phase16.py +792 -0
- semantic_code_intelligence/tests/test_phase17.py +815 -0
- semantic_code_intelligence/tests/test_phase18.py +934 -0
- semantic_code_intelligence/tests/test_phase19.py +986 -0
- semantic_code_intelligence/tests/test_phase20.py +2753 -0
- semantic_code_intelligence/tests/test_phase20b.py +2058 -0
- semantic_code_intelligence/tests/test_phase20c.py +962 -0
- semantic_code_intelligence/tests/test_phase21.py +428 -0
- semantic_code_intelligence/tests/test_phase22.py +799 -0
- semantic_code_intelligence/tests/test_phase23.py +783 -0
- semantic_code_intelligence/tests/test_phase24.py +715 -0
- semantic_code_intelligence/tests/test_phase25.py +496 -0
- semantic_code_intelligence/tests/test_phase26.py +251 -0
- semantic_code_intelligence/tests/test_phase27.py +531 -0
- semantic_code_intelligence/tests/test_phase8.py +592 -0
- semantic_code_intelligence/tests/test_phase9.py +643 -0
- semantic_code_intelligence/tests/test_plugins.py +293 -0
- semantic_code_intelligence/tests/test_priority_features.py +727 -0
- semantic_code_intelligence/tests/test_router.py +41 -0
- semantic_code_intelligence/tests/test_scalability.py +138 -0
- semantic_code_intelligence/tests/test_scanner.py +125 -0
- semantic_code_intelligence/tests/test_search.py +160 -0
- semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
- semantic_code_intelligence/tests/test_tools.py +182 -0
- semantic_code_intelligence/tests/test_vector_store.py +151 -0
- semantic_code_intelligence/tests/test_watcher.py +211 -0
- semantic_code_intelligence/tools/__init__.py +442 -0
- semantic_code_intelligence/tools/executor.py +232 -0
- semantic_code_intelligence/tools/protocol.py +200 -0
- semantic_code_intelligence/tui/__init__.py +454 -0
- semantic_code_intelligence/utils/__init__.py +0 -0
- semantic_code_intelligence/utils/logging.py +112 -0
- semantic_code_intelligence/version.py +3 -0
- semantic_code_intelligence/web/__init__.py +11 -0
- semantic_code_intelligence/web/api.py +289 -0
- semantic_code_intelligence/web/server.py +397 -0
- semantic_code_intelligence/web/ui.py +659 -0
- semantic_code_intelligence/web/visualize.py +226 -0
- semantic_code_intelligence/workspace/__init__.py +427 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""REST API server — developer-friendly HTTP endpoints wrapping CodexA services.
|
|
2
|
+
|
|
3
|
+
Uses only the Python standard library (``http.server``). All endpoints
|
|
4
|
+
return JSON and can be selectively enabled/disabled.
|
|
5
|
+
|
|
6
|
+
Endpoints
|
|
7
|
+
---------
|
|
8
|
+
GET /health → system & index status
|
|
9
|
+
GET /api/search → semantic search (``?q=...&top_k=5``)
|
|
10
|
+
POST /api/ask → natural-language code Q\u0026A
|
|
11
|
+
POST /api/analyze → code explanation / validation
|
|
12
|
+
GET /api/symbols → list indexed symbols (``?file=...&kind=...``)
|
|
13
|
+
GET /api/deps → dependency map (``?file=...``)
|
|
14
|
+
GET /api/callgraph → call graph (``?symbol=...``)
|
|
15
|
+
GET /api/summary → repository summary
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import time
|
|
22
|
+
import urllib.parse
|
|
23
|
+
from http.server import BaseHTTPRequestHandler
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from semantic_code_intelligence.bridge.context_provider import ContextProvider
|
|
28
|
+
from semantic_code_intelligence.utils.logging import get_logger
|
|
29
|
+
|
|
30
|
+
logger = get_logger("web.api")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class APIHandler(BaseHTTPRequestHandler):
|
|
34
|
+
"""JSON REST API handler for CodexA developer endpoints.
|
|
35
|
+
|
|
36
|
+
Class-level attributes are injected before the server starts:
|
|
37
|
+
project_root — the root path of the project being served
|
|
38
|
+
provider — a ``ContextProvider`` instance
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
project_root: Path
|
|
42
|
+
provider: ContextProvider
|
|
43
|
+
|
|
44
|
+
# Use our own logger instead of stderr.
|
|
45
|
+
def log_message(self, fmt: str, *args: Any) -> None: # noqa: D102
|
|
46
|
+
logger.debug(fmt, *args)
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Routing
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def do_GET(self) -> None: # noqa: N802
|
|
53
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
54
|
+
path = parsed.path.rstrip("/")
|
|
55
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
56
|
+
|
|
57
|
+
routes: dict[str, Any] = {
|
|
58
|
+
"/health": self._handle_health,
|
|
59
|
+
"/api/search": self._handle_search,
|
|
60
|
+
"/api/symbols": self._handle_symbols,
|
|
61
|
+
"/api/deps": self._handle_deps,
|
|
62
|
+
"/api/callgraph": self._handle_callgraph,
|
|
63
|
+
"/api/summary": self._handle_summary,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handler = routes.get(path)
|
|
67
|
+
if handler:
|
|
68
|
+
handler(qs)
|
|
69
|
+
else:
|
|
70
|
+
self._json(404, {"error": "Not found", "path": self.path})
|
|
71
|
+
|
|
72
|
+
def do_POST(self) -> None: # noqa: N802
|
|
73
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
74
|
+
path = parsed.path.rstrip("/")
|
|
75
|
+
|
|
76
|
+
body = self._read_body()
|
|
77
|
+
if body is None:
|
|
78
|
+
return # error already sent
|
|
79
|
+
|
|
80
|
+
routes: dict[str, Any] = {
|
|
81
|
+
"/api/ask": self._handle_ask,
|
|
82
|
+
"/api/analyze": self._handle_analyze,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handler = routes.get(path)
|
|
86
|
+
if handler:
|
|
87
|
+
handler(body)
|
|
88
|
+
else:
|
|
89
|
+
self._json(404, {"error": "Not found", "path": self.path})
|
|
90
|
+
|
|
91
|
+
def do_OPTIONS(self) -> None: # noqa: N802
|
|
92
|
+
self.send_response(204)
|
|
93
|
+
self._cors_headers()
|
|
94
|
+
self.end_headers()
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# GET handlers
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _handle_health(self, qs: dict[str, list[str]]) -> None:
|
|
101
|
+
"""GET /health — project status and basic info."""
|
|
102
|
+
codex_dir = self.project_root / ".codexa"
|
|
103
|
+
self._json(200, {
|
|
104
|
+
"status": "ok",
|
|
105
|
+
"project_root": str(self.project_root),
|
|
106
|
+
"indexed": (codex_dir / "index").exists() if codex_dir.exists() else False,
|
|
107
|
+
"config_found": (codex_dir / "config.json").exists() if codex_dir.exists() else False,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
def _handle_search(self, qs: dict[str, list[str]]) -> None:
|
|
111
|
+
"""GET /api/search?q=...&top_k=5&threshold=0.2"""
|
|
112
|
+
query = _qs_first(qs, "q", "")
|
|
113
|
+
if not query:
|
|
114
|
+
self._json(400, {"error": "Missing required parameter: q"})
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
top_k = int(_qs_first(qs, "top_k", "5"))
|
|
118
|
+
threshold = float(_qs_first(qs, "threshold", "0.2"))
|
|
119
|
+
|
|
120
|
+
start = time.monotonic()
|
|
121
|
+
data = self.provider.context_for_query(
|
|
122
|
+
query, top_k=top_k, threshold=threshold,
|
|
123
|
+
)
|
|
124
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
125
|
+
data["elapsed_ms"] = round(elapsed, 2)
|
|
126
|
+
self._json(200, data)
|
|
127
|
+
|
|
128
|
+
def _handle_symbols(self, qs: dict[str, list[str]]) -> None:
|
|
129
|
+
"""GET /api/symbols?file=...&kind=..."""
|
|
130
|
+
builder = self.provider._ensure_indexed()
|
|
131
|
+
file_filter = _qs_first(qs, "file", "")
|
|
132
|
+
kind_filter = _qs_first(qs, "kind", "")
|
|
133
|
+
|
|
134
|
+
symbols = builder.get_all_symbols()
|
|
135
|
+
if file_filter:
|
|
136
|
+
symbols = [s for s in symbols if file_filter in s.file_path]
|
|
137
|
+
if kind_filter:
|
|
138
|
+
symbols = [s for s in symbols if s.kind == kind_filter]
|
|
139
|
+
|
|
140
|
+
self._json(200, {
|
|
141
|
+
"count": len(symbols),
|
|
142
|
+
"symbols": [s.to_dict() for s in symbols[:200]], # cap at 200
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
def _handle_deps(self, qs: dict[str, list[str]]) -> None:
|
|
146
|
+
"""GET /api/deps?file=..."""
|
|
147
|
+
file_path = _qs_first(qs, "file", "")
|
|
148
|
+
data = self.provider.get_dependencies(file_path=file_path)
|
|
149
|
+
self._json(200, data)
|
|
150
|
+
|
|
151
|
+
def _handle_callgraph(self, qs: dict[str, list[str]]) -> None:
|
|
152
|
+
"""GET /api/callgraph?symbol=..."""
|
|
153
|
+
symbol = _qs_first(qs, "symbol", "")
|
|
154
|
+
data = self.provider.get_call_graph(symbol_name=symbol)
|
|
155
|
+
self._json(200, data)
|
|
156
|
+
|
|
157
|
+
def _handle_summary(self, qs: dict[str, list[str]]) -> None:
|
|
158
|
+
"""GET /api/summary"""
|
|
159
|
+
data = self.provider.context_for_repo()
|
|
160
|
+
self._json(200, data)
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# POST handlers
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _handle_ask(self, body: dict[str, Any]) -> None:
|
|
167
|
+
"""POST /api/ask — natural-language question about the codebase.
|
|
168
|
+
|
|
169
|
+
Request body: ``{"question": "...", "top_k": 5}``
|
|
170
|
+
"""
|
|
171
|
+
question = body.get("question", "")
|
|
172
|
+
if not question:
|
|
173
|
+
self._json(400, {"error": "Missing required field: question"})
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
top_k = body.get("top_k", 5)
|
|
177
|
+
start = time.monotonic()
|
|
178
|
+
# Try LLM-powered answer via ReasoningEngine
|
|
179
|
+
try:
|
|
180
|
+
from semantic_code_intelligence.config.settings import load_config, LLMConfig
|
|
181
|
+
from semantic_code_intelligence.llm.reasoning import ReasoningEngine
|
|
182
|
+
|
|
183
|
+
config = load_config(self.provider._root)
|
|
184
|
+
llm_cfg: LLMConfig = config.llm
|
|
185
|
+
provider = None
|
|
186
|
+
if llm_cfg.provider == "openai":
|
|
187
|
+
from semantic_code_intelligence.llm.openai_provider import OpenAIProvider
|
|
188
|
+
provider = OpenAIProvider(
|
|
189
|
+
api_key=llm_cfg.api_key, model=llm_cfg.model,
|
|
190
|
+
base_url=llm_cfg.base_url or None,
|
|
191
|
+
temperature=llm_cfg.temperature, max_tokens=llm_cfg.max_tokens,
|
|
192
|
+
)
|
|
193
|
+
elif llm_cfg.provider == "ollama":
|
|
194
|
+
from semantic_code_intelligence.llm.ollama_provider import OllamaProvider
|
|
195
|
+
provider = OllamaProvider(
|
|
196
|
+
model=llm_cfg.model,
|
|
197
|
+
base_url=llm_cfg.base_url or "http://localhost:11434",
|
|
198
|
+
temperature=llm_cfg.temperature, max_tokens=llm_cfg.max_tokens,
|
|
199
|
+
)
|
|
200
|
+
if provider is not None:
|
|
201
|
+
engine = ReasoningEngine(provider, self.provider._root)
|
|
202
|
+
result = engine.ask(question, top_k=top_k)
|
|
203
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
204
|
+
self._json(200, {
|
|
205
|
+
"question": result.question,
|
|
206
|
+
"answer": result.answer,
|
|
207
|
+
"snippets": result.context_snippets,
|
|
208
|
+
"elapsed_ms": round(elapsed, 2),
|
|
209
|
+
})
|
|
210
|
+
return
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.debug("LLM ask failed, falling back to context search", exc_info=True)
|
|
213
|
+
|
|
214
|
+
# Fallback: return snippets with a synthesized answer
|
|
215
|
+
data = self.provider.context_for_query(question, top_k=top_k)
|
|
216
|
+
snippets = data.get("snippets", [])
|
|
217
|
+
summary_parts = []
|
|
218
|
+
for s in snippets[:3]:
|
|
219
|
+
fp = s.get("file_path", "")
|
|
220
|
+
sl = s.get("start_line", "")
|
|
221
|
+
summary_parts.append(f"- {fp} (line {sl})")
|
|
222
|
+
answer = f"Based on {len(snippets)} relevant code snippets:\n" + "\n".join(summary_parts)
|
|
223
|
+
if not snippets:
|
|
224
|
+
answer = "No relevant code found for this question. Try rephrasing or indexing more files."
|
|
225
|
+
data["answer"] = answer
|
|
226
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
227
|
+
data["elapsed_ms"] = round(elapsed, 2)
|
|
228
|
+
self._json(200, data)
|
|
229
|
+
|
|
230
|
+
def _handle_analyze(self, body: dict[str, Any]) -> None:
|
|
231
|
+
"""POST /api/analyze — code validation / explanation.
|
|
232
|
+
|
|
233
|
+
Request body: ``{"code": "...", "mode": "validate|explain"}``
|
|
234
|
+
"""
|
|
235
|
+
code = body.get("code", "")
|
|
236
|
+
mode = body.get("mode", "validate")
|
|
237
|
+
|
|
238
|
+
if not code:
|
|
239
|
+
self._json(400, {"error": "Missing required field: code"})
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
if mode == "validate":
|
|
243
|
+
data = self.provider.validate_code(code=code)
|
|
244
|
+
elif mode == "explain":
|
|
245
|
+
# File-level explanation for the provided code snippet
|
|
246
|
+
file_path = body.get("file_path", "snippet.py")
|
|
247
|
+
data = self.provider.context_for_file(file_path=file_path)
|
|
248
|
+
else:
|
|
249
|
+
self._json(400, {"error": f"Unknown mode: {mode}. Use 'validate' or 'explain'."})
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
self._json(200, data)
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Helpers
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _read_body(self) -> dict[str, Any] | None:
|
|
259
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
260
|
+
if content_length == 0:
|
|
261
|
+
self._json(400, {"error": "Empty request body"})
|
|
262
|
+
return None
|
|
263
|
+
raw = self.rfile.read(content_length)
|
|
264
|
+
try:
|
|
265
|
+
parsed: dict[str, Any] | None = json.loads(raw.decode("utf-8"))
|
|
266
|
+
return parsed
|
|
267
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
268
|
+
self._json(400, {"error": f"Invalid JSON: {exc}"})
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
def _json(self, status: int, body: dict[str, Any]) -> None:
|
|
272
|
+
payload = json.dumps(body, indent=2, default=str).encode("utf-8")
|
|
273
|
+
self.send_response(status)
|
|
274
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
275
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
276
|
+
self._cors_headers()
|
|
277
|
+
self.end_headers()
|
|
278
|
+
self.wfile.write(payload)
|
|
279
|
+
|
|
280
|
+
def _cors_headers(self) -> None:
|
|
281
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
282
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
283
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _qs_first(qs: dict[str, list[str]], key: str, default: str = "") -> str:
|
|
287
|
+
"""Return the first value for a query-string key, or *default*."""
|
|
288
|
+
vals = qs.get(key, [])
|
|
289
|
+
return vals[0] if vals else default
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Combined web server — serves both the REST API and web UI.
|
|
2
|
+
|
|
3
|
+
Merges API and UI routing into a single HTTP handler so that
|
|
4
|
+
a single ``codexa web`` command provides both the browser interface and the
|
|
5
|
+
developer API on the same port.
|
|
6
|
+
|
|
7
|
+
Uses only the Python standard library.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from semantic_code_intelligence.bridge.context_provider import ContextProvider
|
|
20
|
+
from semantic_code_intelligence.utils.logging import get_logger
|
|
21
|
+
from semantic_code_intelligence.web.api import APIHandler, _qs_first
|
|
22
|
+
from semantic_code_intelligence.web.ui import (
|
|
23
|
+
UIHandler,
|
|
24
|
+
page_search,
|
|
25
|
+
page_symbols,
|
|
26
|
+
page_tools,
|
|
27
|
+
page_quality,
|
|
28
|
+
page_ask,
|
|
29
|
+
page_workspace,
|
|
30
|
+
page_viz,
|
|
31
|
+
_page,
|
|
32
|
+
)
|
|
33
|
+
from semantic_code_intelligence.web.visualize import (
|
|
34
|
+
render_call_graph,
|
|
35
|
+
render_dependency_graph,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = get_logger("web.server")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _CombinedHandler(BaseHTTPRequestHandler):
|
|
42
|
+
"""Routes API requests to ``APIHandler`` logic and UI requests to page builders.
|
|
43
|
+
|
|
44
|
+
Requests starting with ``/api/`` or ``/health`` are handled by API
|
|
45
|
+
logic; everything else returns server-rendered HTML pages.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
project_root: Path
|
|
49
|
+
provider: ContextProvider
|
|
50
|
+
|
|
51
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
52
|
+
logger.debug(fmt, *args)
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Routing
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def do_GET(self) -> None: # noqa: N802
|
|
59
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
60
|
+
path = parsed.path.rstrip("/") or "/"
|
|
61
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
62
|
+
|
|
63
|
+
# API routes — delegate to APIHandler methods directly
|
|
64
|
+
if path.startswith("/api/") or path == "/health":
|
|
65
|
+
self._dispatch_api_get(path, qs)
|
|
66
|
+
else:
|
|
67
|
+
self._dispatch_ui(path, qs)
|
|
68
|
+
|
|
69
|
+
def do_POST(self) -> None: # noqa: N802
|
|
70
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
71
|
+
path = parsed.path.rstrip("/")
|
|
72
|
+
self._dispatch_api_post(path)
|
|
73
|
+
|
|
74
|
+
def do_OPTIONS(self) -> None: # noqa: N802
|
|
75
|
+
self.send_response(204)
|
|
76
|
+
self._cors_headers()
|
|
77
|
+
self.end_headers()
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# API dispatch (inline — avoids creating new handler instances)
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _dispatch_api_get(self, path: str, qs: dict[str, list[str]]) -> None:
|
|
84
|
+
import time
|
|
85
|
+
|
|
86
|
+
if path == "/health":
|
|
87
|
+
codex_dir = self.project_root / ".codexa"
|
|
88
|
+
self._json(200, {
|
|
89
|
+
"status": "ok",
|
|
90
|
+
"project_root": str(self.project_root),
|
|
91
|
+
"indexed": (codex_dir / "index").exists() if codex_dir.exists() else False,
|
|
92
|
+
"config_found": (codex_dir / "config.json").exists() if codex_dir.exists() else False,
|
|
93
|
+
})
|
|
94
|
+
elif path == "/api/search":
|
|
95
|
+
query = _qs_first(qs, "q", "")
|
|
96
|
+
if not query:
|
|
97
|
+
self._json(400, {"error": "Missing required parameter: q"})
|
|
98
|
+
return
|
|
99
|
+
top_k = int(_qs_first(qs, "top_k", "5"))
|
|
100
|
+
threshold = float(_qs_first(qs, "threshold", "0.2"))
|
|
101
|
+
start = time.monotonic()
|
|
102
|
+
data = self.provider.context_for_query(query, top_k=top_k, threshold=threshold)
|
|
103
|
+
data["elapsed_ms"] = round((time.monotonic() - start) * 1000, 2)
|
|
104
|
+
self._json(200, data)
|
|
105
|
+
elif path == "/api/symbols":
|
|
106
|
+
builder = self.provider._ensure_indexed()
|
|
107
|
+
file_filter = _qs_first(qs, "file", "")
|
|
108
|
+
kind_filter = _qs_first(qs, "kind", "")
|
|
109
|
+
symbols = builder.get_all_symbols()
|
|
110
|
+
if file_filter:
|
|
111
|
+
symbols = [s for s in symbols if file_filter in s.file_path]
|
|
112
|
+
if kind_filter:
|
|
113
|
+
symbols = [s for s in symbols if s.kind == kind_filter]
|
|
114
|
+
self._json(200, {
|
|
115
|
+
"count": len(symbols),
|
|
116
|
+
"symbols": [s.to_dict() for s in symbols[:200]],
|
|
117
|
+
})
|
|
118
|
+
elif path == "/api/deps":
|
|
119
|
+
file_path = _qs_first(qs, "file", "")
|
|
120
|
+
data = self.provider.get_dependencies(file_path=file_path)
|
|
121
|
+
self._json(200, data)
|
|
122
|
+
elif path == "/api/callgraph":
|
|
123
|
+
symbol = _qs_first(qs, "symbol", "")
|
|
124
|
+
data = self.provider.get_call_graph(symbol_name=symbol)
|
|
125
|
+
self._json(200, data)
|
|
126
|
+
elif path == "/api/summary":
|
|
127
|
+
data = self.provider.context_for_repo()
|
|
128
|
+
self._json(200, data)
|
|
129
|
+
elif path == "/api/quality":
|
|
130
|
+
try:
|
|
131
|
+
from semantic_code_intelligence.ci.quality import analyze_project
|
|
132
|
+
report = analyze_project(self.project_root)
|
|
133
|
+
self._json(200, report.to_dict())
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
self._json(500, {"error": str(exc)})
|
|
136
|
+
elif path == "/api/metrics":
|
|
137
|
+
try:
|
|
138
|
+
from semantic_code_intelligence.ci.metrics import compute_project_metrics
|
|
139
|
+
pm = compute_project_metrics(self.project_root)
|
|
140
|
+
self._json(200, pm.to_dict())
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
self._json(500, {"error": str(exc)})
|
|
143
|
+
elif path == "/api/hotspots":
|
|
144
|
+
try:
|
|
145
|
+
from semantic_code_intelligence.ci.hotspots import analyze_hotspots
|
|
146
|
+
from semantic_code_intelligence.context.engine import CallGraph, ContextBuilder, DependencyMap
|
|
147
|
+
builder = ContextBuilder()
|
|
148
|
+
dep_map = DependencyMap()
|
|
149
|
+
root = self.project_root
|
|
150
|
+
py_files = sorted(root.rglob("*.py"))
|
|
151
|
+
py_files = [f for f in py_files if ".venv" not in f.parts and "__pycache__" not in f.parts]
|
|
152
|
+
for fp in py_files:
|
|
153
|
+
try:
|
|
154
|
+
content = fp.read_text(encoding="utf-8", errors="replace")
|
|
155
|
+
builder.index_file(str(fp), content)
|
|
156
|
+
dep_map.add_file(str(fp), content)
|
|
157
|
+
except Exception:
|
|
158
|
+
continue
|
|
159
|
+
symbols = builder.get_all_symbols()
|
|
160
|
+
call_graph = CallGraph()
|
|
161
|
+
call_graph.build(symbols)
|
|
162
|
+
report = analyze_hotspots(symbols, call_graph, dep_map, root)
|
|
163
|
+
self._json(200, report.to_dict())
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
self._json(500, {"error": str(exc)})
|
|
166
|
+
elif path.startswith("/api/viz/"):
|
|
167
|
+
kind = path.replace("/api/viz/", "").strip("/")
|
|
168
|
+
target = _qs_first(qs, "target", "")
|
|
169
|
+
try:
|
|
170
|
+
if kind == "callgraph":
|
|
171
|
+
data = self.provider.get_call_graph(symbol_name=target)
|
|
172
|
+
edges = data.get("edges", [])
|
|
173
|
+
mermaid = render_call_graph(edges)
|
|
174
|
+
elif kind == "deps":
|
|
175
|
+
data = self.provider.get_dependencies(file_path=target)
|
|
176
|
+
mermaid = render_dependency_graph(data)
|
|
177
|
+
else:
|
|
178
|
+
self._json(400, {"error": f"Unknown viz kind: {kind}"})
|
|
179
|
+
return
|
|
180
|
+
self._json(200, {"kind": kind, "mermaid": mermaid})
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
self._json(500, {"error": str(exc)})
|
|
183
|
+
else:
|
|
184
|
+
self._json(404, {"error": "Not found", "path": self.path})
|
|
185
|
+
|
|
186
|
+
def _dispatch_api_post(self, path: str) -> None:
|
|
187
|
+
import time
|
|
188
|
+
|
|
189
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
190
|
+
if content_length == 0:
|
|
191
|
+
self._json(400, {"error": "Empty request body"})
|
|
192
|
+
return
|
|
193
|
+
raw = self.rfile.read(content_length)
|
|
194
|
+
try:
|
|
195
|
+
body = json.loads(raw.decode("utf-8"))
|
|
196
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
197
|
+
self._json(400, {"error": f"Invalid JSON: {exc}"})
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if path == "/api/ask":
|
|
201
|
+
question = body.get("question", "")
|
|
202
|
+
if not question:
|
|
203
|
+
self._json(400, {"error": "Missing required field: question"})
|
|
204
|
+
return
|
|
205
|
+
top_k = body.get("top_k", 5)
|
|
206
|
+
start = time.monotonic()
|
|
207
|
+
try:
|
|
208
|
+
from semantic_code_intelligence.config.settings import load_config, LLMConfig
|
|
209
|
+
from semantic_code_intelligence.llm.reasoning import ReasoningEngine
|
|
210
|
+
config = load_config(self.project_root)
|
|
211
|
+
llm_cfg: LLMConfig = config.llm
|
|
212
|
+
if llm_cfg.provider == "openai":
|
|
213
|
+
from semantic_code_intelligence.llm.openai_provider import OpenAIProvider
|
|
214
|
+
provider = OpenAIProvider(
|
|
215
|
+
api_key=llm_cfg.api_key, model=llm_cfg.model,
|
|
216
|
+
base_url=llm_cfg.base_url or None,
|
|
217
|
+
temperature=llm_cfg.temperature, max_tokens=llm_cfg.max_tokens,
|
|
218
|
+
)
|
|
219
|
+
elif llm_cfg.provider == "ollama":
|
|
220
|
+
from semantic_code_intelligence.llm.ollama_provider import OllamaProvider
|
|
221
|
+
provider = OllamaProvider(
|
|
222
|
+
model=llm_cfg.model,
|
|
223
|
+
base_url=llm_cfg.base_url or "http://localhost:11434",
|
|
224
|
+
temperature=llm_cfg.temperature, max_tokens=llm_cfg.max_tokens,
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
provider = None # type: ignore[assignment]
|
|
228
|
+
if provider is not None:
|
|
229
|
+
engine = ReasoningEngine(provider, self.project_root)
|
|
230
|
+
result = engine.ask(question, top_k=top_k)
|
|
231
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
232
|
+
self._json(200, {
|
|
233
|
+
"question": result.question,
|
|
234
|
+
"answer": result.answer,
|
|
235
|
+
"snippets": result.context_snippets,
|
|
236
|
+
"elapsed_ms": round(elapsed, 2),
|
|
237
|
+
})
|
|
238
|
+
return
|
|
239
|
+
except Exception:
|
|
240
|
+
logger.debug("LLM ask failed, falling back to context search", exc_info=True)
|
|
241
|
+
# Fallback: return snippets with a synthesized answer
|
|
242
|
+
data = self.provider.context_for_query(question, top_k=top_k)
|
|
243
|
+
snippets = data.get("snippets", [])
|
|
244
|
+
summary_parts = []
|
|
245
|
+
for s in snippets[:3]:
|
|
246
|
+
fp = s.get("file_path", "")
|
|
247
|
+
sl = s.get("start_line", "")
|
|
248
|
+
summary_parts.append(f"- {fp} (line {sl})")
|
|
249
|
+
answer = f"Based on {len(snippets)} relevant code snippets:\n" + "\n".join(summary_parts)
|
|
250
|
+
if not snippets:
|
|
251
|
+
answer = "No relevant code found for this question. Try rephrasing or indexing more files."
|
|
252
|
+
data["answer"] = answer
|
|
253
|
+
data["elapsed_ms"] = round((time.monotonic() - start) * 1000, 2)
|
|
254
|
+
self._json(200, data)
|
|
255
|
+
elif path == "/api/analyze":
|
|
256
|
+
code = body.get("code", "")
|
|
257
|
+
mode = body.get("mode", "validate")
|
|
258
|
+
if not code:
|
|
259
|
+
self._json(400, {"error": "Missing required field: code"})
|
|
260
|
+
return
|
|
261
|
+
if mode == "validate":
|
|
262
|
+
data = self.provider.validate_code(code=code)
|
|
263
|
+
elif mode == "explain":
|
|
264
|
+
file_path = body.get("file_path", "snippet.py")
|
|
265
|
+
data = self.provider.context_for_file(file_path=file_path)
|
|
266
|
+
else:
|
|
267
|
+
self._json(400, {"error": f"Unknown mode: {mode}"})
|
|
268
|
+
return
|
|
269
|
+
self._json(200, data)
|
|
270
|
+
elif path == "/api/tools/run":
|
|
271
|
+
tool_name = body.get("tool_name", "")
|
|
272
|
+
arguments = body.get("arguments", {})
|
|
273
|
+
if not tool_name:
|
|
274
|
+
self._json(400, {"error": "Missing required field: tool_name"})
|
|
275
|
+
return
|
|
276
|
+
try:
|
|
277
|
+
from semantic_code_intelligence.tools.executor import ToolExecutor
|
|
278
|
+
from semantic_code_intelligence.tools.protocol import ToolInvocation
|
|
279
|
+
executor = ToolExecutor(self.project_root)
|
|
280
|
+
invocation = ToolInvocation(tool_name=tool_name, arguments=arguments)
|
|
281
|
+
result = executor.execute(invocation)
|
|
282
|
+
self._json(200, result.to_dict())
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
self._json(500, {"error": str(exc)})
|
|
285
|
+
else:
|
|
286
|
+
self._json(404, {"error": "Not found"})
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
# UI dispatch
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _dispatch_ui(self, path: str, qs: dict[str, list[str]]) -> None:
|
|
293
|
+
pages: dict[str, str] = {
|
|
294
|
+
"/": page_search(),
|
|
295
|
+
"/symbols": page_symbols(),
|
|
296
|
+
"/tools": page_tools(),
|
|
297
|
+
"/quality": page_quality(),
|
|
298
|
+
"/ask": page_ask(),
|
|
299
|
+
"/workspace": page_workspace(),
|
|
300
|
+
"/viz": page_viz(),
|
|
301
|
+
}
|
|
302
|
+
content = pages.get(path)
|
|
303
|
+
if content:
|
|
304
|
+
self._html(200, content)
|
|
305
|
+
else:
|
|
306
|
+
self._html(404, _page("Not Found", '<div class="empty">Page not found.</div>'))
|
|
307
|
+
|
|
308
|
+
# ------------------------------------------------------------------
|
|
309
|
+
# Response helpers
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def _json(self, status: int, body: dict[str, Any]) -> None:
|
|
313
|
+
payload = json.dumps(body, indent=2, default=str).encode("utf-8")
|
|
314
|
+
self.send_response(status)
|
|
315
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
316
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
317
|
+
self._cors_headers()
|
|
318
|
+
self.end_headers()
|
|
319
|
+
self.wfile.write(payload)
|
|
320
|
+
|
|
321
|
+
def _html(self, status: int, content: str) -> None:
|
|
322
|
+
payload = content.encode("utf-8")
|
|
323
|
+
self.send_response(status)
|
|
324
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
325
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
326
|
+
self.end_headers()
|
|
327
|
+
self.wfile.write(payload)
|
|
328
|
+
|
|
329
|
+
def _cors_headers(self) -> None:
|
|
330
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
331
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
332
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class WebServer:
|
|
336
|
+
"""Combined web server serving API and UI on a single port.
|
|
337
|
+
|
|
338
|
+
Usage::
|
|
339
|
+
|
|
340
|
+
server = WebServer(Path("."), host="127.0.0.1", port=8080)
|
|
341
|
+
server.start() # blocks
|
|
342
|
+
# or
|
|
343
|
+
server.start_background()
|
|
344
|
+
server.stop()
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
DEFAULT_PORT = 8080
|
|
348
|
+
|
|
349
|
+
def __init__(
|
|
350
|
+
self,
|
|
351
|
+
project_root: Path,
|
|
352
|
+
host: str = "127.0.0.1",
|
|
353
|
+
port: int = DEFAULT_PORT,
|
|
354
|
+
) -> None:
|
|
355
|
+
self._root = project_root.resolve()
|
|
356
|
+
self._host = host
|
|
357
|
+
self._port = port
|
|
358
|
+
self._httpd: HTTPServer | None = None
|
|
359
|
+
self._thread: threading.Thread | None = None
|
|
360
|
+
self._provider = ContextProvider(self._root)
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def url(self) -> str:
|
|
364
|
+
return f"http://{self._host}:{self._port}"
|
|
365
|
+
|
|
366
|
+
def _make_server(self) -> HTTPServer:
|
|
367
|
+
_CombinedHandler.project_root = self._root
|
|
368
|
+
_CombinedHandler.provider = self._provider
|
|
369
|
+
return HTTPServer((self._host, self._port), _CombinedHandler)
|
|
370
|
+
|
|
371
|
+
def start(self) -> None:
|
|
372
|
+
"""Start the server (blocking)."""
|
|
373
|
+
self._httpd = self._make_server()
|
|
374
|
+
logger.info("CodexA web server listening on %s", self.url)
|
|
375
|
+
try:
|
|
376
|
+
self._httpd.serve_forever()
|
|
377
|
+
except KeyboardInterrupt:
|
|
378
|
+
pass
|
|
379
|
+
finally:
|
|
380
|
+
self._httpd.server_close()
|
|
381
|
+
|
|
382
|
+
def start_background(self) -> None:
|
|
383
|
+
"""Start in a background daemon thread."""
|
|
384
|
+
self._httpd = self._make_server()
|
|
385
|
+
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
|
|
386
|
+
self._thread.start()
|
|
387
|
+
logger.info("CodexA web server started in background on %s", self.url)
|
|
388
|
+
|
|
389
|
+
def stop(self) -> None:
|
|
390
|
+
"""Shut down the server."""
|
|
391
|
+
if self._httpd:
|
|
392
|
+
self._httpd.shutdown()
|
|
393
|
+
self._httpd.server_close()
|
|
394
|
+
self._httpd = None
|
|
395
|
+
if self._thread:
|
|
396
|
+
self._thread.join(timeout=5)
|
|
397
|
+
self._thread = None
|