mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] apps: multiple independent instances per app (own runtime + ports; alt-click or paste to open)
This commit is contained in:
@@ -11,6 +11,8 @@ class CardPosition(BaseModel):
|
||||
|
||||
class ViewCardPosition(BaseModel):
|
||||
output_id: str
|
||||
# Which instance of the app this card is (1 = primary). Persisted or Pydantic strips it on save and a reloaded second-instance card collapses onto the primary's runtime (same failure shape as the browser-card dashboard_id bleed).
|
||||
instance: int = 1
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
width: float = 480
|
||||
|
||||
@@ -14,6 +14,8 @@ class CardPosition(BaseModel):
|
||||
|
||||
class ViewCardPosition(BaseModel):
|
||||
output_id: str
|
||||
# Which instance of the app this card is (1 = primary). Persisted or Pydantic strips it on save and a reloaded second-instance card collapses onto the primary's runtime (same failure shape as the browser-card dashboard_id bleed).
|
||||
instance: int = 1
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
width: float = 480
|
||||
|
||||
@@ -357,10 +357,10 @@ async def seed_workspace(body: WorkspaceSeedRequest):
|
||||
# --------------------------------------------------------------------------- Persistent app-backend runtime control. backend.py runs as a long-lived subprocess for the lifetime of the App being open; auto-allocated port, log streaming via WebSocket. See runtime.py for the manager. ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def runtime_status_payload(workspace_id: str) -> dict:
|
||||
def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict:
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
from backend.apps.outputs.runtime import is_new_mode
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
rt = runtime_manager.get(workspace_id, instance)
|
||||
if not rt:
|
||||
# Even without a live runtime, the editor needs is_new_mode to decide whether the preview pane should fall back to the legacy /serve/index.html URL (old-mode flat workspaces) or show the "starting preview…" placeholder (new-mode webapp_template). Compute from disk so a failed runtime/start still gives the client the right hint instead of dumping it onto a 404.
|
||||
folder = os.path.join(WORKSPACE_DIR, workspace_id)
|
||||
@@ -388,44 +388,44 @@ def runtime_status_payload(workspace_id: str) -> dict:
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/start")
|
||||
async def runtime_start(workspace_id: str):
|
||||
async def runtime_start(workspace_id: str, instance: int = 1):
|
||||
folder = os.path.join(WORKSPACE_DIR, workspace_id)
|
||||
if not os.path.isdir(folder):
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
await runtime_manager.attach(workspace_id, os.path.abspath(folder))
|
||||
return runtime_status_payload(workspace_id)
|
||||
await runtime_manager.attach(workspace_id, os.path.abspath(folder), instance)
|
||||
return runtime_status_payload(workspace_id, instance)
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/stop")
|
||||
async def runtime_stop(workspace_id: str):
|
||||
async def runtime_stop(workspace_id: str, instance: int = 1):
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
await runtime_manager.detach(workspace_id)
|
||||
return runtime_status_payload(workspace_id)
|
||||
await runtime_manager.detach(workspace_id, instance)
|
||||
return runtime_status_payload(workspace_id, instance)
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/restart")
|
||||
async def runtime_restart(workspace_id: str):
|
||||
async def runtime_restart(workspace_id: str, instance: int = 1):
|
||||
folder = os.path.join(WORKSPACE_DIR, workspace_id)
|
||||
if not os.path.isdir(folder):
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
# Restart only if something's attached; otherwise this is a no-op silently (a hard-reload click while the runtime was already torn down; we'd rather not silently respawn an orphan).
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
rt = runtime_manager.get(workspace_id, instance)
|
||||
if rt:
|
||||
await runtime_manager.restart(workspace_id, os.path.abspath(folder))
|
||||
return runtime_status_payload(workspace_id)
|
||||
await runtime_manager.restart(workspace_id, os.path.abspath(folder), instance)
|
||||
return runtime_status_payload(workspace_id, instance)
|
||||
|
||||
|
||||
@outputs.router.get("/workspace/{workspace_id}/runtime/status")
|
||||
async def runtime_get_status(workspace_id: str):
|
||||
return runtime_status_payload(workspace_id)
|
||||
async def runtime_get_status(workspace_id: str, instance: int = 1):
|
||||
return runtime_status_payload(workspace_id, instance)
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/report-error")
|
||||
async def runtime_report_error(workspace_id: str, body: dict):
|
||||
async def runtime_report_error(workspace_id: str, body: dict, instance: int = 1):
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
rt = runtime_manager.get(workspace_id, instance)
|
||||
if rt is None:
|
||||
return {"ok": False, "recorded": 0}
|
||||
message = (body.get("message") or "").strip()
|
||||
@@ -440,10 +440,10 @@ async def runtime_report_error(workspace_id: str, body: dict):
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/console-log")
|
||||
async def runtime_console_log(workspace_id: str, body: dict):
|
||||
async def runtime_console_log(workspace_id: str, body: dict, instance: int = 1):
|
||||
"""Fold webview console lines into the runtime's terminal stream so they reach the Terminal panes AND the agent-readable .openswarm/terminal.log. Renderer batches; body is {lines: [{level, text}, ...]}."""
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
rt = runtime_manager.get(workspace_id, instance)
|
||||
if rt is None:
|
||||
return {"ok": False, "recorded": 0}
|
||||
lines = body.get("lines") or []
|
||||
@@ -458,9 +458,9 @@ async def runtime_console_log(workspace_id: str, body: dict):
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready")
|
||||
async def runtime_report_ready(workspace_id: str):
|
||||
async def runtime_report_ready(workspace_id: str, instance: int = 1):
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
rt = runtime_manager.get(workspace_id, instance)
|
||||
if rt is None:
|
||||
return {"ok": False}
|
||||
rt.set_render_ok()
|
||||
|
||||
@@ -73,6 +73,11 @@ TERMINAL_LOG_PREFIXES = {
|
||||
LogSubscriber = Callable[[LogLine], None]
|
||||
|
||||
|
||||
def runtime_key(workspace_id: str, instance: int) -> str:
|
||||
"""Registry key for a workspace instance. Instance 1 keeps the bare workspace_id so every existing get()/attach() caller keeps addressing the primary."""
|
||||
return workspace_id if instance <= 1 else f"{workspace_id}#{instance}"
|
||||
|
||||
|
||||
class AppRuntime:
|
||||
"""Manages one workspace's backend.py subprocess.
|
||||
|
||||
@@ -85,9 +90,11 @@ class AppRuntime:
|
||||
- `log_buffer` is the replay source for new subscribers.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace_id: str, workspace_path: str):
|
||||
def __init__(self, workspace_id: str, workspace_path: str, instance: int = 1):
|
||||
self.workspace_id = workspace_id
|
||||
self.workspace_path = workspace_path
|
||||
# Instance 1 is the primary (uses the .env-pinned ports); >1 are extra dashboard cards of the same app, fully independent processes on fresh ports that must never rewrite the shared .env.
|
||||
self.instance = max(1, instance)
|
||||
# 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
|
||||
@@ -97,8 +104,9 @@ class AppRuntime:
|
||||
self.p_suspended: bool = False
|
||||
self.process: Optional[asyncio.subprocess.Process] = None
|
||||
self.log_buffer: deque[LogLine] = deque(maxlen=LOG_BUFFER_LINES)
|
||||
# On-disk tee of the ring buffer so the App Builder agent can inspect terminal output itself (Read/grep); reset on every start().
|
||||
self.p_terminal_log_path = os.path.join(workspace_path, ".openswarm", "terminal.log")
|
||||
# On-disk tee of the ring buffer so the App Builder agent can inspect terminal output itself (Read/grep); reset on every start(). Secondary instances get a suffixed file so they don't clobber the primary's.
|
||||
log_name = "terminal.log" if self.instance <= 1 else f"terminal-{self.instance}.log"
|
||||
self.p_terminal_log_path = os.path.join(workspace_path, ".openswarm", log_name)
|
||||
self.p_terminal_log_bytes = 0
|
||||
self.p_subscribers: set[LogSubscriber] = set()
|
||||
# Recent build/runtime errors scraped from stderr; drained by the agent's post-tool hook after Write/Edit so the agent sees vite/babel/uvicorn errors in its next turn and can self-fix instead of leaving the user with a red iframe overlay.
|
||||
@@ -196,39 +204,49 @@ class AppRuntime:
|
||||
env_path = os.path.join(self.workspace_path, ".env")
|
||||
fp_raw = read_env_value(env_path, "FRONTEND_PORT")
|
||||
bp_raw = read_env_value(env_path, "BACKEND_PORT")
|
||||
# FRONTEND_PORT is allocated by seed_workspace; should always be a number. If missing, fall back to a fresh allocation (rare edge case: workspace seeded by an older OpenSwarm).
|
||||
try:
|
||||
self.frontend_port = int(fp_raw) if fp_raw else find_free_port()
|
||||
except ValueError:
|
||||
if self.instance > 1:
|
||||
# Secondary instance: fresh ports always (the primary owns the .env-pinned ones), and NEVER rewrite the shared .env. .env is read only to learn whether the app has a backend at all.
|
||||
self.frontend_port = find_free_port()
|
||||
# Port-collision safety net: if a ghost subprocess from a prior OpenSwarm run is still bound to the persisted port (force-quit, crash, OS killed the parent before stop_all could reap), Vite would EADDRINUSE silently. Re-probe and reallocate, then rewrite .env so the bash run.sh subprocess reads the new port.
|
||||
if self.frontend_port and not is_port_free(self.frontend_port):
|
||||
new_port = find_free_port()
|
||||
self.p_broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}",
|
||||
))
|
||||
self.frontend_port = new_port
|
||||
write_env_value(env_path, "FRONTEND_PORT", str(new_port))
|
||||
# BACKEND_PORT may be the literal string "NONE" (frontend-only app; the common case) or a number once `backend_init.sh` has run. Only populate self.port when there's a real backend.
|
||||
if bp_raw and bp_raw != "NONE":
|
||||
self.port = find_free_port() if (bp_raw and bp_raw != "NONE") else None
|
||||
else:
|
||||
# FRONTEND_PORT is allocated by seed_workspace; should always be a number. If missing, fall back to a fresh allocation (rare edge case: workspace seeded by an older OpenSwarm).
|
||||
try:
|
||||
self.port = int(bp_raw)
|
||||
self.frontend_port = int(fp_raw) if fp_raw else find_free_port()
|
||||
except ValueError:
|
||||
self.port = None
|
||||
# Same collision check for the backend port; a leaked uvicorn from a prior session would otherwise block the new spawn.
|
||||
if self.port and not is_port_free(self.port):
|
||||
self.frontend_port = find_free_port()
|
||||
# Port-collision safety net: if a ghost subprocess from a prior OpenSwarm run is still bound to the persisted port (force-quit, crash, OS killed the parent before stop_all could reap), Vite would EADDRINUSE silently. Re-probe and reallocate, then rewrite .env so the bash run.sh subprocess reads the new port.
|
||||
if self.frontend_port and not is_port_free(self.frontend_port):
|
||||
new_port = find_free_port()
|
||||
self.p_broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}",
|
||||
f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}",
|
||||
))
|
||||
self.port = new_port
|
||||
write_env_value(env_path, "BACKEND_PORT", str(new_port))
|
||||
else:
|
||||
self.port = None
|
||||
self.frontend_port = new_port
|
||||
write_env_value(env_path, "FRONTEND_PORT", str(new_port))
|
||||
# BACKEND_PORT may be the literal string "NONE" (frontend-only app; the common case) or a number once `backend_init.sh` has run. Only populate self.port when there's a real backend.
|
||||
if bp_raw and bp_raw != "NONE":
|
||||
try:
|
||||
self.port = int(bp_raw)
|
||||
except ValueError:
|
||||
self.port = None
|
||||
# Same collision check for the backend port; a leaked uvicorn from a prior session would otherwise block the new spawn.
|
||||
if self.port and not is_port_free(self.port):
|
||||
new_port = find_free_port()
|
||||
self.p_broadcast(LogLine(
|
||||
"runtime",
|
||||
f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}",
|
||||
))
|
||||
self.port = new_port
|
||||
write_env_value(env_path, "BACKEND_PORT", str(new_port))
|
||||
else:
|
||||
self.port = None
|
||||
|
||||
env = self.p_spawn_env_base()
|
||||
if self.instance > 1:
|
||||
# run.sh applies these AFTER sourcing .env, so the secondary boots on its own ports. Older workspaces (pre-override run.sh) ignore them; their secondary instance EADDRINUSEs visibly in the Terminal instead of silently sharing the primary.
|
||||
env["OPENSWARM_FORCE_FRONTEND_PORT"] = str(self.frontend_port)
|
||||
if self.port:
|
||||
env["OPENSWARM_FORCE_BACKEND_PORT"] = str(self.port)
|
||||
# bash run.sh reads .env itself; we don't need to set FRONTEND_PORT / BACKEND_PORT here. We DO export the install paths as env vars (the more reliable read site for subshells). OPENSWARM_DEBUGGER_PATH is legacy-only: workspaces seeded before the PyPI swap have a run.sh that editable-installs the bundled debugger from it; new templates resolve `swarm-debug` from PyPI via pyproject.
|
||||
from backend.apps.outputs.view_builder_templates import (
|
||||
DEBUGGER_PATH,
|
||||
@@ -539,19 +557,20 @@ class AppRuntimeManager:
|
||||
self.idle_lru: "OrderedDict[str, AppRuntime]" = OrderedDict()
|
||||
self.p_lock = asyncio.Lock()
|
||||
|
||||
async def attach(self, workspace_id: str, workspace_path: str) -> AppRuntime:
|
||||
async def attach(self, workspace_id: str, workspace_path: str, instance: int = 1) -> AppRuntime:
|
||||
key = runtime_key(workspace_id, instance)
|
||||
revived = False
|
||||
# Defined here so every code path below leaves it bound; the revive-idle branch used to skip the assignment, leaving the post-lock `if dead is not None:` check throwing UnboundLocalError.
|
||||
dead: Optional[AppRuntime] = None
|
||||
async with self.p_lock:
|
||||
rt = self.runtimes.get(workspace_id)
|
||||
rt = self.runtimes.get(key)
|
||||
if rt is None:
|
||||
# Maybe the runtime is sitting idle in the LRU; revive it without paying the spawn cost again.
|
||||
idle_rt = self.idle_lru.pop(workspace_id, None)
|
||||
idle_rt = self.idle_lru.pop(key, None)
|
||||
if idle_rt is not None and idle_rt.running:
|
||||
rt = idle_rt
|
||||
rt.workspace_path = workspace_path
|
||||
self.runtimes[workspace_id] = rt
|
||||
self.runtimes[key] = rt
|
||||
revived = True
|
||||
# SIGCONT the process tree if A2 had it paused while idle. Pair with the SIGSTOP in detach() below.
|
||||
resume_process_tree(rt.process)
|
||||
@@ -560,12 +579,12 @@ class AppRuntimeManager:
|
||||
if idle_rt is not None:
|
||||
# Stale idle entry; process died while idling. Drop and spawn a fresh one below; old one gets stopped outside the lock.
|
||||
dead = idle_rt
|
||||
rt = AppRuntime(workspace_id, workspace_path)
|
||||
self.runtimes[workspace_id] = rt
|
||||
rt = AppRuntime(workspace_id, workspace_path, instance)
|
||||
self.runtimes[key] = rt
|
||||
else:
|
||||
# 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[workspace_id] = self.p_attached.get(workspace_id, 0) + 1
|
||||
self.p_attached[key] = self.p_attached.get(key, 0) + 1
|
||||
if not revived and not rt.running:
|
||||
await rt.start()
|
||||
# Stop any dead idle runtime outside the lock to avoid blocking.
|
||||
@@ -573,27 +592,28 @@ class AppRuntimeManager:
|
||||
try:
|
||||
await dead.stop()
|
||||
except Exception:
|
||||
logger.exception("failed to reap dead idle runtime %s", workspace_id)
|
||||
logger.exception("failed to reap dead idle runtime %s", key)
|
||||
return rt
|
||||
|
||||
async def detach(self, workspace_id: str) -> None:
|
||||
async def detach(self, workspace_id: str, instance: int = 1) -> None:
|
||||
key = runtime_key(workspace_id, instance)
|
||||
to_idle: Optional[AppRuntime] = None
|
||||
to_reap: list[AppRuntime] = []
|
||||
async with self.p_lock:
|
||||
count = self.p_attached.get(workspace_id, 0) - 1
|
||||
count = self.p_attached.get(key, 0) - 1
|
||||
if count > 0:
|
||||
self.p_attached[workspace_id] = count
|
||||
self.p_attached[key] = count
|
||||
return
|
||||
self.p_attached.pop(workspace_id, None)
|
||||
rt = self.runtimes.pop(workspace_id, None)
|
||||
self.p_attached.pop(key, None)
|
||||
rt = self.runtimes.pop(key, None)
|
||||
if rt is None:
|
||||
return
|
||||
# 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)
|
||||
else:
|
||||
self.idle_lru[workspace_id] = rt
|
||||
self.idle_lru.move_to_end(workspace_id)
|
||||
self.idle_lru[key] = rt
|
||||
self.idle_lru.move_to_end(key)
|
||||
suspend_process_tree(rt.process)
|
||||
rt.p_suspended = True
|
||||
while len(self.idle_lru) > MAX_IDLE_RUNTIMES:
|
||||
@@ -608,16 +628,17 @@ class AppRuntimeManager:
|
||||
try:
|
||||
await old.stop()
|
||||
except Exception:
|
||||
logger.exception("failed to reap idle runtime %s", workspace_id)
|
||||
logger.exception("failed to reap idle runtime %s", key)
|
||||
if to_idle is not None:
|
||||
logger.debug("workspace %s idled (LRU size now %d)", workspace_id, len(self.idle_lru))
|
||||
logger.debug("workspace %s idled (LRU size now %d)", key, len(self.idle_lru))
|
||||
|
||||
def get(self, workspace_id: str) -> Optional[AppRuntime]:
|
||||
def get(self, workspace_id: str, instance: int = 1) -> Optional[AppRuntime]:
|
||||
key = runtime_key(workspace_id, instance)
|
||||
# Active subscribers see the live runtime; idle-pool members are also accessible so a status probe between detach and the next attach still works.
|
||||
rt = self.runtimes.get(workspace_id)
|
||||
rt = self.runtimes.get(key)
|
||||
if rt is not None:
|
||||
return rt
|
||||
return self.idle_lru.get(workspace_id)
|
||||
return self.idle_lru.get(key)
|
||||
|
||||
def drain_errors_for_path(self, file_path: str) -> list[str]:
|
||||
"""If `file_path` falls under one of the live workspace
|
||||
@@ -632,15 +653,16 @@ class AppRuntimeManager:
|
||||
abs_path = os.path.abspath(file_path)
|
||||
except Exception:
|
||||
return []
|
||||
# Walk both active and idle runtimes; the user might have navigated away from the workspace mid-build, but the agent could still be editing files; the LRU keeps the runtime alive for ~3 idle slots.
|
||||
# Walk both active and idle runtimes; the user might have navigated away from the workspace mid-build, but the agent could still be editing files; the LRU keeps the runtime alive for ~3 idle slots. Aggregate across instances: with two cards of the same app open, either instance's build errors matter to the agent.
|
||||
drained: list[str] = []
|
||||
for rt in (*self.runtimes.values(), *self.idle_lru.values()):
|
||||
try:
|
||||
ws_root = os.path.abspath(rt.workspace_path)
|
||||
except Exception:
|
||||
continue
|
||||
if abs_path == ws_root or abs_path.startswith(ws_root + os.sep):
|
||||
return rt.drain_errors()
|
||||
return []
|
||||
drained.extend(rt.drain_errors())
|
||||
return drained
|
||||
|
||||
def get_render_state_for_workspace(self, workspace_id: str) -> tuple[Optional[str], str]:
|
||||
rt = self.runtimes.get(workspace_id) or self.idle_lru.get(workspace_id)
|
||||
@@ -653,8 +675,9 @@ class AppRuntimeManager:
|
||||
if rt is not None:
|
||||
rt.reset_render_state()
|
||||
|
||||
async def restart(self, workspace_id: str, workspace_path: Optional[str] = None) -> Optional[AppRuntime]:
|
||||
rt = self.runtimes.get(workspace_id) or self.idle_lru.get(workspace_id)
|
||||
async def restart(self, workspace_id: str, workspace_path: Optional[str] = None, instance: int = 1) -> Optional[AppRuntime]:
|
||||
key = runtime_key(workspace_id, instance)
|
||||
rt = self.runtimes.get(key) or self.idle_lru.get(key)
|
||||
if rt is None:
|
||||
return None
|
||||
if workspace_path:
|
||||
|
||||
@@ -8,6 +8,14 @@ if [[ -f "$ROOT_DIR/.env" ]]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Per-instance port overrides: OpenSwarm passes these when the user opens a SECOND instance of the app, so it boots on fresh ports instead of colliding with the primary's .env-pinned ones.
|
||||
if [[ -n "${OPENSWARM_FORCE_FRONTEND_PORT:-}" ]]; then
|
||||
export FRONTEND_PORT="$OPENSWARM_FORCE_FRONTEND_PORT"
|
||||
fi
|
||||
if [[ -n "${OPENSWARM_FORCE_BACKEND_PORT:-}" ]]; then
|
||||
export BACKEND_PORT="$OPENSWARM_FORCE_BACKEND_PORT"
|
||||
fi
|
||||
|
||||
# Recursively SIGTERM a pid + all of its descendants. We track FRONTEND_PID
|
||||
# and BACKEND_PID below, but each of those is a `bash` wrapper that has its
|
||||
# own grandchildren (vite, uvicorn, npm). A flat `kill $FRONTEND_PID` leaves
|
||||
|
||||
@@ -36,12 +36,15 @@ class DashboardExportable:
|
||||
if bid:
|
||||
cards[bid] = {**card, "session_id": bid}
|
||||
view_cards = {}
|
||||
for oid, card in (layout.get("view_cards") or {}).items():
|
||||
for key, card in (layout.get("view_cards") or {}).items():
|
||||
# Keys are output_id for the primary card, `output_id#N` for extra instances of the same app; resolve the bundle id off the bare output id and rebuild the suffix.
|
||||
oid = str(card.get("output_id") or key).split("#")[0]
|
||||
bid = ctx.bundle_id_for(EntityType.app, oid)
|
||||
if bid:
|
||||
inst = int(card.get("instance") or 1)
|
||||
# parent_session_id tethers the app card to the agent that built it; it's a session id, so it remaps like spawned_by on browser cards.
|
||||
parent = card.get("parent_session_id")
|
||||
view_cards[bid] = {
|
||||
view_cards[bid if inst <= 1 else f"{bid}#{inst}"] = {
|
||||
**card, "output_id": bid,
|
||||
"parent_session_id": ctx.bundle_id_for(EntityType.session, parent) if parent else None,
|
||||
}
|
||||
@@ -64,7 +67,8 @@ class DashboardExportable:
|
||||
def dependencies(self) -> list[DepRef]:
|
||||
layout = self.p_data.get("layout") or {}
|
||||
deps = [DepRef(EntityType.session, sid, "has_agent") for sid in (layout.get("cards") or {})]
|
||||
deps += [DepRef(EntityType.app, oid, "has_app") for oid in (layout.get("view_cards") or {})]
|
||||
p_view_oids = {str(card.get("output_id") or key).split("#")[0] for key, card in (layout.get("view_cards") or {}).items()}
|
||||
deps += [DepRef(EntityType.app, oid, "has_app") for oid in sorted(p_view_oids)]
|
||||
return deps
|
||||
|
||||
def requirements(self) -> list[Requirement]:
|
||||
@@ -80,11 +84,13 @@ class DashboardExportable:
|
||||
if nsid:
|
||||
cards[nsid] = {**card, "session_id": nsid}
|
||||
view_cards = {}
|
||||
for bid, card in (layout.get("view_cards") or {}).items():
|
||||
for key, card in (layout.get("view_cards") or {}).items():
|
||||
bid = str(card.get("output_id") or key).split("#")[0]
|
||||
noid = remap.local(bid)
|
||||
if noid:
|
||||
inst = int(card.get("instance") or 1)
|
||||
parent = card.get("parent_session_id")
|
||||
view_cards[noid] = {
|
||||
view_cards[noid if inst <= 1 else f"{noid}#{inst}"] = {
|
||||
**card, "output_id": noid,
|
||||
"parent_session_id": remap.local(parent) if parent else None,
|
||||
}
|
||||
|
||||
+4
-3
@@ -253,16 +253,17 @@ def p_ws_auth_ok(websocket: WebSocket) -> bool:
|
||||
|
||||
|
||||
@app.websocket("/ws/outputs/runtime/{workspace_id}/logs")
|
||||
async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str):
|
||||
async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str, instance: int = 1):
|
||||
"""Stream the persistent app-backend's stdout/stderr to the Terminal
|
||||
pane. On connect we replay the runtime's ring buffer so a Terminal
|
||||
tab opened mid-session sees the context it missed, then we tail
|
||||
every subsequent line until disconnect."""
|
||||
every subsequent line until disconnect. `instance` targets a specific
|
||||
dashboard card's runtime when the same app is open more than once."""
|
||||
if not p_ws_auth_ok(websocket):
|
||||
return
|
||||
await websocket.accept()
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
rt = runtime_manager.get(workspace_id, instance)
|
||||
if rt is None:
|
||||
# No active runtime, surface that to the client and close. The frontend will call /runtime/start and reconnect. Also emit a status frame with is_new_mode (computed from disk) so the preview pane shows the "starting preview…" placeholder for webapp_template workspaces instead of falling back to the legacy /serve/index.html URL (which 404s in new-mode).
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Multi-instance app runtimes: two dashboard cards of the same app must get fully
|
||||
independent AppRuntime processes on independent ports. Pins the invariants the
|
||||
frontend's per-instance attach relies on: instance-suffixed registry keys, no shared
|
||||
refcount between instances, fresh (non-.env) ports + OPENSWARM_FORCE_* env for
|
||||
secondaries, and per-instance terminal.log files."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.outputs.runtime import AppRuntime, AppRuntimeManager, runtime_key
|
||||
|
||||
|
||||
def test_runtime_key_primary_is_bare_workspace_id():
|
||||
assert runtime_key("ws1", 1) == "ws1"
|
||||
assert runtime_key("ws1", 2) == "ws1#2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_two_instances_creates_independent_runtimes(tmp_path, monkeypatch):
|
||||
# Neither instance should actually spawn a process; pin start() to a no-op.
|
||||
async def p_fake_start(self):
|
||||
return True
|
||||
monkeypatch.setattr(AppRuntime, "start", p_fake_start)
|
||||
mgr = AppRuntimeManager()
|
||||
ws = str(tmp_path)
|
||||
rt1 = await mgr.attach("ws1", ws, 1)
|
||||
rt2 = await mgr.attach("ws1", ws, 2)
|
||||
assert rt1 is not rt2
|
||||
assert rt1.instance == 1 and rt2.instance == 2
|
||||
assert mgr.get("ws1", 1) is rt1
|
||||
assert mgr.get("ws1", 2) is rt2
|
||||
# Detaching one instance must not tear down or refcount-touch the other. (The stub runtime has no live process, so detach reaps it rather than idling it.)
|
||||
await mgr.detach("ws1", 2)
|
||||
assert "ws1" in mgr.runtimes
|
||||
assert mgr.get("ws1", 2) is None
|
||||
assert mgr.get("ws1", 1) is rt1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secondary_instance_uses_fresh_ports_and_force_env(tmp_path, monkeypatch):
|
||||
# A new-mode workspace with pinned .env ports; the secondary must NOT reuse or rewrite them.
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
(ws / "run.sh").write_text("#!/bin/bash\n")
|
||||
(ws / ".env").write_text("FRONTEND_PORT=45001\nBACKEND_PORT=45002\n")
|
||||
captured_env: dict = {}
|
||||
|
||||
async def p_fake_exec(*cmd, **kwargs):
|
||||
captured_env.update(kwargs.get("env") or {})
|
||||
raise RuntimeError("stop before real spawn")
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", p_fake_exec)
|
||||
rt = AppRuntime("ws1", str(ws), instance=2)
|
||||
ok = await rt.start()
|
||||
# The stubbed spawn raises, so start() reports failure and nulls the ports; the forced env captured at spawn time carries what the secondary would have used.
|
||||
assert ok is False
|
||||
forced_fp = int(captured_env["OPENSWARM_FORCE_FRONTEND_PORT"])
|
||||
forced_bp = int(captured_env["OPENSWARM_FORCE_BACKEND_PORT"])
|
||||
assert forced_fp != 45001
|
||||
assert forced_bp != 45002
|
||||
# .env untouched: the primary still owns its pinned ports.
|
||||
assert (ws / ".env").read_text() == "FRONTEND_PORT=45001\nBACKEND_PORT=45002\n"
|
||||
|
||||
|
||||
def test_secondary_terminal_log_is_suffixed(tmp_path):
|
||||
os.makedirs(os.path.join(str(tmp_path), ".openswarm"))
|
||||
rt1 = AppRuntime("ws1", str(tmp_path), instance=1)
|
||||
rt2 = AppRuntime("ws1", str(tmp_path), instance=2)
|
||||
rt1.record_frontend_log("log", "hello from instance 1")
|
||||
rt2.record_frontend_log("log", "hello from instance 2")
|
||||
files = sorted(os.listdir(os.path.join(str(tmp_path), ".openswarm")))
|
||||
assert files == ["terminal-2.log", "terminal.log"]
|
||||
@@ -258,7 +258,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
.map((el) => el.semanticData!.selectId as string);
|
||||
const appIds = selectedEls
|
||||
.filter((el) => el.semanticType === 'view-card' && el.semanticData?.selectId)
|
||||
.map((el) => el.semanticData!.selectId as string);
|
||||
// Strip a multi-instance card-key suffix (`output_id#N`): the backend AppAgent gate wants bare output ids.
|
||||
.map((el) => (el.semanticData!.selectId as string).split('#')[0]);
|
||||
const settingIds = selectedEls
|
||||
.filter((el) => el.semanticType === 'settings-option' && el.semanticData?.selectId)
|
||||
.map((el) => el.semanticData!.selectId as string);
|
||||
|
||||
@@ -55,7 +55,7 @@ interface Props {
|
||||
selectedBrowserIds?: string[],
|
||||
selectedAppIds?: string[],
|
||||
) => void;
|
||||
onAddView: (outputId: string) => void;
|
||||
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
|
||||
onHistoryResume: (sessionId: string) => void;
|
||||
onAddBrowser: () => void;
|
||||
onAddNote: () => void;
|
||||
@@ -240,8 +240,9 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
}
|
||||
}, [historyOpen, viewPickerOpen, onCancel, handleCloseHistory]);
|
||||
|
||||
const handleSelectView = useCallback((output: Output) => {
|
||||
onAddView(output.id);
|
||||
// Alt/Option-click opens ANOTHER independent instance of an already-open app (its own runtime + ports); plain click focuses the existing card.
|
||||
const handleSelectView = useCallback((output: Output, newInstance?: boolean) => {
|
||||
onAddView(output.id, { newInstance });
|
||||
setViewPickerOpen(false);
|
||||
setViewSearch('');
|
||||
}, [onAddView]);
|
||||
@@ -533,7 +534,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
filteredOutputs.map((output) => (
|
||||
<Box
|
||||
key={output.id}
|
||||
onClick={() => handleSelectView(output)}
|
||||
onClick={(e) => handleSelectView(output, e.altKey)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -81,7 +81,7 @@ interface DashboardCanvasProps {
|
||||
onStarter: (prompt: string, mode?: string) => void;
|
||||
toolbarPrefill?: string;
|
||||
toolbarPrefillMode?: string;
|
||||
onAddView: (outputId: string) => void;
|
||||
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
|
||||
onHistoryResume: (sessionId: string) => void;
|
||||
onAddBrowser: () => void;
|
||||
onAddNote: () => void;
|
||||
|
||||
@@ -191,12 +191,14 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
{Object.values(viewCards).map((vc) => {
|
||||
{Object.entries(viewCards).map(([cardKey, vc]) => {
|
||||
const output = outputs[vc.output_id];
|
||||
if (!output) return null;
|
||||
return (
|
||||
<DashboardViewCard
|
||||
key={`view-${vc.output_id}`}
|
||||
key={`view-${cardKey}`}
|
||||
cardKey={cardKey}
|
||||
instance={vc.instance ?? 1}
|
||||
output={output}
|
||||
cardX={vc.x}
|
||||
cardY={vc.y}
|
||||
@@ -207,8 +209,8 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
cmdHeld={cmdHeld}
|
||||
isSelected={selection.isSelected(vc.output_id)}
|
||||
isHighlighted={highlightedCardId === vc.output_id}
|
||||
isSelected={selection.isSelected(cardKey)}
|
||||
isHighlighted={highlightedCardId === cardKey}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
|
||||
@@ -39,7 +39,7 @@ interface DashboardOverlaysProps {
|
||||
onNewAgent: () => void;
|
||||
onToolbarCancel: () => void;
|
||||
onToolbarSend: (...args: any[]) => void;
|
||||
onAddView: (outputId: string) => void;
|
||||
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
|
||||
onHistoryResume: (sessionId: string) => void;
|
||||
onAddBrowser: () => void;
|
||||
onAddNote: () => void;
|
||||
|
||||
@@ -59,6 +59,10 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
|
||||
|
||||
interface Props {
|
||||
output: Output;
|
||||
// Record key in dashboardLayout.viewCards (output.id for the primary, `${output.id}#N` for extras); every layout/selection dispatch keys by this.
|
||||
cardKey?: string;
|
||||
// Which independent instance of the app this card runs; each instance gets its own runtime + ports.
|
||||
instance?: number;
|
||||
cardX: number;
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
@@ -115,16 +119,17 @@ const BootingBody: React.FC = () => {
|
||||
};
|
||||
|
||||
const DashboardViewCard: React.FC<Props> = ({
|
||||
output, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
|
||||
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
cardZOrder = 0, onDoubleClick, onBringToFront,
|
||||
}) => {
|
||||
const cardKey = cardKeyProp ?? output.id;
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
|
||||
const previewRef = useRef<ViewPreviewHandle>(null);
|
||||
const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId);
|
||||
const interactive = activeViewCardId === output.id;
|
||||
const interactive = activeViewCardId === cardKey;
|
||||
|
||||
// Deselecting the card exits interact mode (click anywhere else on canvas).
|
||||
useEffect(() => {
|
||||
@@ -203,8 +208,8 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
didDrag.current = false;
|
||||
setIsDragging(true);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
onDragStart?.(output.id, 'view');
|
||||
}, [cardX, cardY, onDragStart, output.id]);
|
||||
onDragStart?.(cardKey, 'view');
|
||||
}, [cardX, cardY, onDragStart, cardKey]);
|
||||
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
@@ -251,7 +256,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
finalY = Math.round(finalY / 24) * 24;
|
||||
}
|
||||
dispatch(setViewCardPosition({
|
||||
outputId: output.id,
|
||||
outputId: cardKey,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
}));
|
||||
@@ -264,7 +269,7 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, output.id, onDragEnd]);
|
||||
}, [dispatch, cardKey, onDragEnd]);
|
||||
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir; startX: number; startY: number;
|
||||
@@ -318,19 +323,19 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
if (!resizeRef.current) return;
|
||||
const result = computeResize(e);
|
||||
if (result) {
|
||||
dispatch(setViewCardPosition({ outputId: output.id, x: result.x, y: result.y }));
|
||||
dispatch(setViewCardSize({ outputId: output.id, width: result.w, height: result.h }));
|
||||
dispatch(setViewCardPosition({ outputId: cardKey, x: result.x, y: result.y }));
|
||||
dispatch(setViewCardSize({ outputId: cardKey, width: result.w, height: result.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalResize(null);
|
||||
setIsResizing(false);
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, output.id]);
|
||||
}, [computeResize, dispatch, cardKey]);
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(recordClosedCard({ kind: 'view', id: output.id }));
|
||||
void removeViewCardCleanly(output.id, dispatch);
|
||||
dispatch(recordClosedCard({ kind: 'view', id: cardKey }));
|
||||
void removeViewCardCleanly(cardKey, dispatch);
|
||||
};
|
||||
|
||||
const [reloadMenuRect, setReloadMenuRect] = useState<DOMRect | null>(null);
|
||||
@@ -343,14 +348,14 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
const tok = getAuthToken();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (tok) headers.Authorization = `Bearer ${tok}`;
|
||||
await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart`, {
|
||||
await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart?instance=${instance}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
} catch { /* failures surface via the runtime log WS */ }
|
||||
}
|
||||
previewRef.current?.reload();
|
||||
}, [output.workspace_id]);
|
||||
}, [output.workspace_id, instance]);
|
||||
|
||||
// In Terminal view a soft webview reload is invisible (the terminal is what you're looking at), so the refresh button always hard-reloads there.
|
||||
const handleRefresh = (e: React.MouseEvent) => {
|
||||
@@ -373,16 +378,16 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
return (
|
||||
<Box
|
||||
data-select-type="view-card"
|
||||
data-select-id={output.id}
|
||||
data-select-id={cardKey}
|
||||
data-select-meta={JSON.stringify({ name: output.name, description: output.description, path: output.workspace_path })}
|
||||
onPointerDownCapture={() => onBringToFront?.(output.id, 'view')}
|
||||
onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(output.id, 'view', e.shiftKey);
|
||||
onCardSelect?.(cardKey, 'view', e.shiftKey);
|
||||
}}
|
||||
onDoubleClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(output.id, 'view');
|
||||
onDoubleClick?.(cardKey, 'view');
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -476,6 +481,11 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
>
|
||||
{output.name}
|
||||
</Typography>
|
||||
{instance > 1 && (
|
||||
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: c.text.ghost, bgcolor: c.bg.page, borderRadius: 999, px: 0.75, py: 0.1, flexShrink: 0 }}>
|
||||
#{instance}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{hasWorkspace && (
|
||||
<Box
|
||||
@@ -554,10 +564,12 @@ const DashboardViewCard: React.FC<Props> = ({
|
||||
<DashboardOutputPreview
|
||||
previewRef={previewRef}
|
||||
output={output}
|
||||
cardKey={cardKey}
|
||||
instance={instance}
|
||||
inputData={inputData}
|
||||
backendResult={backendResult}
|
||||
interactive={interactive}
|
||||
onAppClicked={() => dispatch(setActiveViewCardId(output.id))}
|
||||
onAppClicked={() => dispatch(setActiveViewCardId(cardKey))}
|
||||
onRuntimeLog={handleRuntimeLog}
|
||||
/>
|
||||
{/* Code/Terminal overlay the always-mounted preview instead of replacing it: unmounting the webview kills the app's live state and forces a reload on switch-back. */}
|
||||
@@ -689,12 +701,14 @@ const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => {
|
||||
const DashboardOutputPreview: React.FC<{
|
||||
previewRef: React.Ref<ViewPreviewHandle>;
|
||||
output: Output;
|
||||
cardKey?: string;
|
||||
instance?: number;
|
||||
inputData: Record<string, any>;
|
||||
backendResult: any;
|
||||
interactive: boolean;
|
||||
onAppClicked: () => void;
|
||||
onRuntimeLog?: (line: RuntimeLogLine) => void;
|
||||
}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked, onRuntimeLog }) => {
|
||||
}> = ({ previewRef, output, cardKey, instance = 1, inputData, backendResult, interactive, onAppClicked, onRuntimeLog }) => {
|
||||
const tokens = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const workspaceId = output.workspace_id ?? null;
|
||||
@@ -702,6 +716,7 @@ const DashboardOutputPreview: React.FC<{
|
||||
workspaceId,
|
||||
enabled: !!workspaceId,
|
||||
onLog: onRuntimeLog,
|
||||
instance,
|
||||
});
|
||||
const { url, isBooting } = pickPreviewUrl({
|
||||
workspaceId,
|
||||
@@ -717,25 +732,25 @@ const DashboardOutputPreview: React.FC<{
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (tok) headers.Authorization = `Bearer ${tok}`;
|
||||
if (text.includes('[openswarm:app-ready]')) {
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready`, {
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready?instance=${instance}`, {
|
||||
method: 'POST', headers,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Fold console output into the runtime terminal stream (card Terminal view + agent-readable terminal.log).
|
||||
postAppConsoleLine(workspaceId, level, text);
|
||||
postAppConsoleLine(workspaceId, level, text, instance);
|
||||
if (level !== 'error' || !text.includes('[openswarm:app-error]')) return;
|
||||
const idx = text.indexOf('[openswarm:app-error]');
|
||||
const tail = text.slice(idx + '[openswarm:app-error]'.length).trim();
|
||||
const firstNewline = tail.indexOf('\n');
|
||||
const message = firstNewline >= 0 ? tail.slice(0, firstNewline).trim() : tail;
|
||||
const componentStack = firstNewline >= 0 ? tail.slice(firstNewline + 1).trim() : '';
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error`, {
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error?instance=${instance}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ message, componentStack }),
|
||||
}).catch(() => {});
|
||||
}, [workspaceId]);
|
||||
}, [workspaceId, instance]);
|
||||
|
||||
// An orphaned record (files deleted on disk) used to render the raw 404 JSON inside the card, or spin on "Starting preview" forever; probe once instead.
|
||||
const [filesMissing, setFilesMissing] = useState(false);
|
||||
@@ -773,7 +788,7 @@ const DashboardOutputPreview: React.FC<{
|
||||
This app's files are missing.
|
||||
</Typography>
|
||||
<Typography
|
||||
onClick={() => void removeViewCardCleanly(output.id, dispatch)}
|
||||
onClick={() => void removeViewCardCleanly(cardKey ?? output.id, dispatch)}
|
||||
sx={{
|
||||
color: tokens.accent.primary,
|
||||
fontSize: '0.85rem',
|
||||
@@ -800,7 +815,7 @@ const DashboardOutputPreview: React.FC<{
|
||||
return (
|
||||
<ViewPreview
|
||||
ref={previewRef}
|
||||
registryId={output.id}
|
||||
registryId={cardKey ?? output.id}
|
||||
serveUrl={url}
|
||||
frontendCode={output.files?.['index.html'] ?? ''}
|
||||
inputData={inputData}
|
||||
@@ -808,7 +823,7 @@ const DashboardOutputPreview: React.FC<{
|
||||
onConsoleMessage={handleConsoleMessage}
|
||||
interactive={interactive}
|
||||
onAppClicked={onAppClicked}
|
||||
agentBrowserId={`app:${output.id}`}
|
||||
agentBrowserId={instance > 1 ? `app:${output.id}#${instance}` : `app:${output.id}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,10 +44,10 @@ const CardSearchPalette: React.FC<Props> = ({
|
||||
rect: { x: card.x, y: card.y, width: card.width, height: card.height },
|
||||
});
|
||||
}
|
||||
for (const vc of Object.values(viewCards)) {
|
||||
for (const [key, vc] of Object.entries(viewCards)) {
|
||||
result.push({
|
||||
id: vc.output_id,
|
||||
label: `View: ${vc.output_id.slice(0, 12)}`,
|
||||
id: key,
|
||||
label: `View: ${vc.output_id.slice(0, 12)}${(vc.instance ?? 1) > 1 ? ` (#${vc.instance})` : ''}`,
|
||||
type: 'view',
|
||||
rect: { x: vc.x, y: vc.y, width: vc.width, height: vc.height },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { expandSession } from '@/shared/state/agentsSlice';
|
||||
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { bringToFront, viewCardKey } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType } from '../state/useDashboardSelection';
|
||||
import type { CanvasActions } from './useCanvasControls';
|
||||
@@ -45,7 +45,7 @@ export function useArrowNav({
|
||||
allCardEntries.push({ id: card.session_id, type: 'agent', cx: card.x + card.width / 2, cy: card.y + card.height / 2 });
|
||||
}
|
||||
for (const vc of Object.values(viewCards)) {
|
||||
allCardEntries.push({ id: vc.output_id, type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 });
|
||||
allCardEntries.push({ id: viewCardKey(vc.output_id, vc.instance), type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 });
|
||||
}
|
||||
for (const bc of Object.values(browserCards)) {
|
||||
allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 });
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type BrowserCardPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import { store } from '@/shared/state/store';
|
||||
import { setClipboardCards, getClipboardCards, type ClipboardCard } from '@/shared/dashboardClipboard';
|
||||
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
|
||||
|
||||
@@ -134,8 +135,15 @@ export function useDashboardClipboard({
|
||||
newSelection.set(newId, 'agent');
|
||||
}
|
||||
} else if (card.type === 'view') {
|
||||
dispatch(addViewCard({ outputId: card.id, expandedSessionIds, x: px, y: py, width: card.width, height: card.height }));
|
||||
newSelection.set(card.id, 'view');
|
||||
// Pasting an app whose card is already open creates a NEW independent instance (own runtime + ports) instead of no-op'ing.
|
||||
const outputId = card.id.split('#')[0];
|
||||
dispatch(addViewCard({ outputId, expandedSessionIds, x: px, y: py, width: card.width, height: card.height, newInstance: true }));
|
||||
const viewCards = store.getState().dashboardLayout.viewCards;
|
||||
let pastedKey = outputId;
|
||||
for (const [key, vc] of Object.entries(viewCards)) {
|
||||
if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[pastedKey]?.instance ?? 1)) pastedKey = key;
|
||||
}
|
||||
newSelection.set(pastedKey, 'view');
|
||||
} else if (card.type === 'browser') {
|
||||
const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
dispatch(pasteBrowserCard({
|
||||
|
||||
@@ -39,13 +39,21 @@ export function useDashboardCardActions({
|
||||
}: UseDashboardCardActionsArgs) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleAddView = useCallback((outputId: string) => {
|
||||
dispatch(addViewCard({ outputId, expandedSessionIds }));
|
||||
const handleAddView = useCallback((outputId: string, opts?: { newInstance?: boolean }) => {
|
||||
dispatch(addViewCard({ outputId, expandedSessionIds, newInstance: opts?.newInstance }));
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.viewCards[outputId];
|
||||
// Focus whichever card the dispatch produced: with newInstance that's the highest-numbered instance of this output, else the primary.
|
||||
const viewCards = store.getState().dashboardLayout.viewCards;
|
||||
let focusKey = outputId;
|
||||
if (opts?.newInstance) {
|
||||
for (const [key, vc] of Object.entries(viewCards)) {
|
||||
if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[focusKey]?.instance ?? 1)) focusKey = key;
|
||||
}
|
||||
}
|
||||
const card = viewCards[focusKey];
|
||||
if (card) {
|
||||
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
|
||||
handleHighlightCard(outputId);
|
||||
handleHighlightCard(focusKey);
|
||||
}
|
||||
}, 200);
|
||||
}, [dispatch, expandedSessionIds, canvasActions, handleHighlightCard]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { viewCardKey } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export type { CardType } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
|
||||
@@ -75,7 +76,7 @@ export function useDashboardSelection(
|
||||
const selectAll = useCallback(() => {
|
||||
const next = new Map<string, CardType>();
|
||||
for (const card of Object.values(cards)) next.set(card.session_id, 'agent');
|
||||
for (const vc of Object.values(viewCards)) next.set(vc.output_id, 'view');
|
||||
for (const vc of Object.values(viewCards)) next.set(viewCardKey(vc.output_id, vc.instance), 'view');
|
||||
for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser');
|
||||
for (const n of Object.values(notes)) next.set(n.note_id, 'note');
|
||||
for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow');
|
||||
@@ -134,7 +135,7 @@ export function useDashboardSelection(
|
||||
height: vc.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set(vc.output_id, 'view');
|
||||
intersecting.set(viewCardKey(vc.output_id, vc.instance), 'view');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,14 @@ const MAX_LINES_PER_FLUSH = 50;
|
||||
|
||||
interface PendingConsoleLine { level: string; text: string }
|
||||
|
||||
// Queues keyed by `${workspaceId}:${instance}` so two cards of the same app don't cross their console streams.
|
||||
const pendingByWorkspace = new Map<string, PendingConsoleLine[]>();
|
||||
const flushTimers = new Map<string, number>();
|
||||
|
||||
function flushConsoleLines(workspaceId: string): void {
|
||||
flushTimers.delete(workspaceId);
|
||||
const queue = pendingByWorkspace.get(workspaceId);
|
||||
function flushConsoleLines(workspaceId: string, instance: number): void {
|
||||
const qKey = `${workspaceId}:${instance}`;
|
||||
flushTimers.delete(qKey);
|
||||
const queue = pendingByWorkspace.get(qKey);
|
||||
if (!queue || queue.length === 0) return;
|
||||
const batch = queue.splice(0, MAX_LINES_PER_FLUSH);
|
||||
if (queue.length > 0) {
|
||||
@@ -31,23 +33,24 @@ function flushConsoleLines(workspaceId: string): void {
|
||||
const tok = getAuthToken();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (tok) headers.Authorization = `Bearer ${tok}`;
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/console-log`, {
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/console-log?instance=${instance}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ lines: batch }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export function postAppConsoleLine(workspaceId: string, level: string, text: string): void {
|
||||
export function postAppConsoleLine(workspaceId: string, level: string, text: string, instance: number = 1): void {
|
||||
if (!workspaceId || !text) return;
|
||||
let queue = pendingByWorkspace.get(workspaceId);
|
||||
const qKey = `${workspaceId}:${instance}`;
|
||||
let queue = pendingByWorkspace.get(qKey);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
pendingByWorkspace.set(workspaceId, queue);
|
||||
pendingByWorkspace.set(qKey, queue);
|
||||
}
|
||||
queue.push({ level, text });
|
||||
if (!flushTimers.has(workspaceId)) {
|
||||
flushTimers.set(workspaceId, window.setTimeout(() => flushConsoleLines(workspaceId), FLUSH_MS));
|
||||
if (!flushTimers.has(qKey)) {
|
||||
flushTimers.set(qKey, window.setTimeout(() => flushConsoleLines(workspaceId, instance), FLUSH_MS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,12 @@ export interface RuntimePreviewOptions {
|
||||
/** Gate the spawn so callers can defer paying runtime cost until preview is wanted. */
|
||||
enabled?: boolean;
|
||||
onLog?: (line: RuntimeLogLine) => void;
|
||||
/** Which independent instance of the app to attach (1 = primary). Each instance is its own process on its own ports. */
|
||||
instance?: number;
|
||||
}
|
||||
|
||||
export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePreviewState {
|
||||
const { workspaceId, enabled = true, onLog } = opts;
|
||||
const { workspaceId, enabled = true, onLog, instance = 1 } = opts;
|
||||
const [frontendUrl, setFrontendUrl] = useState<string | null>(null);
|
||||
const [isNewMode, setIsNewMode] = useState(false);
|
||||
const [isHydrating, setIsHydrating] = useState(true);
|
||||
@@ -53,7 +55,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start`, {
|
||||
await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start?instance=${instance}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
@@ -63,7 +65,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const wsBase = API_BASE.replace(/^http/, 'ws').replace(/\/api$/, '');
|
||||
const url = `${wsBase}/ws/outputs/runtime/${workspaceId}/logs?token=${encodeURIComponent(auth || '')}`;
|
||||
const url = `${wsBase}/ws/outputs/runtime/${workspaceId}/logs?token=${encodeURIComponent(auth || '')}&instance=${instance}`;
|
||||
ws = new WebSocket(url);
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
@@ -96,12 +98,12 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie
|
||||
setIsNewMode(false);
|
||||
setIsHydrating(true);
|
||||
// detach is ref-counted on the backend; fire-and-forget.
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop`, {
|
||||
fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop?instance=${instance}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [workspaceId, enabled]);
|
||||
}, [workspaceId, enabled, instance]);
|
||||
|
||||
return { frontendUrl, isNewMode, isHydrating };
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface CardPosition {
|
||||
|
||||
export interface ViewCardPosition {
|
||||
output_id: string;
|
||||
// Which instance of the app this card is (1 = primary, absent on pre-instance layouts). Each instance is a fully independent runtime on its own ports.
|
||||
instance?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -52,6 +54,11 @@ export interface ViewCardPosition {
|
||||
parent_session_id?: string | null;
|
||||
}
|
||||
|
||||
// Record key + card identity for a view card. The primary keeps the bare output_id so persisted layouts and every existing by-output lookup stay valid; secondaries append #N.
|
||||
export function viewCardKey(outputId: string, instance?: number): string {
|
||||
return (instance ?? 1) > 1 ? `${outputId}#${instance}` : outputId;
|
||||
}
|
||||
|
||||
export interface BrowserTab {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -632,7 +639,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
|
||||
const allItems = [
|
||||
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: viewCardKey(c.output_id, c.instance), x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...(hub ? [{ kind: 'workflows-hub' as const, id: 'workflows-hub', x: hub.x, y: hub.y, storedW: hub.width, storedH: hub.height }] : []),
|
||||
@@ -679,9 +686,16 @@ const dashboardLayoutSlice = createSlice({
|
||||
outputId: string; expandedSessionIds?: string[];
|
||||
parentSessionId?: string | null;
|
||||
x?: number; y?: number; width?: number; height?: number;
|
||||
// Open ANOTHER independent instance of an already-open app instead of no-op'ing.
|
||||
newInstance?: boolean;
|
||||
}>) {
|
||||
const { outputId, expandedSessionIds, parentSessionId, x, y, width, height } = action.payload;
|
||||
if (state.viewCards[outputId]) return;
|
||||
const { outputId, expandedSessionIds, parentSessionId, x, y, width, height, newInstance } = action.payload;
|
||||
let instance = 1;
|
||||
if (state.viewCards[outputId]) {
|
||||
if (!newInstance) return;
|
||||
instance = 2;
|
||||
while (state.viewCards[viewCardKey(outputId, instance)]) instance++;
|
||||
}
|
||||
const w = width || DEFAULT_VIEW_CARD_W;
|
||||
const h = height || DEFAULT_VIEW_CARD_H;
|
||||
let posX: number, posY: number;
|
||||
@@ -701,8 +715,9 @@ const dashboardLayoutSlice = createSlice({
|
||||
posY = pos.y;
|
||||
}
|
||||
}
|
||||
state.viewCards[outputId] = {
|
||||
state.viewCards[viewCardKey(outputId, instance)] = {
|
||||
output_id: outputId,
|
||||
instance,
|
||||
x: posX,
|
||||
y: posY,
|
||||
width: w,
|
||||
@@ -1352,7 +1367,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (entry.kind === 'browser') {
|
||||
state.browserCards[entry.card.browser_id] = { ...entry.card, zOrder, dashboard_id: dashboardId ?? entry.card.dashboard_id };
|
||||
} else if (entry.kind === 'view') {
|
||||
state.viewCards[entry.card.output_id] = { ...entry.card, zOrder };
|
||||
state.viewCards[viewCardKey(entry.card.output_id, entry.card.instance)] = { ...entry.card, zOrder };
|
||||
} else if (entry.kind === 'workflow') {
|
||||
state.workflowCards[entry.card.workflow_id] = { ...entry.card, zOrder };
|
||||
} else if (entry.kind === 'note') {
|
||||
|
||||
@@ -34,6 +34,32 @@ mkdir -p "$DEST"
|
||||
( cd "$TMP/clone" && rm -rf .git LICENSE README.md .gitignore )
|
||||
cp -R "$TMP/clone/." "$DEST/"
|
||||
|
||||
# Patch 1a: root run.sh honors per-instance port overrides. OpenSwarm passes
|
||||
# OPENSWARM_FORCE_FRONTEND_PORT / OPENSWARM_FORCE_BACKEND_PORT when the user
|
||||
# opens a SECOND instance of an app; without this the `source .env` above
|
||||
# them pins every instance to the same ports.
|
||||
ROOT_RUN_SH="$DEST/run.sh"
|
||||
if ! grep -q "OPENSWARM_FORCE_FRONTEND_PORT" "$ROOT_RUN_SH"; then
|
||||
awk '
|
||||
inserted != 1 && sourced && /^fi$/ {
|
||||
print
|
||||
print ""
|
||||
print "# Per-instance port overrides: OpenSwarm passes these when the user opens a SECOND instance of the app, so it boots on fresh ports instead of colliding with the primary'\''s .env-pinned ones."
|
||||
print "if [[ -n \"${OPENSWARM_FORCE_FRONTEND_PORT:-}\" ]]; then"
|
||||
print " export FRONTEND_PORT=\"$OPENSWARM_FORCE_FRONTEND_PORT\""
|
||||
print "fi"
|
||||
print "if [[ -n \"${OPENSWARM_FORCE_BACKEND_PORT:-}\" ]]; then"
|
||||
print " export BACKEND_PORT=\"$OPENSWARM_FORCE_BACKEND_PORT\""
|
||||
print "fi"
|
||||
inserted = 1
|
||||
next
|
||||
}
|
||||
/source "\$ROOT_DIR\/.env"/ { sourced = 1 }
|
||||
{ print }
|
||||
' "$ROOT_RUN_SH" > "$ROOT_RUN_SH.tmp" && mv "$ROOT_RUN_SH.tmp" "$ROOT_RUN_SH"
|
||||
chmod +x "$ROOT_RUN_SH"
|
||||
fi
|
||||
|
||||
# Patch 1: backend/run.sh forces all swarm-debug per-file toggles ON at
|
||||
# every boot (they default OFF, including files the agent creates later),
|
||||
# so `debug()` output actually lands in the App Builder Terminal. Runs
|
||||
|
||||
Reference in New Issue
Block a user