mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 12:12:22 +02:00
113 lines
4.4 KiB
Python
113 lines
4.4 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
|
|
from typing import Optional
|
|
|
|
from typeguard import typechecked
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@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
|