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

833 lines
37 KiB
Python

import json
import os
import logging
import mimetypes
from datetime import datetime
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import HTTPException, Query
from fastapi.responses import Response
from backend.auth import get_auth_token
from backend.config.Apps import SubApp
from backend.apps.outputs.models import (
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
VibeCodeRequest, WorkspaceSeedRequest, AgentCreateAppRequest,
PublishPreflightRequest, PublishRequest, PublishPreflightResponse,
PublishResult, PublishReview,
)
from backend.apps.outputs.code_safety import get_code_warnings
from backend.apps.outputs.executor import execute_backend_code
from backend.apps.outputs.publish_common import slugify, PublishError
from backend.apps.outputs.publish_scan import scan_for_publish, quick_ast_gate
from backend.apps.outputs.publish_build import build_static, collect_bundle
from backend.apps.outputs.publish_cloud import upload_to_cloud, unpublish_from_cloud
from backend.apps.outputs.view_builder_templates import (
VIEW_TEMPLATE_FILES,
load_app_builder_skill,
seed_webapp_template_workspace,
)
from backend.apps.settings.settings import load_settings
from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
from backend.apps.outputs.html_inject import (
get_anthropic_client,
validate_against_schema,
build_data_injection,
inject_data_into_html,
backend_url_for_workspace,
inject_token_into_relative_urls,
decode_data_param,
)
from backend.apps.outputs.workspace_io import (
load_all,
save,
load,
load_output,
resolve_in_workspace,
walk_directory,
workspace_root,
would_shrink_oversize_file,
)
from backend.apps.outputs.prompts import VIBE_CODE_SYSTEM_PROMPT
logger = logging.getLogger(__name__)
@asynccontextmanager
async def outputs_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(WORKSPACE_DIR, exist_ok=True)
try:
yield
finally:
# Reap every per-app subprocess. Without this each `bash run.sh` (and its vite/uvicorn descendants) reparents to PID 1 when the main backend dies, leaving ghost listeners on the .env-pinned ports that block the next OpenSwarm launch's reload preview.
try:
from backend.apps.outputs.runtime import manager as runtime_manager
killed = await runtime_manager.stop_all()
if killed:
logger.info("outputs lifespan: reaped %d workspace runtimes on shutdown", killed)
except Exception:
logger.exception("outputs lifespan: stop_all failed")
outputs = SubApp("outputs", outputs_lifespan)
# --------------------------------------------------------------------------- File-serving endpoints (for iframe preview with multi-file support) ---------------------------------------------------------------------------
@outputs.router.get("/workspace/{workspace_id}/serve/{filepath:path}")
async def serve_workspace_file(workspace_id: str, filepath: str, p_d: str = ""):
"""Serve a file from a workspace folder. For index.html, inject OUTPUT data."""
folder = os.path.join(WORKSPACE_DIR, workspace_id)
full_path = resolve_in_workspace(folder, filepath)
if full_path is None:
raise HTTPException(status_code=403, detail="Path traversal not allowed")
if not os.path.isfile(full_path):
raise HTTPException(status_code=404, detail="File not found")
with open(full_path) as f:
content = f.read()
if filepath == "index.html":
input_json, result_json = decode_data_param(p_d) if p_d else ("{}", "null")
backend_url_json = backend_url_for_workspace(workspace_id)
content = inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the parent's ?token= query string, so rewrite the HTML to put the token back on every relative URL; otherwise sub-resources 401.
content = inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
return Response(content=content, media_type=mime or "text/plain")
@outputs.router.get("/{output_id}/serve/{filepath:path}")
async def serve_output_file(output_id: str, filepath: str, p_d: str = ""):
"""Serve a file from a saved output's files dict. For index.html, inject OUTPUT data."""
output = load(output_id)
content = output.files.get(filepath)
if content is None:
raise HTTPException(status_code=404, detail="File not found in output")
if filepath == "index.html":
input_json, result_json = decode_data_param(p_d) if p_d else ("{}", "null")
backend_url_json = backend_url_for_workspace(output.workspace_id) if output.workspace_id else "null"
content = inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
content = inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
return Response(content=content, media_type=mime or "text/plain")
# --------------------------------------------------------------------------- CRUD + workspace endpoints ---------------------------------------------------------------------------
@outputs.router.get("/list")
async def list_outputs():
return {"outputs": [o.model_dump() for o in load_all()]}
@outputs.router.get("/workspace/{workspace_id}")
async def read_workspace(workspace_id: str):
"""Read all files from an output workspace folder."""
folder = os.path.join(WORKSPACE_DIR, workspace_id)
if not os.path.isdir(folder):
raise HTTPException(status_code=404, detail="Workspace not found")
files, truncated = walk_directory(folder)
meta = None
if "meta.json" in files:
try:
meta = json.loads(files["meta.json"])
except (json.JSONDecodeError, ValueError):
pass
# Include `path` so the frontend can rehydrate without re-calling /seed. /seed unconditionally overwrites, which would clobber any in-progress edits the agent made since the last save. `truncated` lists oversize files omitted from `files` so the editor shows them read-only instead of writing back a stub.
return {"files": files, "meta": meta, "path": os.path.abspath(folder), "truncated": truncated}
def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = None) -> bool:
"""Sync the Output row's name/description from meta.json (or fallback_name when
meta.json has no name). Only overwrites placeholder values; user renames win."""
try:
folder = os.path.join(WORKSPACE_DIR, workspace_id)
meta_path = os.path.join(folder, "meta.json")
name = ""
description = ""
if os.path.exists(meta_path):
try:
with open(meta_path) as f:
meta = json.load(f)
if isinstance(meta, dict):
name = str(meta.get("name") or "").strip()
description = str(meta.get("description") or "").strip()
except (OSError, json.JSONDecodeError, ValueError):
pass
if not name and fallback_name:
name = str(fallback_name).strip()
if not name and not description:
return False
matching = [o for o in load_all() if o.workspace_id == workspace_id]
if not matching:
return False
output = matching[0]
changed = False
if name and output.name in ("", "Untitled App") and output.name != name:
output.name = name
changed = True
if description and not output.description and output.description != description:
output.description = description
changed = True
if changed:
output.updated_at = datetime.now().isoformat()
save(output)
return changed
except Exception:
logger.exception("sync_output_from_meta_json failed for %s", workspace_id)
return False
def ensure_webapp_workspace_seeded_and_registered(
workspace_id: str,
folder: str,
session_id: Optional[str] = None,
) -> Optional[str]:
"""Idempotently seed the webapp template into `folder` and register an
Output row pointing at `workspace_id`. Used by the canvas-chat launch
path so picking "App Builder" from the mode dropdown produces the same
sidebar visibility as the Apps editor's `/workspace/seed` flow.
When `session_id` is supplied, it is persisted on the Output row so the
Apps editor can reattach to the same chat history later (without this
link, double-clicking the app card opens an empty editor instead of
the conversation the user already had with the agent).
Idempotency:
- If `run.sh` already exists in the folder, skip the template copy
(matches the seed_workspace endpoint's idempotency guard).
- If any Output already points at this workspace_id, reuse it but
still attach session_id if it's missing.
Returns the output_id on success, None on failure (best-effort; the
caller's session still launches even if registration fails).
"""
try:
os.makedirs(folder, exist_ok=True)
already_seeded = os.path.exists(os.path.join(folder, "run.sh"))
if not already_seeded:
from backend.apps.outputs.runtime import find_free_port
frontend_port = find_free_port()
seed_webapp_template_workspace(folder, frontend_port)
with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f:
f.write(load_app_builder_skill())
existing = [o for o in load_all() if o.workspace_id == workspace_id]
if existing:
output = existing[0]
if session_id and output.session_id != session_id:
output.session_id = session_id
output.updated_at = datetime.now().isoformat()
save(output)
return output.id
now = datetime.now().isoformat()
output = Output(
name="Untitled App",
description="",
icon="view_quilt",
files={},
workspace_id=workspace_id,
session_id=session_id,
created_at=now,
updated_at=now,
)
save(output)
return output.id
except Exception:
logger.exception("ensure_webapp_workspace_seeded_and_registered failed for %s", workspace_id)
return None
@outputs.router.post("/agent-create")
async def agent_create_app(body: AgentCreateAppRequest):
"""The CreateApp MCP tool's backend: seed a webapp-template workspace, register
the Output linked to the calling agent session, name it, and broadcast so the
dashboard drops a live card next to the agent. Any agent, any mode."""
from uuid import uuid4
workspace_id = uuid4().hex
folder = os.path.join(WORKSPACE_DIR, workspace_id)
output_id = ensure_webapp_workspace_seeded_and_registered(
workspace_id=workspace_id,
folder=folder,
session_id=body.parent_session_id or None,
)
if not output_id:
raise HTTPException(status_code=500, detail="workspace seed/registration failed")
output = load(output_id)
output.name = body.name.strip() or "Untitled App"
output.description = body.description.strip()
output.updated_at = datetime.now().isoformat()
save(output)
# Keep meta.json in agreement so the agent's own naming pass and the sidebar read the same values.
try:
with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f:
json.dump({"name": output.name, "description": output.description}, f, indent=2)
except OSError:
logger.exception("agent-create meta.json write failed for %s", workspace_id)
from backend.apps.agents.core.ws_manager import ws_manager
try:
await ws_manager.broadcast_global("agent:output_upserted", {
"output": output.model_dump(mode="json"),
})
except Exception:
logger.exception("agent-create output_upserted broadcast failed")
# The reference isn't returned here: it's written to <folder>/SKILL.md at seed time and the agent reads it on demand, keeping the ~6.5k-token blob out of both this response and the agent transcript.
return {
"ok": True,
"output_id": output_id,
"path": os.path.abspath(folder),
}
@outputs.router.post("/workspace/seed")
async def seed_workspace(body: WorkspaceSeedRequest):
"""Create a workspace folder and pre-seed it.
Two seeding modes:
- **`template_mode="flat"`** (current default): writes the legacy
VIEW_TEMPLATE_FILES (single index.html + meta.json + schema.json).
Used by every workspace created so far. Runtime spawns
`python -u backend.py` (if present) and the preview pane fetches
from `/api/outputs/workspace/{ws}/serve/...`.
- **`template_mode="webapp_template"`**: copies the vendored
openswarm-ai/webapp-template snapshot (React + Vite + TS frontend
with an optional FastAPI backend) into the workspace, allocates a
free FRONTEND_PORT and writes it into both `.env` and
`.env.example`. BACKEND_PORT stays NONE; the agent opts in with
`bash backend_init.sh`. Runtime spawn flips to `bash run.sh` and
the preview pane points at `http://localhost:{FRONTEND_PORT}/`.
`body.files` is ignored in this mode; the snapshot is the source
of truth.
"""
folder = os.path.join(WORKSPACE_DIR, body.workspace_id)
os.makedirs(folder, exist_ok=True)
# An explicit non-empty `files` payload means the caller has flat-mode content to write (a saved legacy Output being reseeded). Don't clobber that with the React template even if template_mode is the new default; the migration helper has its own path for that.
effective_mode = body.template_mode
if body.files:
effective_mode = "flat"
if effective_mode == "webapp_template":
# Idempotency guard: re-seeding an existing webapp_template workspace would clobber the agent's edits (the helper uses dirs_exist_ok=True + copytree). If `run.sh` already exists, the workspace was seeded on a previous visit; skip the file copy and only re-derive the frontend port from .env.
from backend.apps.outputs.runtime import find_free_port, read_env_value
already_seeded = os.path.exists(os.path.join(folder, "run.sh"))
if already_seeded:
fp_raw = read_env_value(os.path.join(folder, ".env"), "FRONTEND_PORT")
try:
frontend_port = int(fp_raw) if fp_raw else find_free_port()
except (TypeError, ValueError):
frontend_port = find_free_port()
else:
frontend_port = find_free_port()
seed_webapp_template_workspace(folder, frontend_port)
# SKILL.md still goes in workspace root; agent reads it for context. Live content (user-editable via Skills page) is injected into the system prompt regardless.
with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f:
f.write(load_app_builder_skill())
meta = body.meta or {}
if body.meta and not already_seeded:
with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f:
json.dump(body.meta, f, indent=2)
# Create (or look up) the Output record so the app appears in the Apps sidebar the moment the user kicks off generation. Previously the record only landed when the editor's autosave fired, which itself was gated on `files['index.html']` being non-empty (a flat-template invariant); meaning React+Vite apps that navigated-away mid-build had no way back. The record is a thin pointer (name + workspace_id); the workspace itself remains the source of truth for the code.
output_id: Optional[str] = None
try:
existing = [o for o in load_all() if o.workspace_id == body.workspace_id]
if existing:
output_id = existing[0].id
else:
now = datetime.now().isoformat()
output = Output(
name=str(meta.get("name") or "Untitled App"),
description=str(meta.get("description") or ""),
icon="view_quilt",
files={},
workspace_id=body.workspace_id,
created_at=now,
updated_at=now,
)
save(output)
output_id = output.id
except Exception:
logger.exception("seed-time Output create failed for %s", body.workspace_id)
return {
"path": os.path.abspath(folder),
"template_mode": "webapp_template",
"frontend_port": frontend_port,
"output_id": output_id,
"already_seeded": already_seeded,
}
# Legacy flat path. Seed only fills in MISSING files; it never overwrites what's already on disk. A reopen re-sends the inline output.files snapshot, which lags behind whatever the agent just wrote to the workspace; writing it back reverted every edited file (new files survived, edited ones snapped to the snapshot). Disk wins once an app exists.
if body.files:
for rel_path, content in body.files.items():
full_path = resolve_in_workspace(folder, rel_path)
if full_path is None:
continue
if os.path.exists(full_path):
continue
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w", encoding="utf-8") as f:
f.write(content)
else:
for rel_path, content in VIEW_TEMPLATE_FILES.items():
full_path = os.path.join(folder, rel_path)
if os.path.exists(full_path):
continue
with open(full_path, "w", encoding="utf-8") as f:
f.write(content)
# SKILL.md is a creation-time snapshot; the live rules reach the agent via the system-prompt injection regardless, so never rewrite an existing one.
skill_path = os.path.join(folder, "SKILL.md")
if not os.path.exists(skill_path):
with open(skill_path, "w", encoding="utf-8") as f:
f.write(load_app_builder_skill())
if body.meta:
meta_path = os.path.join(folder, "meta.json")
if not os.path.exists(meta_path):
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(body.meta, f, indent=2)
return {"path": os.path.abspath(folder), "template_mode": "flat"}
# --------------------------------------------------------------------------- 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, 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, 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)
is_new = is_new_mode(folder) if os.path.isdir(folder) else False
return {
"running": False,
"port": None,
"has_backend_file": False,
"backend_url": None,
"frontend_port": None,
"frontend_url": None,
"is_new_mode": is_new,
}
return {
"running": rt.running,
"port": rt.port,
"has_backend_file": rt.has_backend_file,
# For old-mode: backend.py serves; backend_url is its port. For new-mode: backend.py is optional (gated by BACKEND_PORT!=NONE); only populated if the agent ran bash backend_init.sh.
"backend_url": f"http://127.0.0.1:{rt.port}" if rt.running and rt.port else None,
# New-mode only: where the Vite dev server is reachable. Old-mode workspaces report null and the editor falls back to the legacy /api/outputs/workspace/{ws}/serve/... path.
"frontend_port": rt.frontend_port,
"frontend_url": rt.frontend_url if rt.running else None,
"is_new_mode": rt.is_new_mode,
}
@outputs.router.post("/workspace/{workspace_id}/runtime/start")
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), instance)
return runtime_status_payload(workspace_id, instance)
@outputs.router.post("/workspace/{workspace_id}/runtime/stop")
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, instance)
return runtime_status_payload(workspace_id, instance)
@outputs.router.post("/workspace/{workspace_id}/runtime/restart")
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, instance)
if rt:
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, 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, instance: int = 1):
from backend.apps.outputs.runtime import manager as runtime_manager
rt = runtime_manager.get(workspace_id, instance)
if rt is None:
return {"ok": False, "recorded": 0}
message = (body.get("message") or "").strip()
component_stack = (body.get("componentStack") or "").strip()
if not message:
return {"ok": False, "recorded": 0}
composed = message
if component_stack:
composed = f"{composed}\n{component_stack}"
rt.set_render_error(composed)
return {"ok": True, "recorded": 1}
@outputs.router.post("/workspace/{workspace_id}/runtime/console-log")
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, instance)
if rt is None:
return {"ok": False, "recorded": 0}
lines = body.get("lines") or []
recorded = 0
for entry in lines[:200]:
text = str(entry.get("text") or "").strip()
if not text:
continue
rt.record_frontend_log(str(entry.get("level") or "log"), text)
recorded += 1
return {"ok": True, "recorded": recorded}
@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready")
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, instance)
if rt is None:
return {"ok": False}
rt.set_render_ok()
return {"ok": True}
@outputs.router.post("/shutdown-all")
async def runtime_shutdown_all():
"""Reap every workspace subprocess. Electron POSTs this during
will-quit so app subprocesses die BEFORE the main backend gets
SIGTERM'd; without it `bash run.sh` + its vite/uvicorn descendants
reparent to PID 1 and squat on .env-pinned ports forever."""
from backend.apps.outputs.runtime import manager as runtime_manager
killed = await runtime_manager.stop_all()
return {"ok": True, "killed": killed}
@outputs.router.put("/workspace/{workspace_id}/file/{filepath:path}")
async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
"""Write (create/overwrite) a single file in a workspace."""
folder = os.path.join(WORKSPACE_DIR, workspace_id)
if not os.path.isdir(folder):
raise HTTPException(status_code=404, detail="Workspace not found")
full_path = resolve_in_workspace(folder, filepath)
if full_path is None:
raise HTTPException(status_code=403, detail="Path traversal not allowed")
content = body.get("content", "")
if would_shrink_oversize_file(full_path, content):
return {"ok": True, "skipped": "oversize"}
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w", encoding="utf-8") as f:
f.write(content)
return {"ok": True}
@outputs.router.delete("/workspace/{workspace_id}/file/{filepath:path}")
async def delete_workspace_file(workspace_id: str, filepath: str):
"""Delete a single file from a workspace."""
folder = os.path.join(WORKSPACE_DIR, workspace_id)
if not os.path.isdir(folder):
raise HTTPException(status_code=404, detail="Workspace not found")
full_path = resolve_in_workspace(folder, filepath)
if full_path is None:
raise HTTPException(status_code=403, detail="Path traversal not allowed")
if os.path.isfile(full_path):
os.remove(full_path)
root = workspace_root(folder)
parent = os.path.dirname(full_path)
while parent != root:
if os.path.isdir(parent) and not os.listdir(parent):
os.rmdir(parent)
parent = os.path.dirname(parent)
else:
break
return {"ok": True}
@outputs.router.get("/{output_id}")
async def get_output(output_id: str):
return load(output_id).model_dump()
@outputs.router.post("/create")
async def create_output(body: OutputCreate):
now = datetime.now().isoformat()
output = Output(
name=body.name,
description=body.description,
icon=body.icon,
input_schema=body.input_schema,
files=body.files,
thumbnail=body.thumbnail,
created_at=now,
updated_at=now,
)
save(output)
return {"ok": True, "output": output.model_dump()}
@outputs.router.put("/{output_id}")
async def update_output(output_id: str, body: OutputUpdate):
output = load(output_id)
# exclude_unset, NOT exclude_none: a PUT that explicitly sends session_id=null (the Apps stale-link self-heal) must clear the field. exclude_none silently dropped that null, so the dead pointer never cleared and the app 404'd on every open, forever. Unset fields stay untouched; only what the client sent applies.
for k, v in body.model_dump(exclude_unset=True).items():
setattr(output, k, v)
now = datetime.now().isoformat()
output.updated_at = now
# Only a real screenshot write moves the sort key; files/linkage saves don't reorder.
if body.thumbnail is not None:
output.preview_updated_at = now
save(output)
return {"ok": True, "output": output.model_dump()}
@outputs.router.delete("/{output_id}")
async def delete_output(output_id: str):
load(output_id)
path = os.path.join(DATA_DIR, f"{output_id}.json")
if os.path.exists(path):
os.remove(path)
from backend.apps.outputs import versions
versions.delete_all(output_id)
return {"ok": True}
@outputs.router.post("/vibe-code")
async def vibe_code(body: VibeCodeRequest):
"""Use an LLM to generate or iterate on Output code from a natural language prompt."""
try:
import anthropic
except ImportError:
return {
"message": "anthropic SDK not installed. Install with: pip install anthropic",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
context_parts = []
if body.current_frontend_code:
context_parts.append(f"Current frontend code:\n```html\n{body.current_frontend_code}\n```")
if body.current_backend_code:
context_parts.append(f"Current backend code:\n```python\n{body.current_backend_code}\n```")
if body.current_schema:
context_parts.append(f"Current input schema:\n```json\n{body.current_schema}\n```")
if body.name:
context_parts.append(f"Current name: {body.name}")
if body.description:
context_parts.append(f"Current description: {body.description}")
user_message = body.prompt
if context_parts:
user_message = "\n\n".join(context_parts) + "\n\nUser request: " + body.prompt
from backend.apps.agents.providers.registry import resolve_aux_model
try:
aux_model, p_aux_base = await resolve_aux_model(load_settings(), preferred_tier="sonnet")
except ValueError as e:
return {
"message": f"Error: {str(e)}",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
client = get_anthropic_client(aux_model)
try:
resp = await client.messages.create(
model=aux_model,
max_tokens=8000,
system=VIBE_CODE_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
from backend.apps.agents.core.aux_llm import safe_resp_text
raw = safe_resp_text(resp).strip()
if not raw:
return {
"message": "Aux model returned no content. Please try again.",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
raw = raw[:-3]
result = json.loads(raw)
return {
"message": result.get("message", "View updated."),
"frontend_code": result.get("frontend_code", body.current_frontend_code),
"backend_code": result.get("backend_code", body.current_backend_code),
"input_schema": result.get("input_schema", body.current_schema),
"name": result.get("name", body.name),
"description": result.get("description", body.description),
}
except json.JSONDecodeError:
return {
"message": "I generated code but couldn't parse the response. Please try again.",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
except Exception as e:
logger.exception("Vibe code generation failed")
return {
"message": f"Error: {str(e)}",
"frontend_code": body.current_frontend_code,
"backend_code": body.current_backend_code,
"input_schema": body.current_schema,
}
@outputs.router.post("/execute")
async def execute_output(body: OutputExecute):
output = load(body.output_id)
validation_err = validate_against_schema(body.input_data, output.input_schema)
if validation_err:
return OutputExecuteResult(
output_id=output.id,
output_name=output.name,
frontend_code=output.frontend_code,
input_data=body.input_data,
backend_result=None,
error=validation_err,
).model_dump()
backend_result = None
stdout_text = None
stderr_text = None
error = None
warnings_out: Optional[list[str]] = None
code_preview: Optional[str] = None
if output.backend_code:
# HITL gate: collect warnings up front. If the caller hasn't opted in via force=True AND the code touches anything outside the safe allowlist, return the warnings + the code itself so the UI can show a preview dialog. No subprocess is spawned on this path, zero-cost when warnings exist, identical-to-before when they don't.
if not body.force:
warnings_out = get_code_warnings(output.backend_code)
if warnings_out:
code_preview = output.backend_code
if not warnings_out:
try:
# `approved` is body.force alone: code that merely passed the gate above still runs sandboxed, because a clean scan is not consent.
exec_result = await execute_backend_code(
output.backend_code, body.input_data, approved=bool(body.force)
)
backend_result = exec_result.result
stdout_text = exec_result.stdout
stderr_text = exec_result.stderr
except Exception as e:
error = str(e)
return OutputExecuteResult(
output_id=output.id,
output_name=output.name,
frontend_code=output.frontend_code,
input_data=body.input_data,
backend_result=backend_result,
stdout=stdout_text,
stderr=stderr_text,
error=error,
warnings=warnings_out if warnings_out else None,
code_preview=code_preview,
).model_dump()
# --------------------------------------------------------------------------- Publishing to {slug}.openswarm.host ---------------------------------------------------------------------------
@outputs.router.post("/publish/preflight")
async def publish_preflight(body: PublishPreflightRequest):
"""Scan the app (AST + an aux-LLM pass on the user's own creds) and return a
review. No build, no cloud call; this just drives the security modal."""
output = load(body.output_id)
review = await scan_for_publish(output, load_settings())
return PublishPreflightResponse(review=review).model_dump()
@outputs.router.post("/publish")
async def publish_output(body: PublishRequest):
"""Build (webapp) + bundle + upload to the cloud host. `force` skips the
cheap AST safety net (the user already saw the findings in the review modal)."""
output = load(body.output_id)
settings = load_settings()
if not body.force:
ast = quick_ast_gate(output)
if ast:
return PublishResult(
ok=False,
blocked=True,
review=PublishReview(verdict="warn", findings=ast),
).model_dump()
output.publish_status = "publishing"
output.publish_error = None
save(output)
try:
dist = await build_static(output)
bundle = collect_bundle(output, dist)
slug_hint = slugify(body.slug or output.name)
res = await upload_to_cloud(
settings,
output_id=output.id,
name=output.name,
slug_hint=slug_hint,
bundle=bundle,
override=body.force,
)
except PublishError as e:
output.publish_status = "error"
output.publish_error = str(e)
save(output)
return PublishResult(ok=False, error=str(e)).model_dump()
except Exception as e:
logger.exception("publish failed for %s", output.id)
output.publish_status = "error"
output.publish_error = "Something went wrong while publishing."
save(output)
return PublishResult(ok=False, error=output.publish_error).model_dump()
output.published_slug = res.get("slug")
output.published_url = res.get("url")
output.publish_status = "published"
output.publish_error = None
save(output)
return PublishResult(
ok=True,
published_slug=output.published_slug,
published_url=output.published_url,
).model_dump()
@outputs.router.post("/unpublish")
async def unpublish_output(body: PublishPreflightRequest):
"""Take the app offline and clear its publish state."""
output = load(body.output_id)
if output.published_slug:
try:
await unpublish_from_cloud(load_settings(), output.published_slug)
except PublishError as e:
return {"ok": False, "error": str(e)}
output.published_slug = None
output.published_url = None
output.publish_status = None
output.publish_error = None
save(output)
return {"ok": True}