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

970 lines
40 KiB
Python

import json
import os
import re
import logging
import mimetypes
import base64
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 jsonschema import validate as schema_validate, ValidationError as SchemaValidationError
from backend.config.Apps import SubApp
from backend.apps.outputs.models import (
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
VibeCodeRequest, WorkspaceSeedRequest,
)
from backend.apps.outputs.executor import execute_backend_code, get_code_warnings
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
logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
"haiku": "claude-haiku-4-5-20251001",
}
def _resolve_model(short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def _get_anthropic_client(api_model: str | None = None):
"""Create an AsyncAnthropic client using the API key from app settings.
When `api_model` is provided and carries a 9Router prefix (cc/, cx/, gc/),
the client is pointed at 9Router so non-Anthropic aux calls don't 400 on
api.anthropic.com. Without an api_model we fall back to the default
connection-mode-driven client.
"""
from backend.apps.settings.credentials import (
get_anthropic_client,
get_anthropic_client_for_model,
)
settings = load_settings()
if api_model:
return get_anthropic_client_for_model(settings, api_model)
return get_anthropic_client(settings)
def _validate_against_schema(data: dict, schema: dict) -> str | None:
"""Validate *data* against *schema*. Return an error string or None."""
try:
schema_validate(instance=data, schema=schema)
return None
except SchemaValidationError as exc:
path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)"
return f"Schema validation failed at {path}: {exc.message}"
from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null") -> str:
"""Build a <script> tag that sets OUTPUT_INPUT / OUTPUT_BACKEND_RESULT /
OUTPUT_BACKEND_URL and listens for postMessage updates.
OUTPUT_BACKEND_URL is `null` when the app has no live `backend.py`
process; otherwise it's `http://localhost:<port>` and app code can
`fetch(window.OUTPUT_BACKEND_URL + '/route')` to hit the persistent
backend's endpoints."""
return (
"<script>\n"
"(function() {\n"
" window.OUTPUT_INPUT = " + input_json + ";\n"
" window.OUTPUT_BACKEND_RESULT = " + result_json + ";\n"
" window.OUTPUT_BACKEND_URL = " + backend_url_json + ";\n"
" window.addEventListener('message', function(e) {\n"
" if (e.data && e.data.type === 'OUTPUT_DATA') {\n"
" window.OUTPUT_INPUT = e.data.input || {};\n"
" window.OUTPUT_BACKEND_RESULT = e.data.backendResult || null;\n"
" if (e.data.backendUrl !== undefined) window.OUTPUT_BACKEND_URL = e.data.backendUrl;\n"
" window.dispatchEvent(new CustomEvent('output-data-ready'));\n"
" }\n"
" });\n"
"})();\n"
"</script>"
)
def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null") -> str:
injection = _build_data_injection(input_json, result_json, backend_url_json)
if "</head>" in html:
return html.replace("</head>", f"{injection}\n</head>", 1)
if "<body" in html:
return html.replace("<body", f"{injection}\n<body", 1)
return f"{injection}\n{html}"
def _backend_url_for_workspace(workspace_id: str) -> str:
"""Return the JSON-encoded backend URL for the given workspace, or
"null" if no runtime is active. Cheap inline lookup so serve_workspace_file
doesn't have to think about it."""
try:
from backend.apps.outputs.runtime import manager as runtime_manager
rt = runtime_manager.get(workspace_id)
if rt and rt.running and rt.port:
return json.dumps(f"http://127.0.0.1:{rt.port}")
except Exception:
logger.exception("backend url lookup failed for %s", workspace_id)
return "null"
# URL schemes / prefixes that must NOT have ?token= appended. These are either
# external (CDNs, mailto) or non-network references that the auth middleware
# never sees. Anything else is treated as a same-origin relative URL pointing
# at our /api/outputs/.../serve/ subtree, which DOES need the token.
_ABSOLUTE_URL_PREFIXES = (
"http://", "https://", "//", "data:", "blob:",
"mailto:", "tel:", "javascript:", "about:", "#",
)
_HREF_SRC_ATTR_RE = re.compile(
r"""(\s(?:href|src))\s*=\s*(["'])([^"']+)\2""",
re.IGNORECASE,
)
def _inject_token_into_relative_urls(html: str, token: str) -> str:
"""Append `?token=<t>` to every relative href/src in the served HTML.
Browsers strip the parent iframe URL's query string before resolving
relative `<link href="styles.css">` / `<script src="x.js">`, so without
this rewrite the sub-resource fetch lands at the auth middleware with no
credentials and gets a 401. Idempotent: skips URLs that already carry a
`token=` param. Skips absolute URLs (CDN, data:, etc.); see prefix list.
"""
if not token:
return html
def _patch(match: re.Match) -> str:
attr, quote, url = match.group(1), match.group(2), match.group(3)
lowered = url.lower().lstrip()
if lowered.startswith(_ABSOLUTE_URL_PREFIXES):
return match.group(0)
if "token=" in url:
return match.group(0)
# Split off any hash fragment so `?token=` lands in the query, not in
# the fragment: `page.html?v=1#sec` → `page.html?v=1&token=X#sec`.
hash_idx = url.find("#")
if hash_idx >= 0:
base, frag = url[:hash_idx], url[hash_idx:]
else:
base, frag = url, ""
sep = "&" if "?" in base else "?"
return f'{attr}={quote}{base}{sep}token={token}{frag}{quote}'
return _HREF_SRC_ATTR_RE.sub(_patch, html)
def _decode_data_param(d: str) -> tuple[str, str]:
"""Decode the base64-encoded _d query param into (input_json, result_json)."""
try:
decoded = json.loads(base64.b64decode(d))
input_json = json.dumps(decoded.get("i", {}))
result_json = json.dumps(decoded.get("r", None))
return input_json, result_json
except Exception:
return "{}", "null"
@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)
def _load_all() -> list[Output]:
result = []
if not os.path.exists(DATA_DIR):
return result
for fname in os.listdir(DATA_DIR):
if fname.endswith(".json"):
with open(os.path.join(DATA_DIR, fname)) as f:
result.append(Output(**json.load(f)))
return result
def _save(output: Output):
with open(os.path.join(DATA_DIR, f"{output.id}.json"), "w") as f:
json.dump(output.model_dump(), f, indent=2)
def _load(output_id: str) -> Output:
path = os.path.join(DATA_DIR, f"{output_id}.json")
if not os.path.exists(path):
raise HTTPException(status_code=404, detail="Output not found")
with open(path) as f:
return Output(**json.load(f))
def load_output(output_id: str) -> Output | None:
"""Public helper for other modules to resolve an output by ID."""
path = os.path.join(DATA_DIR, f"{output_id}.json")
if not os.path.exists(path):
return None
with open(path) as f:
return Output(**json.load(f))
# Build/install/cache directories that the polling endpoint must never
# descend into. Without this skip-list the workspace endpoint reads
# `node_modules/` (300 MB of MUI source, when it's a real dir and not a
# symlink), `.venv/` (10k+ Python files from the hardlinked cache),
# `__pycache__/`, `dist/`, `.git/`, etc; every 2 seconds while the
# agent is active. Result: backend CPU pegged on JSON-serializing
# auto-generated chunks the frontend will then throw away. The frontend
# already filters these for display; this skip is the real fix.
_WALK_SKIP_DIRS = frozenset({
"node_modules",
".vite",
".vite-cache",
".vite_cache",
".git",
"dist",
".next",
"__pycache__",
".venv",
"venv",
".pytest_cache",
".mypy_cache",
".ruff_cache",
})
# Cap per-file response size at 256 KB. Hand-written source rarely
# exceeds this; auto-generated bundles routinely run into the MBs and
# they're not what the user/agent is editing. Anything over the cap
# returns a truncated stub the frontend treats as "open the file
# directly to see full contents."
_WALK_MAX_FILE_BYTES = 256 * 1024
def _walk_directory(folder: str) -> dict[str, str]:
"""Walk a directory tree and return {relative_path: content} for all
text files the user is actually authoring. Skips build/install
directories AND truncates oversize files; both critical for the
polling endpoint, which is called every 2 s while the agent is
writing code and would otherwise serialize hundreds of MB per poll."""
files: dict[str, str] = {}
if not os.path.isdir(folder):
return files
for root, dirs, filenames in os.walk(folder):
# Mutate `dirs` in place; that's how os.walk skips a subtree.
# Doing it here means we never even stat the children, so a
# 10k-file `.venv/` costs ~one stat (on the dir itself) instead
# of 10k.
dirs[:] = [d for d in dirs if d not in _WALK_SKIP_DIRS]
for fname in filenames:
full_path = os.path.join(root, fname)
# Normalize to forward-slash keys so the frontend's
# `path.split('/')` and `.startsWith(prefix)` checks work
# the same on Windows (where os.sep is '\\') as on macOS.
# Without this, every workspace file came back as
# `backend\\app.py` on Windows and the file tree silently
# mis-parsed.
rel_path = os.path.relpath(full_path, folder).replace(os.sep, "/")
try:
# Stat first; cheap, lets us skip giant files without
# opening + reading them.
size = os.path.getsize(full_path)
if size > _WALK_MAX_FILE_BYTES:
files[rel_path] = (
f"// [openswarm] file truncated ({size} bytes > "
f"{_WALK_MAX_FILE_BYTES} byte cap). Open directly "
f"to view full contents."
)
continue
with open(full_path) as f:
files[rel_path] = f.read()
except Exception:
pass
return files
# ---------------------------------------------------------------------------
# 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, _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 = os.path.normpath(os.path.join(folder, filepath))
if not full_path.startswith(os.path.normpath(folder)):
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(_d) if _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)
# 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, _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(_d) if _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)
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 = _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.
return {"files": files, "meta": meta, "path": os.path.abspath(folder)}
def sync_output_from_meta_json(workspace_id: str) -> bool:
"""Read meta.json from the workspace folder; if it has a non-empty
name or description that differs from the linked Output row, update
the row. Returns True if anything changed.
Idempotent and best-effort: missing workspace, missing meta.json,
malformed JSON, or no linked Output all return False silently.
Why this exists: the Apps editor's React component polls meta.json
every few seconds and propagates name/description into the Output
via autosave. The canvas-chat App Builder launch has no such
poller, so apps stayed named "Untitled App" forever even after
the agent wrote a real name into meta.json. Calling this from the
session-complete hook closes that gap on the one event we know
fires exactly once per session.
"""
try:
folder = os.path.join(WORKSPACE_DIR, workspace_id)
meta_path = os.path.join(folder, "meta.json")
if not os.path.exists(meta_path):
return False
with open(meta_path) as f:
meta = json.load(f)
if not isinstance(meta, dict):
return False
name = str(meta.get("name") or "").strip()
description = str(meta.get("description") or "").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
# Only overwrite the default placeholder ("Untitled App" / "") so a
# user who explicitly renamed the app in the UI isn't clobbered by
# a stale meta.json from a prior agent turn.
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 (OSError, json.JSONDecodeError, ValueError):
return False
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") 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("/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") 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") 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; unchanged.
if body.files:
for rel_path, content in body.files.items():
full_path = os.path.normpath(os.path.join(folder, rel_path))
if not full_path.startswith(os.path.normpath(folder)):
continue
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
else:
for rel_path, content in VIEW_TEMPLATE_FILES.items():
full_path = os.path.join(folder, rel_path)
with open(full_path, "w") as f:
f.write(content)
# Seed the workspace's SKILL.md with the LIVE skill content so an
# agent that Reads SKILL.md sees the same text the Skills page shows.
# Snapshot at workspace creation; subsequent edits don't rewrite
# already-seeded workspaces (the system-prompt injection in
# agent_manager reads live, so the agent always has the latest
# rules regardless of this on-disk copy).
with open(os.path.join(folder, "SKILL.md"), "w") as f:
f.write(load_app_builder_skill())
if body.meta:
with open(os.path.join(folder, "meta.json"), "w") 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) -> 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)
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):
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)
@outputs.router.post("/workspace/{workspace_id}/runtime/stop")
async def runtime_stop(workspace_id: str):
from backend.apps.outputs.runtime import manager as runtime_manager
await runtime_manager.detach(workspace_id)
return _runtime_status_payload(workspace_id)
@outputs.router.post("/workspace/{workspace_id}/runtime/restart")
async def runtime_restart(workspace_id: str):
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)
if rt:
await runtime_manager.restart(workspace_id, os.path.abspath(folder))
return _runtime_status_payload(workspace_id)
@outputs.router.get("/workspace/{workspace_id}/runtime/status")
async def runtime_get_status(workspace_id: str):
return _runtime_status_payload(workspace_id)
@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")
folder_norm = os.path.normpath(folder)
full_path = os.path.normpath(os.path.join(folder, filepath))
# `startswith(folder_norm + os.sep)` (not just folder_norm) so a workspace
# `abc-123` can't be tricked into writing into a sibling `abc-1234-evil` ,
# prefix-string collision rather than path-component containment. Today's
# UUID-format ids make the collision unlikely in practice, but the check
# is one character and immunizes future id schemes.
if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep):
raise HTTPException(status_code=403, detail="Path traversal not allowed")
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(body.get("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")
folder_norm = os.path.normpath(folder)
full_path = os.path.normpath(os.path.join(folder, filepath))
if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep):
raise HTTPException(status_code=403, detail="Path traversal not allowed")
if os.path.isfile(full_path):
os.remove(full_path)
parent = os.path.dirname(full_path)
while parent != os.path.normpath(folder):
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)
pass
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)
for k, v in body.model_dump(exclude_none=True).items():
setattr(output, k, v)
output.updated_at = datetime.now().isoformat()
_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)
return {"ok": True}
VIBE_CODE_SYSTEM_PROMPT = """\
You are an expert at building self-contained HTML/JS/CSS applications that run in an iframe.
The user will describe what they want, and you will generate:
1. **frontend_code**: A complete HTML document. React 18 is available via esm.sh CDN.
- Use: <script type="importmap">{"imports":{"react":"https://esm.sh/react@18","react-dom/client":"https://esm.sh/react-dom@18/client"}}</script>
- Input data is at window.OUTPUT_INPUT (object), backend result at window.OUTPUT_BACKEND_RESULT.
2. **input_schema**: A JSON Schema object defining the structured input.
3. **backend_code** (optional): Python code where input_data is a global dict and result is a global dict to assign to.
4. **name**: A short name for the view.
5. **description**: A one-sentence description.
6. **message**: A brief explanation of what you did/changed.
Return ONLY valid JSON with these keys. No markdown fences, no extra text.\
"""
@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, _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.agent_manager 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)
pass
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:
# We've either already vetted (no warnings above) or the
# user explicitly opted in with force=True. Pass
# skip_validation=True so we don't pay for a redundant
# AST walk inside execute_backend_code.
exec_result = await execute_backend_code(
output.backend_code, body.input_data, skip_validation=True
)
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()