Files
the-nexus/mempalace/fleet_api.py
Alexander Whitestone 4c08119c9e
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Staging Verification Gate / verify-staging (push) Failing after 4s
fix: port 8080 conflict between L402 server and preview (#1415) (#1431)
Merge PR #1431
2026-04-14 22:11:56 +00:00

249 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
fleet_api.py — Lightweight HTTP API for the shared fleet palace.
Exposes fleet memory search and recording over HTTP so that Alpha servers and other
wizard deployments can query the palace without direct filesystem access.
Endpoints:
GET /health
Returns {"status": "ok", "palace": "<path>"}
GET /search?q=<query>[&room=<room>][&n=<int>]
Returns {"results": [...], "query": "...", "room": "...", "count": N}
Each result: {"text": "...", "room": "...", "wing": "...", "score": 0.9}
GET /wings
Returns {"wings": ["bezalel", ...]} — distinct wizard wings present
POST /record
Body: {"text": "...", "room": "...", "wing": "...", "source_file": "...", "metadata": {...}}
Returns {"success": true, "id": "..."}
Error responses use {"error": "<message>"} with appropriate HTTP status codes.
Usage:
# Default: localhost:7771, fleet palace at /var/lib/mempalace/fleet
python mempalace/fleet_api.py
# Custom host/port/palace:
FLEET_PALACE_PATH=/data/fleet python mempalace/fleet_api.py --host 0.0.0.0 --port 7772
Refs: #1078, #1075, #1085
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
# Add repo root to path so we can import nexus.mempalace
_HERE = Path(__file__).resolve().parent
_REPO_ROOT = _HERE.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 7771
MAX_RESULTS = 50
def _get_palace_path() -> Path:
return Path(os.environ.get("FLEET_PALACE_PATH", "/var/lib/mempalace/fleet"))
def _json_response(handler: BaseHTTPRequestHandler, status: int, body: dict) -> None:
payload = json.dumps(body).encode()
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(payload)))
handler.end_headers()
handler.wfile.write(payload)
def _handle_health(handler: BaseHTTPRequestHandler) -> None:
palace = _get_palace_path()
_json_response(handler, 200, {
"status": "ok",
"palace": str(palace),
"palace_exists": palace.exists(),
})
def _handle_search(handler: BaseHTTPRequestHandler, qs: dict) -> None:
query_terms = qs.get("q", [""])
q = query_terms[0].strip() if query_terms else ""
if not q:
_json_response(handler, 400, {"error": "Missing required parameter: q"})
return
room_terms = qs.get("room", [])
room = room_terms[0].strip() if room_terms else None
n_terms = qs.get("n", [])
try:
n = max(1, min(int(n_terms[0]), MAX_RESULTS)) if n_terms else 10
except (ValueError, IndexError):
_json_response(handler, 400, {"error": "Invalid parameter: n must be an integer"})
return
try:
from nexus.mempalace.searcher import search_fleet, MemPalaceUnavailable
except ImportError as exc:
_json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"})
return
try:
results = search_fleet(q, room=room, n_results=n)
except Exception as exc: # noqa: BLE001
_json_response(handler, 503, {"error": str(exc)})
return
_json_response(handler, 200, {
"query": q,
"room": room,
"count": len(results),
"results": [
{
"text": r.text,
"room": r.room,
"wing": r.wing,
"score": round(r.score, 4),
}
for r in results
],
})
def _handle_wings(handler: BaseHTTPRequestHandler) -> None:
"""Return distinct wizard wing names found in the fleet palace directory."""
palace = _get_palace_path()
if not palace.exists():
_json_response(handler, 503, {
"error": f"Fleet palace not found: {palace}",
})
return
wings = sorted({
p.name for p in palace.iterdir() if p.is_dir()
})
_json_response(handler, 200, {"wings": wings})
def _handle_record(handler: BaseHTTPRequestHandler) -> None:
"""Handle POST /record to add a new memory."""
content_length = int(handler.headers.get("Content-Length", 0))
if not content_length:
_json_response(handler, 400, {"error": "Missing request body"})
return
try:
body = json.loads(handler.rfile.read(content_length))
except json.JSONDecodeError:
_json_response(handler, 400, {"error": "Invalid JSON body"})
return
text = body.get("text", "").strip()
if not text:
_json_response(handler, 400, {"error": "Missing required field: text"})
return
room = body.get("room", "general")
wing = body.get("wing")
source_file = body.get("source_file", "")
metadata = body.get("metadata", {})
try:
from nexus.mempalace.searcher import add_memory, MemPalaceUnavailable
except ImportError as exc:
_json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"})
return
try:
# Note: add_memory uses MEMPALACE_PATH by default.
# For fleet_api, we should probably use FLEET_PALACE_PATH.
palace_path = _get_palace_path()
doc_id = add_memory(
text=text,
room=room,
wing=wing,
palace_path=palace_path,
source_file=source_file,
extra_metadata=metadata
)
_json_response(handler, 201, {"success": True, "id": doc_id})
except Exception as exc:
_json_response(handler, 503, {"error": str(exc)})
class FleetAPIHandler(BaseHTTPRequestHandler):
"""Request handler for the fleet memory API."""
def log_message(self, fmt: str, *args) -> None: # noqa: ANN001
# Prefix with tag for easier log filtering
sys.stderr.write(f"[fleet_api] {fmt % args}\n")
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
qs = parse_qs(parsed.query)
if path == "/health":
_handle_health(self)
elif path == "/search":
_handle_search(self, qs)
elif path == "/wings":
_handle_wings(self)
else:
_json_response(self, 404, {
"error": f"Unknown endpoint: {path}",
"endpoints": ["/health", "/search", "/wings"],
})
def do_POST(self) -> None: # noqa: N802
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
if path == "/record":
_handle_record(self)
else:
_json_response(self, 404, {
"error": f"Unknown endpoint: {path}",
"endpoints": ["/record"],
})
def make_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> HTTPServer:
return HTTPServer((host, port), FleetAPIHandler)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Fleet palace HTTP API server."
)
parser.add_argument("--host", default=DEFAULT_HOST, help=f"Bind host (default: {DEFAULT_HOST})")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Bind port (default: {DEFAULT_PORT})")
args = parser.parse_args(argv)
palace = _get_palace_path()
print(f"[fleet_api] Palace: {palace}")
if not palace.exists():
print(f"[fleet_api] WARNING: palace path does not exist yet: {palace}", file=sys.stderr)
server = make_server(args.host, args.port)
print(f"[fleet_api] Listening on http://{args.host}:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[fleet_api] Shutting down.")
return 0
if __name__ == "__main__":
sys.exit(main())