Files
openswarm/backend/apps/outputs/static_serve.py
T

163 lines
6.5 KiB
Python

"""Serve-mode support (ENG-209): an open app that nobody is editing costs a whole vite dev server
(~270MB). When a FRESH built bundle exists and no agent is bound to the workspace, the runtime
serves `frontend/dist` statically through the existing workspace serve route instead of spawning
anything; vite comes back the moment an agent starts editing."""
import asyncio
import logging
import os
import subprocess
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from typing import Optional
from typeguard import typechecked
logger = logging.getLogger(__name__)
class P_BundleHandler(SimpleHTTPRequestHandler):
"""Static files out of the dist, with the SPA fallback every router app expects: a path that names
no file (and has no extension) is the app's own route, so it gets index.html, not a 404."""
def send_head(self): # type: ignore[override]
path = self.translate_path(self.path)
if not os.path.exists(path) and "." not in os.path.basename(self.path.split("?", 1)[0]):
self.path = "/"
return super().send_head()
def log_message(self, format, *args): # type: ignore[override]
return None
class BundleServer:
"""A loopback static server for one built bundle. Serve mode used to hand the app a deep path
under the backend's authenticated serve route, and every React Router app matched no route there
and rendered nothing (Haik's users, 2026-09-03: "frontend-only apps after reloading do not
render"). Vite serves the app at `/`; so does this, so the app cannot tell the two modes apart."""
def __init__(self, dist_dir: str) -> None:
self.dist_dir = dist_dir
self.port: Optional[int] = None
self.p_server: Optional[ThreadingHTTPServer] = None
self.p_thread: Optional[threading.Thread] = None
def start(self) -> int:
handler = partial(P_BundleHandler, directory=self.dist_dir)
self.p_server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
self.p_server.daemon_threads = True
self.port = int(self.p_server.server_address[1])
self.p_thread = threading.Thread(target=self.p_server.serve_forever, name=f"bundle-server-{self.port}", daemon=True)
self.p_thread.start()
return self.port
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.port}/"
def stop(self) -> None:
server, self.p_server = self.p_server, None
if server is not None:
server.shutdown()
server.server_close()
self.port = None
@typechecked
def dist_index(workspace_path: str) -> str:
return os.path.join(workspace_path, "frontend", "dist", "index.html")
@typechecked
def p_newest_source_mtime(workspace_path: str) -> float:
newest = 0.0
fe = os.path.join(workspace_path, "frontend")
for probe in ("package.json", "vite.config.mts", "vite.config.ts", "index.html"):
p = os.path.join(fe, probe)
if os.path.isfile(p):
newest = max(newest, os.path.getmtime(p))
src = os.path.join(fe, "src")
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = [d for d in dirnames if d != "node_modules"]
for f in filenames:
try:
newest = max(newest, os.path.getmtime(os.path.join(dirpath, f)))
except OSError:
pass
return newest
@typechecked
def static_fresh(workspace_path: str) -> bool:
"""A dist we can honestly serve: exists, newer than every source file, and built with RELATIVE
asset paths (vite `base: './'`). Absolute /assets/ URLs would resolve against the backend host
root and 404, so a dist built by an older template is refused rather than served broken."""
idx = dist_index(workspace_path)
if not os.path.isfile(idx):
return False
try:
with open(idx, encoding="utf-8", errors="ignore") as f:
html = f.read()
except OSError:
return False
if 'src="/' in html or 'href="/' in html:
return False
return os.path.getmtime(idx) >= p_newest_source_mtime(workspace_path)
@typechecked
def workspace_being_edited(workspace_path: str) -> bool:
"""True when a live agent session is bound to this workspace (launch sets the chat's cwd to the
app it edits), so serve-mode never hides an agent's in-flight changes behind a stale bundle."""
try:
from backend.apps.agents.agents import agent_manager
norm = os.path.realpath(workspace_path)
for session in agent_manager.sessions.values():
if session.status not in ("running", "waiting_approval"):
continue
cwd = getattr(session, "cwd", None)
if cwd and os.path.realpath(cwd) == norm:
return True
except Exception:
logger.debug("being-edited probe failed; assuming edited (vite)", exc_info=True)
return True
return False
@typechecked
async def build_dist_in_background(workspace_path: str, workspace_id: str) -> Optional[int]:
"""Fire `npm run build` for a parked app so the NEXT open can serve static. Best-effort: any
missing precondition (no node_modules, no build script) just means vite next time. Returns the
exit code, or None when the build was skipped."""
fe = os.path.join(workspace_path, "frontend")
if not os.path.isdir(os.path.join(fe, "node_modules")):
return None
if static_fresh(workspace_path):
return None
node_bin = os.path.dirname(os.environ.get("OPENSWARM_NODE_PATH", "") or "")
env = {**os.environ}
if node_bin:
env["PATH"] = f"{node_bin}:{env.get('PATH', '')}"
log_path = os.path.join(workspace_path, ".openswarm", "build.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True)
try:
with open(log_path, "w", encoding="utf-8") as log:
proc = await asyncio.create_subprocess_exec(
"npm", "run", "build",
cwd=fe, env=env, stdout=log, stderr=subprocess.STDOUT,
)
code = await asyncio.wait_for(proc.wait(), timeout=180)
logger.info("background dist build for %s exited %s", workspace_id, code)
return code
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
pass
logger.warning("background dist build for %s timed out", workspace_id)
return -1
except Exception:
logger.debug("background dist build for %s failed to spawn", workspace_id, exc_info=True)
return None