"""Stdlib HTTP server for the integration-failure-triage Space. Replaces the old `python -m http.server` + bash refresh loop. Responsibilities: * Serve `output/index.html` and `output/state.json` like the previous setup. * Inject a small banner at the top of the served HTML (right after ``) with a "Refresh now" form button and the last-refresh timestamp. * `POST /refresh` → kicks `python app/refresh.py --no-history` in a subprocess. Returns 202 immediately + `Refresh-Job-Id` header. Only one concurrent refresh — second concurrent POST returns 409. * `GET /status` → JSON describing background-job state. * Background thread: synchronous initial render on startup, then loops every `REFRESH_INTERVAL_SECONDS` (default 21600 = 6h) running the full refresh (with history sweep). No third-party deps — `http.server` only. """ from __future__ import annotations import datetime import io import json import os import subprocess import sys import threading import time import uuid from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # --------------------------------------------------------------------------- # Paths and config # --------------------------------------------------------------------------- APP_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_DIR = os.path.dirname(APP_DIR) OUTPUT_DIR = os.environ.get("OUTPUT_DIR", os.path.join(REPO_DIR, "output")) REFRESH_SCRIPT = os.path.join(APP_DIR, "refresh.py") PORT = int(os.environ.get("PORT", "7860")) REFRESH_INTERVAL_SECONDS = int(os.environ.get("REFRESH_INTERVAL_SECONDS", "21600")) BISECT_MAX_BUCKETS = int(os.environ.get("BISECT_MAX_BUCKETS", "1")) # Whether to even expose the /bisect endpoint. The bisect step needs a GPU # upgrade + a local `transformers` clone — on cpu-basic it will exit ~125 (skip) # for every iteration. Enable explicitly once the Space is on suitable hardware. BISECT_ENABLED = os.environ.get("BISECT_ENABLED", "1") not in ("0", "false", "False") # --------------------------------------------------------------------------- # Job state — module-level globals, protected by _job_lock. # --------------------------------------------------------------------------- _job_lock = threading.Lock() _job_state: dict = { "running": False, "job_id": None, "started_at": None, # ISO-8601 UTC, set when a job starts "last_finished_at": None, # ISO-8601 UTC, set when a job ends "last_exit_code": None, # int, set when a job ends "last_job_id": None, "last_log_path": None, } # Parallel state machine for the bisect job (independent single-flight). _bisect_lock = threading.Lock() _bisect_state: dict = { "running": False, "job_id": None, "started_at": None, "last_finished_at": None, "last_exit_code": None, "last_job_id": None, "last_log_path": None, } def _utcnow_iso() -> str: return datetime.datetime.now(datetime.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") def _public_status() -> dict: """Snapshot of job state suitable for JSON serialisation. Caller-locked.""" return { "running": _job_state["running"], "started_at": _job_state["started_at"], "last_finished_at": _job_state["last_finished_at"], "last_exit_code": _job_state["last_exit_code"], } def _public_bisect_status() -> dict: """Snapshot of bisect state. Caller holds `_bisect_lock`.""" return { "enabled": BISECT_ENABLED, "running": _bisect_state["running"], "started_at": _bisect_state["started_at"], "last_finished_at": _bisect_state["last_finished_at"], "last_exit_code": _bisect_state["last_exit_code"], } def _run_bisect_subprocess(job_id: str, extra_args: list[str]) -> None: """Run `python -m app.spot_bisect ` from REPO_DIR. -m form is required so the stdlib `bisect` module isn't shadowed by our `app/spot_bisect.py` file (the rename was the workaround). The script writes its cache to `OUTPUT_DIR/data/bisect_cache.json` (bucket-backed in prod). """ log_path = f"/tmp/bisect-{job_id}.log" cmd = [sys.executable, "-m", "app.spot_bisect", *extra_args] with _bisect_lock: _bisect_state["last_log_path"] = log_path try: with open(log_path, "wb") as logf: proc = subprocess.Popen( cmd, cwd=REPO_DIR, stdout=logf, stderr=subprocess.STDOUT, ) exit_code = proc.wait() except Exception as exc: # noqa: BLE001 try: with open(log_path, "ab") as logf: logf.write(f"\n[server.py] launching bisect failed: {exc!r}\n".encode()) except Exception: pass exit_code = -1 with _bisect_lock: _bisect_state["running"] = False _bisect_state["last_finished_at"] = _utcnow_iso() _bisect_state["last_exit_code"] = int(exit_code) _bisect_state["last_job_id"] = job_id _bisect_state["started_at"] = None _bisect_state["job_id"] = None def _try_start_bisect(extra_args: list[str]) -> str | None: """Start a bisect job (`spot_bisect --execute`) if none is in flight. Returns job_id on success, None if a bisect is already running. """ if not BISECT_ENABLED: return None with _bisect_lock: if _bisect_state["running"]: return None job_id = uuid.uuid4().hex[:12] _bisect_state["running"] = True _bisect_state["job_id"] = job_id _bisect_state["started_at"] = _utcnow_iso() t = threading.Thread( target=_run_bisect_subprocess, args=(job_id, extra_args), name=f"bisect-{job_id}", daemon=True, ) t.start() return job_id def _run_refresh_subprocess(job_id: str, extra_args: list[str]) -> None: """Run `python app/refresh.py ` and update state on exit.""" log_path = f"/tmp/refresh-{job_id}.log" cmd = [sys.executable, REFRESH_SCRIPT, *extra_args] with _job_lock: _job_state["last_log_path"] = log_path try: with open(log_path, "wb") as logf: proc = subprocess.Popen( cmd, cwd=REPO_DIR, stdout=logf, stderr=subprocess.STDOUT, ) exit_code = proc.wait() except Exception as exc: # noqa: BLE001 # Log the failure to the same file so users can debug it. try: with open(log_path, "ab") as logf: logf.write(f"\n[server.py] launching refresh failed: {exc!r}\n".encode()) except Exception: pass exit_code = -1 with _job_lock: _job_state["running"] = False _job_state["last_finished_at"] = _utcnow_iso() _job_state["last_exit_code"] = int(exit_code) _job_state["last_job_id"] = job_id _job_state["started_at"] = None _job_state["job_id"] = None def _try_start_refresh(extra_args: list[str]) -> str | None: """Try to start a refresh. Returns job_id on success, None if one is already running.""" with _job_lock: if _job_state["running"]: return None job_id = uuid.uuid4().hex[:12] _job_state["running"] = True _job_state["job_id"] = job_id _job_state["started_at"] = _utcnow_iso() t = threading.Thread( target=_run_refresh_subprocess, args=(job_id, extra_args), name=f"refresh-{job_id}", daemon=True, ) t.start() return job_id # --------------------------------------------------------------------------- # Banner injection # --------------------------------------------------------------------------- def _job_summary(state: dict, lock, label: str) -> str: """Return a one-line HTML span summarizing a job's state (refresh or bisect).""" with lock: running = state["running"] started_at = state["started_at"] last_finished_at = state["last_finished_at"] last_exit_code = state["last_exit_code"] if running: return f'{label}: running… \U0001f504 (started {started_at or "?"})' if last_exit_code is None: return f'{label}: idle' if last_exit_code == 0: return f'{label}: ok @ {last_finished_at}' return f'{label}: exit={last_exit_code} @ {last_finished_at}' def _format_banner() -> str: refresh_summary = _job_summary(_job_state, _job_lock, "Refresh") bisect_summary = _job_summary(_bisect_state, _bisect_lock, "Bisect") if BISECT_ENABLED else "" btn = ( 'style="background:#1f6feb;color:white;border:none;' 'padding:6px 12px;border-radius:5px;cursor:pointer;font-size:13px;margin-left:6px;"' ) bisect_form = "" if BISECT_ENABLED: bisect_form = ( '
\n' f' \n' '
\n' ) sep = " · " if bisect_summary else "" return ( '
\n' f' {refresh_summary}{sep}{bisect_summary}\n' ' \n' '
\n' f' \n' '
\n' f'{bisect_form}' '
\n' '
' ) def _inject_banner(html: bytes) -> bytes: """Insert the banner right after the first `` tag. String-replace at request time — `render.py` is not aware of the banner. """ banner = _format_banner().encode("utf-8") # Match `` and any `` variant, only first occurrence. needle_simple = b"" idx = html.find(needle_simple) if idx != -1: insert_at = idx + len(needle_simple) return html[:insert_at] + b"\n" + banner + b"\n" + html[insert_at:] # Fallback: try a regex-ish manual scan for ``. lower = html.lower() idx = lower.find(b"", idx) if end != -1: insert_at = end + 1 return html[:insert_at] + b"\n" + banner + b"\n" + html[insert_at:] # No body tag — prepend banner so it's still visible. return banner + b"\n" + html # --------------------------------------------------------------------------- # HTTP handler # --------------------------------------------------------------------------- class TriageHandler(BaseHTTPRequestHandler): server_version = "TriageServer/1.0" # Quieter logs — default BaseHTTPRequestHandler is noisy on stderr. def log_message(self, format: str, *args) -> None: # noqa: A002 sys.stderr.write( "[%s] %s - - %s\n" % (self.log_date_time_string(), self.address_string(), format % args) ) # ----- GET ----- def do_GET(self) -> None: # noqa: N802 path = self.path.split("?", 1)[0] if path == "/" or path == "/index.html": return self._serve_index() if path == "/state.json": return self._serve_static(os.path.join(OUTPUT_DIR, "state.json"), "application/json") if path == "/status": return self._serve_status() if path == "/healthz": return self._send_json(HTTPStatus.OK, {"ok": True}) if path == "/favicon.ico": self.send_response(HTTPStatus.NO_CONTENT) self.end_headers() return # Allow serving any other file from OUTPUT_DIR (e.g. /data/* if present). return self._serve_from_output(path) def _serve_index(self) -> None: html_path = os.path.join(OUTPUT_DIR, "index.html") if not os.path.isfile(html_path): return self._send_text( HTTPStatus.SERVICE_UNAVAILABLE, "Report not generated yet. Try again in a moment.\n", ) with open(html_path, "rb") as f: html = f.read() injected = _inject_banner(html) self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(injected))) self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(injected) def _serve_static(self, path: str, content_type: str) -> None: if not os.path.isfile(path): return self._send_text(HTTPStatus.NOT_FOUND, "not found\n") with open(path, "rb") as f: data = f.read() self.send_response(HTTPStatus.OK) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(data) def _serve_from_output(self, url_path: str) -> None: # Strip leading slash, normalise, prevent traversal. rel = url_path.lstrip("/") abs_path = os.path.realpath(os.path.join(OUTPUT_DIR, rel)) if not abs_path.startswith(os.path.realpath(OUTPUT_DIR) + os.sep) and abs_path != os.path.realpath(OUTPUT_DIR): return self._send_text(HTTPStatus.FORBIDDEN, "forbidden\n") if os.path.isdir(abs_path): return self._send_text(HTTPStatus.NOT_FOUND, "not found\n") if not os.path.isfile(abs_path): return self._send_text(HTTPStatus.NOT_FOUND, "not found\n") # Cheap content-type guess ext = os.path.splitext(abs_path)[1].lower() ctype = { ".html": "text/html; charset=utf-8", ".json": "application/json", ".css": "text/css", ".js": "application/javascript", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".txt": "text/plain; charset=utf-8", }.get(ext, "application/octet-stream") return self._serve_static(abs_path, ctype) def _serve_status(self) -> None: with _job_lock: refresh_payload = _public_status() with _bisect_lock: bisect_payload = _public_bisect_status() # Keep the old top-level keys (refresh state) for backwards compatibility # with anyone polling /status today; add a `bisect` sub-object. self._send_json(HTTPStatus.OK, {**refresh_payload, "bisect": bisect_payload}) # ----- POST ----- def do_POST(self) -> None: # noqa: N802 path = self.path.split("?", 1)[0] if path == "/refresh": return self._handle_post_job( kind="refresh", starter=lambda: _try_start_refresh(["--no-history"]), state=_job_state, lock=_job_lock, header_name="Refresh-Job-Id", disabled_reason=None, ) if path == "/bisect": if not BISECT_ENABLED: return self._send_json( HTTPStatus.SERVICE_UNAVAILABLE, {"error": "bisect endpoint disabled (set BISECT_ENABLED=1 on a GPU-capable Space)"}, ) return self._handle_post_job( kind="bisect", starter=lambda: _try_start_bisect([ "--execute", "--max-buckets", str(BISECT_MAX_BUCKETS), ]), state=_bisect_state, lock=_bisect_lock, header_name="Bisect-Job-Id", disabled_reason=None, ) return self._send_text(HTTPStatus.NOT_FOUND, "not found\n") def _handle_post_job(self, *, kind: str, starter, state: dict, lock, header_name: str, disabled_reason: str | None) -> None: # Drain any request body (forms send empty body but be polite). length = int(self.headers.get("Content-Length") or "0") if length: try: self.rfile.read(length) except Exception: pass if disabled_reason is not None: return self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"error": disabled_reason}) job_id = starter() if job_id is None: with lock: current_id = state["job_id"] body = json.dumps({ "error": f"{kind} already in progress", "running_job_id": current_id, }).encode("utf-8") self.send_response(HTTPStatus.CONFLICT) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) if current_id: self.send_header(header_name, current_id) self.end_headers() self.wfile.write(body) return body = json.dumps({"job_id": job_id, "status": "accepted"}).encode("utf-8") self.send_response(HTTPStatus.ACCEPTED) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header(header_name, job_id) accept = (self.headers.get("Accept") or "").lower() if "text/html" in accept: self.send_header("Location", "/") self.end_headers() self.wfile.write(body) # ----- helpers ----- def _send_text(self, status: HTTPStatus, text: str) -> None: data = text.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _send_json(self, status: HTTPStatus, payload: dict) -> None: data = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(data) # --------------------------------------------------------------------------- # Background refresh loop (replaces the bash while-loop) # --------------------------------------------------------------------------- def _initial_refresh_sync() -> None: """Run a fast initial refresh in-process so the page is up immediately. Uses `refresh.main([...])` directly rather than a subprocess so that we block until the file is on disk before opening the HTTP port. Errors are swallowed and logged so the server still starts. """ sys.path.insert(0, APP_DIR) try: import refresh # type: ignore[import-not-found] except Exception as exc: # noqa: BLE001 print(f"[server.py] failed to import refresh: {exc!r}", file=sys.stderr, flush=True) return print("[server.py] initial render (--no-history)…", flush=True) t0 = time.time() job_id = uuid.uuid4().hex[:12] log_path = f"/tmp/refresh-{job_id}.log" with _job_lock: _job_state["running"] = True _job_state["job_id"] = job_id _job_state["started_at"] = _utcnow_iso() _job_state["last_log_path"] = log_path exit_code = 0 try: refresh.main(["--no-history", "--out-dir", OUTPUT_DIR]) except SystemExit as e: exit_code = int(e.code) if isinstance(e.code, int) else (0 if not e.code else 1) except Exception as exc: # noqa: BLE001 print(f"[server.py] initial refresh failed: {exc!r}", file=sys.stderr, flush=True) exit_code = 1 elapsed = time.time() - t0 print(f"[server.py] initial render done in {elapsed:.1f}s (exit={exit_code})", flush=True) with _job_lock: _job_state["running"] = False _job_state["last_finished_at"] = _utcnow_iso() _job_state["last_exit_code"] = exit_code _job_state["last_job_id"] = job_id _job_state["started_at"] = None _job_state["job_id"] = None def _scheduled_loop() -> None: """Cron-style loop: every REFRESH_INTERVAL_SECONDS, kick a full refresh.""" while True: time.sleep(REFRESH_INTERVAL_SECONDS) # Use the same single-flight gate; if a user-triggered refresh is in # flight, just skip this tick. job_id = _try_start_refresh(["--history-days", "90"]) if job_id is None: print("[server.py] scheduled refresh skipped — another in flight", flush=True) else: print(f"[server.py] scheduled refresh started job={job_id}", flush=True) # --------------------------------------------------------------------------- # Entrypoint # --------------------------------------------------------------------------- def main() -> None: os.makedirs(OUTPUT_DIR, exist_ok=True) # Only run the initial sync refresh if we don't already have an index.html # on disk (so reloads / local dev don't pay the fetch cost every start). # Toggle off via SKIP_INITIAL_REFRESH=1. skip_initial = os.environ.get("SKIP_INITIAL_REFRESH") == "1" have_index = os.path.isfile(os.path.join(OUTPUT_DIR, "index.html")) if not skip_initial and not have_index: _initial_refresh_sync() elif have_index: print( f"[server.py] reusing existing {OUTPUT_DIR}/index.html " "(set SKIP_INITIAL_REFRESH=0 + delete to force re-render)", flush=True, ) # Start the scheduled-refresh thread. t = threading.Thread(target=_scheduled_loop, name="refresh-scheduler", daemon=True) t.start() httpd = ThreadingHTTPServer(("0.0.0.0", PORT), TriageHandler) print(f"[server.py] serving on http://0.0.0.0:{PORT} (output={OUTPUT_DIR})", flush=True) try: httpd.serve_forever() except KeyboardInterrupt: print("[server.py] shutting down", flush=True) finally: httpd.server_close() if __name__ == "__main__": main()