mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 21:27:41 +02:00
[eric] apps: an app nobody is editing serves its built bundle with no dev-server process (~270MB -> ~0), vite reboots the moment an agent binds or the dist goes stale
This commit is contained in:
@@ -61,6 +61,13 @@ class AgentLaunch(AgentManagerProtocol):
|
||||
):
|
||||
from backend.apps.outputs.workspace_io import app_workspace_dir
|
||||
bound = app_workspace_dir(config.selected_app_output_ids[0])
|
||||
if bound:
|
||||
# The user is about to EDIT this app: a serve-static runtime must boot vite now or the agent's changes render nowhere (ENG-209).
|
||||
try:
|
||||
from backend.apps.outputs.runtime import manager as p_rt_manager
|
||||
await p_rt_manager.ensure_editing(bound)
|
||||
except Exception:
|
||||
pass
|
||||
if bound:
|
||||
config.target_directory = bound
|
||||
|
||||
|
||||
@@ -120,18 +120,23 @@ async def serve_workspace_file(workspace_id: str, filepath: str, p_d: str = ""):
|
||||
if not os.path.isfile(full_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
with open(full_path) as f:
|
||||
content = f.read()
|
||||
|
||||
if filepath == "index.html":
|
||||
# endswith, not equality: serve-mode delivers frontend/dist/index.html through this same route and needs the identical injection + token rewrite (ENG-209).
|
||||
if filepath.endswith("index.html"):
|
||||
with open(full_path) as f:
|
||||
content = f.read()
|
||||
input_json, result_json = decode_data_param(p_d) if p_d else ("{}", "null")
|
||||
backend_url_json = backend_url_for_workspace(workspace_id)
|
||||
content = inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
|
||||
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the parent's ?token= query string, so rewrite the HTML to put the token back on every relative URL; otherwise sub-resources 401.
|
||||
content = inject_token_into_relative_urls(content, get_auth_token())
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
return Response(content=content, media_type=mime or "text/plain")
|
||||
|
||||
# Binary-safe for everything else: a built bundle carries fonts and images that a text read would corrupt.
|
||||
with open(full_path, "rb") as f:
|
||||
raw = f.read()
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
return Response(content=content, media_type=mime or "text/plain")
|
||||
return Response(content=raw, media_type=mime or "application/octet-stream")
|
||||
|
||||
|
||||
@outputs.router.get("/{output_id}/serve/{filepath:path}")
|
||||
@@ -460,6 +465,8 @@ def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict:
|
||||
"running": rt.running,
|
||||
# 'spawned' vs 'serving': ready flips only once the primary port answered the bind poll (and un-flips when the process dies or is frozen).
|
||||
"ready": rt.ready,
|
||||
# True when the app is served as a built bundle with no dev-server process (ENG-209).
|
||||
"serve_static": rt.serve_static,
|
||||
"port": rt.port,
|
||||
"serving_url": serving_url,
|
||||
"has_backend_file": rt.has_backend_file,
|
||||
|
||||
@@ -108,6 +108,8 @@ class AppRuntime:
|
||||
# Old-mode: `port` is the backend.py port. New-mode: `port` is the workspace's optional FastAPI backend (only set if BACKEND_PORT!=NONE) and `frontend_port` is the Vite dev server port. Both Nones until start() decides what's there.
|
||||
self.port: Optional[int] = None
|
||||
self.frontend_port: Optional[int] = None
|
||||
# Serve-mode (ENG-209): a fresh built bundle + no agent editing means NO process at all; the workspace serve route delivers frontend/dist and this flag makes ready/frontend_url say so.
|
||||
self.serve_static: bool = False
|
||||
# New-mode only: flips True once something is actually listening on frontend_port (we kick off a background poll task in p_start_new_mode). frontend_url returns null until this flips, so the preview pane doesn't try to navigate to an unbound port and show a "Site can't be reached" error mid-npm-install.
|
||||
self.p_frontend_ready: bool = False
|
||||
# True while the process tree is SIGSTOP'd in the idle pool. A frozen vite still holds its port but can't answer it, so frontend_url must stay null while suspended (else the webview loads a dead port = the ERR_FAILED on fast app-switching).
|
||||
@@ -174,6 +176,8 @@ class AppRuntime:
|
||||
"""True only when the runtime is actually SERVING (process alive, not frozen, and its
|
||||
primary port answered the bind poll), so callers can tell 'spawned' from 'serving'.
|
||||
Old-mode workspaces have no bind poll; a live process is their best readiness signal."""
|
||||
if self.serve_static:
|
||||
return True
|
||||
if not self.running or self.p_suspended:
|
||||
return False
|
||||
if self.is_new_mode:
|
||||
@@ -183,6 +187,11 @@ class AppRuntime:
|
||||
@property
|
||||
def frontend_url(self) -> Optional[str]:
|
||||
# Gated on `_frontend_ready` (set by the background bind-poll task in p_start_new_mode) so the preview pane only switches over once Vite is actually accepting connections. Without this, the editor flashes a "Site can't be reached" error while `npm install` is running. Also gated on `running`: a vite that crashed or got orphaned still has _frontend_ready=True, and handing the webview that dead port is the ERR_FAILED you see on reopen. No live process, no URL. And gated on `not _suspended`: a SIGSTOP'd idle runtime is "running" (returncode is None) but frozen, so its port won't answer.
|
||||
if self.serve_static:
|
||||
# The existing workspace serve route: index injection + token rewrite apply, and the dist's relative assets resolve under the same path.
|
||||
from backend.auth import init_auth_token
|
||||
p_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
return f"http://127.0.0.1:{p_port}/api/outputs/workspace/{self.workspace_id}/serve/frontend/dist/index.html?token={init_auth_token()}"
|
||||
if self.frontend_port and self.p_frontend_ready and self.running and not self.p_suspended:
|
||||
return f"http://127.0.0.1:{self.frontend_port}/"
|
||||
return None
|
||||
@@ -230,6 +239,14 @@ class AppRuntime:
|
||||
return await self.p_start_old_mode()
|
||||
|
||||
async def p_start_new_mode(self) -> bool:
|
||||
# Serve-mode (ENG-209): a fresh built bundle + nobody editing = no process at all. Primary
|
||||
# instance only (secondaries are explicitly "another independent window", keep them live).
|
||||
if self.instance == 1:
|
||||
from backend.apps.outputs.static_serve import static_fresh, workspace_being_edited
|
||||
if static_fresh(self.workspace_path) and not workspace_being_edited(self.workspace_path):
|
||||
self.serve_static = True
|
||||
self.p_broadcast(LogLine("runtime", "[runtime] serving the built bundle (no dev server); editing an app boots vite automatically"))
|
||||
return True
|
||||
# Legacy workspaces (scaffolded pre-multi-instance) ignore the forced ports; self-heal their run.sh so a second instance stops colliding on the primary's ports.
|
||||
ensure_force_port_shim(self.workspace_path)
|
||||
env_path = os.path.join(self.workspace_path, ".env")
|
||||
@@ -672,8 +689,15 @@ class AppRuntimeManager:
|
||||
# Workspace paths shouldn't change for a given id, but if somehow they did (e.g. the user moved the workspace folder), trust the latest caller; they have the current truth.
|
||||
rt.workspace_path = workspace_path
|
||||
self.p_attached[key] = self.p_attached.get(key, 0) + 1
|
||||
if not revived and not rt.running:
|
||||
if not revived and not rt.running and not rt.serve_static:
|
||||
await rt.start()
|
||||
# A serve-static runtime re-checks its world on every attach: an agent may have bound to the
|
||||
# workspace since, or the dist may have gone stale; either flips it back to a real vite boot.
|
||||
if rt.serve_static:
|
||||
from backend.apps.outputs.static_serve import static_fresh, workspace_being_edited
|
||||
if workspace_being_edited(rt.workspace_path) or not static_fresh(rt.workspace_path):
|
||||
rt.serve_static = False
|
||||
await rt.start()
|
||||
# Stop any dead idle runtime outside the lock to avoid blocking.
|
||||
if dead is not None:
|
||||
try:
|
||||
@@ -682,6 +706,15 @@ class AppRuntimeManager:
|
||||
logger.exception("failed to reap dead idle runtime %s", key)
|
||||
return rt
|
||||
|
||||
async def ensure_editing(self, workspace_path: str) -> None:
|
||||
"""An agent just bound to this workspace: any serve-static runtime for it must become a real
|
||||
vite dev server, or the agent edits files the user never sees."""
|
||||
norm = os.path.realpath(workspace_path)
|
||||
for rt in list(self.runtimes.values()) + list(self.idle_lru.values()):
|
||||
if os.path.realpath(rt.workspace_path) == norm and rt.serve_static:
|
||||
rt.serve_static = False
|
||||
await rt.start()
|
||||
|
||||
async def detach(self, workspace_id: str, instance: int = 1) -> None:
|
||||
key = runtime_key(workspace_id, instance)
|
||||
to_idle: Optional[AppRuntime] = None
|
||||
@@ -695,6 +728,10 @@ class AppRuntimeManager:
|
||||
rt = self.runtimes.pop(key, None)
|
||||
if rt is None:
|
||||
return
|
||||
# Park-time dist build (ENG-209): a vite runtime going idle is the moment to bake the static bundle, so the NEXT open serves it processlessly. Fire-and-forget; failure just means vite again.
|
||||
if rt.running and not rt.serve_static and rt.instance == 1 and is_new_mode(rt.workspace_path):
|
||||
from backend.apps.outputs.static_serve import build_dist_in_background
|
||||
asyncio.create_task(build_dist_in_background(rt.workspace_path, rt.workspace_id))
|
||||
# If the process is already dead, no point keeping it around; just clean up. Otherwise move to the LRU AND SIGSTOP the process tree so it consumes 0% CPU while idle. The matching SIGCONT lives in attach() above.
|
||||
if not rt.running:
|
||||
to_reap.append(rt)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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.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
|
||||
@@ -49,6 +49,8 @@ export default defineConfig(({ mode }) => {
|
||||
const backendEnabled = backendPort && backendPort !== 'NONE';
|
||||
|
||||
return {
|
||||
// Relative asset URLs: the built bundle is served under /api/outputs/workspace/<id>/serve/frontend/dist/, where absolute /assets/ paths would 404 (ENG-209 serve-mode).
|
||||
base: './',
|
||||
cacheDir: sharedViteCacheDir(),
|
||||
plugins: [
|
||||
react(),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""ENG-209: an open app nobody is editing serves its built bundle with NO process; vite comes back
|
||||
the moment an agent binds or the dist goes stale. Every transition is covered, since a wrong switch
|
||||
would break preview for every app."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
from backend.apps.outputs import static_serve
|
||||
from backend.apps.outputs.runtime import AppRuntime, AppRuntimeManager
|
||||
|
||||
|
||||
def p_seed(tmp_path, relative_assets=True, with_dist=True):
|
||||
ws = tmp_path / "ws"
|
||||
fe = ws / "frontend"
|
||||
(fe / "src").mkdir(parents=True)
|
||||
(fe / "src" / "App.tsx").write_text("export {}")
|
||||
(fe / "package.json").write_text("{}")
|
||||
(ws / "run.sh").write_text("#!/bin/bash\n")
|
||||
if with_dist:
|
||||
dist = fe / "dist"
|
||||
dist.mkdir()
|
||||
src = "./assets/index-abc.js" if relative_assets else "/assets/index-abc.js"
|
||||
(dist / "index.html").write_text(f'<html><script src="{src}"></script></html>')
|
||||
return str(ws)
|
||||
|
||||
|
||||
def test_static_fresh_requires_dist_newer_than_source_and_relative_assets(tmp_path):
|
||||
ws = p_seed(tmp_path)
|
||||
assert static_serve.static_fresh(ws) is True
|
||||
time.sleep(0.02)
|
||||
os.utime(os.path.join(ws, "frontend", "src", "App.tsx"))
|
||||
assert static_serve.static_fresh(ws) is False
|
||||
assert static_serve.static_fresh(p_seed(tmp_path / "abs", relative_assets=False)) is False
|
||||
assert static_serve.static_fresh(p_seed(tmp_path / "nodist", with_dist=False)) is False
|
||||
|
||||
|
||||
def test_start_serves_static_when_not_edited(tmp_path, monkeypatch):
|
||||
ws = p_seed(tmp_path)
|
||||
monkeypatch.setattr(static_serve, "workspace_being_edited", lambda p: False)
|
||||
rt = AppRuntime("ws-t", ws)
|
||||
assert asyncio.run(rt.start()) is True
|
||||
assert rt.serve_static is True and rt.process is None and rt.ready is True
|
||||
assert "/serve/frontend/dist/index.html" in (rt.frontend_url or "")
|
||||
|
||||
|
||||
def test_start_skips_serve_when_edited(tmp_path, monkeypatch):
|
||||
ws = p_seed(tmp_path)
|
||||
monkeypatch.setattr(static_serve, "workspace_being_edited", lambda p: True)
|
||||
rt = AppRuntime("ws-t2", ws)
|
||||
spawned = {"n": 0}
|
||||
|
||||
async def p_no_spawn(env):
|
||||
spawned["n"] += 1
|
||||
return None, ws, "stub"
|
||||
monkeypatch.setattr(rt, "p_resolve_launch", p_no_spawn)
|
||||
try:
|
||||
asyncio.run(rt.start())
|
||||
except Exception:
|
||||
pass
|
||||
assert rt.serve_static is False and spawned["n"] == 1
|
||||
|
||||
|
||||
def p_manager_no_watcher(monkeypatch):
|
||||
# The restart-sentinel watcher is a forever loop; tests must not arm it or asyncio.run leaks a task.
|
||||
monkeypatch.setattr(AppRuntimeManager, "p_ensure_restart_watcher", lambda self: None)
|
||||
return AppRuntimeManager()
|
||||
|
||||
|
||||
def test_ensure_editing_flips_serve_runtime_to_vite(tmp_path, monkeypatch):
|
||||
ws = p_seed(tmp_path)
|
||||
mgr = p_manager_no_watcher(monkeypatch)
|
||||
rt = AppRuntime("ws-t", ws)
|
||||
rt.serve_static = True
|
||||
restarted = {"n": 0}
|
||||
|
||||
async def p_fake_start():
|
||||
restarted["n"] += 1
|
||||
return True
|
||||
monkeypatch.setattr(rt, "start", p_fake_start)
|
||||
mgr.runtimes["ws-t"] = rt
|
||||
asyncio.run(mgr.ensure_editing(ws))
|
||||
assert rt.serve_static is False and restarted["n"] == 1
|
||||
|
||||
|
||||
def test_attach_recheck_reboots_when_dist_goes_stale(tmp_path, monkeypatch):
|
||||
ws = p_seed(tmp_path)
|
||||
mgr = p_manager_no_watcher(monkeypatch)
|
||||
rt = AppRuntime("ws-t", ws)
|
||||
rt.serve_static = True
|
||||
restarted = {"n": 0}
|
||||
|
||||
async def p_fake_start():
|
||||
restarted["n"] += 1
|
||||
return True
|
||||
monkeypatch.setattr(rt, "start", p_fake_start)
|
||||
mgr.runtimes["ws-t"] = rt
|
||||
|
||||
monkeypatch.setattr(static_serve, "workspace_being_edited", lambda p: False)
|
||||
asyncio.run(mgr.attach("ws-t", ws))
|
||||
assert rt.serve_static is True and restarted["n"] == 0
|
||||
|
||||
time.sleep(0.02)
|
||||
os.utime(os.path.join(ws, "frontend", "src", "App.tsx"))
|
||||
asyncio.run(mgr.attach("ws-t", ws))
|
||||
assert rt.serve_static is False and restarted["n"] == 1
|
||||
Reference in New Issue
Block a user