[eric] apps: multiple independent instances per app (own runtime + ports; alt-click or paste to open)

This commit is contained in:
ciregenz
2026-07-02 01:24:22 -07:00
parent a4e81a8f2f
commit 85d77ca552
23 changed files with 345 additions and 149 deletions
+2
View File
@@ -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
+2
View File
@@ -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
+20 -20
View File
@@ -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()
+75 -52
View File
@@ -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
+11 -5
View File
@@ -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
View File
@@ -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');
}
}
+12 -9
View File
@@ -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') {
+26
View File
@@ -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