mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 18:27:45 +02:00
[eric] app-builder: cross-platform workspace runtime so apps build and run on windows (resolve bash/python/npm, venv Scripts layout, bundled-node PATH, online npm retry)
This commit is contained in:
@@ -3,11 +3,28 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from collections import deque, OrderedDict
|
from collections import deque, OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_bash() -> str:
|
||||||
|
# Windows: Python's subprocess uses Windows-style PATH resolution and doesn't follow Git Bash's Unix-style entries like /mingw64/bin/..., so a bare "bash" call hits [WinError 2]. shutil.which goes through Windows PATHEXT lookup; fall back to the conventional Git for Windows install path so users without bash in their Windows PATH still work. POSIX: just return "bash" since the kernel finds it via PATH like any other exec.
|
||||||
|
found = shutil.which("bash")
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
if sys.platform == "win32":
|
||||||
|
for candidate in (
|
||||||
|
r"C:\Program Files\Git\bin\bash.exe",
|
||||||
|
r"C:\Program Files\Git\usr\bin\bash.exe",
|
||||||
|
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
||||||
|
):
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
return "bash"
|
||||||
|
|
||||||
from .runtime_proc import (
|
from .runtime_proc import (
|
||||||
_ERROR_PATTERNS,
|
_ERROR_PATTERNS,
|
||||||
_FRONTEND_BIND_POLL_INTERVAL,
|
_FRONTEND_BIND_POLL_INTERVAL,
|
||||||
@@ -225,7 +242,7 @@ class AppRuntime:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.process = await asyncio.create_subprocess_exec(
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
"bash", "run.sh",
|
_resolve_bash(), "run.sh",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=self.workspace_path,
|
cwd=self.workspace_path,
|
||||||
@@ -364,7 +381,15 @@ class AppRuntime:
|
|||||||
"""Inherited env minus the install token. Backend.py can hit our
|
"""Inherited env minus the install token. Backend.py can hit our
|
||||||
REST API back via its own creds if it really needs to, but it
|
REST API back via its own creds if it really needs to, but it
|
||||||
shouldn't inherit the host process's token by default."""
|
shouldn't inherit the host process's token by default."""
|
||||||
return {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
|
env = {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
|
||||||
|
# Hand the workspace's backend/run.sh the exact interpreter we're
|
||||||
|
# running on. In the packaged build that's the bundled standalone
|
||||||
|
# Python, so a fresh machine with no system `python3` still works;
|
||||||
|
# in dev it's whatever launched uvicorn. OPENSWARM_NODE_PATH already
|
||||||
|
# rides in via os.environ (set by the Electron shell) for run.sh's
|
||||||
|
# Node resolution.
|
||||||
|
env["OPENSWARM_PYTHON"] = sys.executable
|
||||||
|
return env
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
|||||||
@@ -6,11 +6,50 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_npm() -> list[str] | None:
|
||||||
|
"""Resolve an invokable npm command. Windows ships npm as npm.cmd (a
|
||||||
|
batch shim), which Python's subprocess won't find via a bare "npm";
|
||||||
|
and the packaged Electron build bundles only node.exe (no npm) but
|
||||||
|
exports OPENSWARM_NODE_PATH, so we also probe node's own bundled
|
||||||
|
npm-cli.js. Returns an argv prefix, or None when npm is genuinely
|
||||||
|
absent (caller treats warm-cache as a skippable optimization)."""
|
||||||
|
node_path = os.environ.get("OPENSWARM_NODE_PATH")
|
||||||
|
if node_path and os.path.exists(node_path):
|
||||||
|
node_dir = os.path.dirname(node_path)
|
||||||
|
for shim in ("npm.cmd", "npm"):
|
||||||
|
cand = os.path.join(node_dir, shim)
|
||||||
|
if os.path.exists(cand):
|
||||||
|
return [cand]
|
||||||
|
# node.exe with no sibling npm: invoke npm-cli.js directly via node.
|
||||||
|
for rel in (
|
||||||
|
os.path.join("node_modules", "npm", "bin", "npm-cli.js"),
|
||||||
|
os.path.join(node_dir, "node_modules", "npm", "bin", "npm-cli.js"),
|
||||||
|
):
|
||||||
|
cli = rel if os.path.isabs(rel) else os.path.join(node_dir, rel)
|
||||||
|
if os.path.exists(cli):
|
||||||
|
return [node_path, cli]
|
||||||
|
for name in ("npm.cmd", "npm") if sys.platform == "win32" else ("npm",):
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return [found]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_python() -> str:
|
||||||
|
"""The interpreter to build warm/workspace venvs with. sys.executable
|
||||||
|
is the running backend's python (bundled standalone in the packaged
|
||||||
|
build, system python in dev) and is always valid, sidestepping the
|
||||||
|
Windows `python3` Microsoft-Store alias shim that shutil.which finds
|
||||||
|
first and which exits non-zero with 'Python was not found'."""
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
# Absolute path to the bundled skill source. Surfaced as a constant so the
|
# Absolute path to the bundled skill source. Surfaced as a constant so the
|
||||||
# skills subsystem can register it as a built-in skill (copy into
|
# skills subsystem can register it as a built-in skill (copy into
|
||||||
# ~/.claude/skills/ on first boot) without re-deriving the path.
|
# ~/.claude/skills/ on first boot) without re-deriving the path.
|
||||||
@@ -265,17 +304,33 @@ def _ensure_warm_cache() -> str | None:
|
|||||||
tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json")
|
tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json")
|
||||||
shutil.copyfile(tmpl_pkg, os.path.join(cache_dir, "package.json"))
|
shutil.copyfile(tmpl_pkg, os.path.join(cache_dir, "package.json"))
|
||||||
base_flags = ["--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"]
|
base_flags = ["--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"]
|
||||||
|
npm = _resolve_npm()
|
||||||
|
if npm is None:
|
||||||
|
logger.info("webapp-template: no npm available; skipping warm cache (workspace will install on first run)")
|
||||||
|
return None
|
||||||
if os.path.exists(tmpl_lock):
|
if os.path.exists(tmpl_lock):
|
||||||
shutil.copyfile(tmpl_lock, os.path.join(cache_dir, "package-lock.json"))
|
shutil.copyfile(tmpl_lock, os.path.join(cache_dir, "package-lock.json"))
|
||||||
cmd = ["npm", "ci", *base_flags]
|
cmd = [*npm, "ci", *base_flags]
|
||||||
else:
|
else:
|
||||||
# No lockfile yet; `npm install` resolves the tree and
|
# No lockfile yet; `npm install` resolves the tree and
|
||||||
# writes one into the cache dir for future use.
|
# writes one into the cache dir for future use.
|
||||||
cmd = ["npm", "install", *base_flags]
|
cmd = [*npm, "install", *base_flags]
|
||||||
logger.info("webapp-template: warming node_modules cache at %s", cache_dir)
|
logger.info("webapp-template: warming node_modules cache at %s", cache_dir)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd, cwd=cache_dir, capture_output=True, text=True, timeout=600
|
cmd, cwd=cache_dir, capture_output=True, text=True, timeout=600
|
||||||
)
|
)
|
||||||
|
# --prefer-offline reuses npm's metadata cache, which can be
|
||||||
|
# stale: if a pinned transitive (e.g. a @babel/* helper) was
|
||||||
|
# published after the cache snapshot, resolution fails ETARGET
|
||||||
|
# even though the registry has it. Retry once online (drops
|
||||||
|
# --prefer-offline) so a partially-stale cache self-heals
|
||||||
|
# instead of dead-ending the whole App Builder frontend.
|
||||||
|
if result.returncode != 0 and "ETARGET" in (result.stderr or ""):
|
||||||
|
online_cmd = [c for c in cmd if c != "--prefer-offline"]
|
||||||
|
logger.info("webapp-template: warm-cache offline pass hit ETARGET; retrying online")
|
||||||
|
result = subprocess.run(
|
||||||
|
online_cmd, cwd=cache_dir, capture_output=True, text=True, timeout=600
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"webapp-template warm-cache install failed (rc=%s): %s",
|
"webapp-template warm-cache install failed (rc=%s): %s",
|
||||||
@@ -378,18 +433,7 @@ def _ensure_warm_python_venv() -> str | None:
|
|||||||
# `python.exe`. On macOS/Linux the versioned candidates
|
# `python.exe`. On macOS/Linux the versioned candidates
|
||||||
# match first so we don't accidentally pick a system
|
# match first so we don't accidentally pick a system
|
||||||
# Python 2.x via the bare name.
|
# Python 2.x via the bare name.
|
||||||
py = None
|
py = _resolve_python()
|
||||||
candidates = (
|
|
||||||
"python3.13", "python3.12", "python3.11", "python3.10",
|
|
||||||
"python3", "python",
|
|
||||||
)
|
|
||||||
for candidate in candidates:
|
|
||||||
if shutil.which(candidate):
|
|
||||||
py = candidate
|
|
||||||
break
|
|
||||||
if py is None:
|
|
||||||
logger.warning("webapp-template warm-venv: no python on PATH")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Wipe any half-populated venv from a previous crashed run.
|
# Wipe any half-populated venv from a previous crashed run.
|
||||||
if os.path.isdir(venv_dir):
|
if os.path.isdir(venv_dir):
|
||||||
|
|||||||
@@ -21,24 +21,56 @@ fi
|
|||||||
|
|
||||||
BACKEND_DIR_ABSPATH="$(dirname "$RUN_BACKEND_ABSPATH")"
|
BACKEND_DIR_ABSPATH="$(dirname "$RUN_BACKEND_ABSPATH")"
|
||||||
|
|
||||||
|
# Windows (Git Bash / MSYS) reports OSTYPE=msys|cygwin|win32; venv layout
|
||||||
|
# is Scripts\ + python.exe, and the bare interpreter is `python` not
|
||||||
|
# `python3`. Branch once here so every later path is correct.
|
||||||
|
IS_WIN=0
|
||||||
|
case "$OSTYPE" in
|
||||||
|
msys*|cygwin*|win32*) IS_WIN=1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# --- Find a working Python 3 ---
|
# --- Find a working Python 3 ---
|
||||||
|
# Prefer an explicit path the host passed us (OPENSWARM_PYTHON, set by the
|
||||||
|
# packaged Electron shell to the bundled standalone Python so a fresh
|
||||||
|
# Windows machine with no system Python still works). Fall back to PATH
|
||||||
|
# probing for dev. `python` is first on Windows since python3.x aliases
|
||||||
|
# usually don't exist there.
|
||||||
PYTHON=""
|
PYTHON=""
|
||||||
for candidate in python3.13 python3.12 python3.11 python3.10 python3; do
|
if [[ -n "${OPENSWARM_PYTHON:-}" ]] && "${OPENSWARM_PYTHON}" -c "import sys; sys.exit(0 if sys.version_info[0]==3 else 1)" &>/dev/null; then
|
||||||
if command -v "$candidate" &>/dev/null && "$candidate" -c "print('ok')" &>/dev/null; then
|
PYTHON="${OPENSWARM_PYTHON}"
|
||||||
PYTHON="$candidate"
|
else
|
||||||
break
|
if [[ "$IS_WIN" == "1" ]]; then
|
||||||
|
CANDIDATES="python python3 python3.13 python3.12 python3.11 python3.10"
|
||||||
|
else
|
||||||
|
CANDIDATES="python3.13 python3.12 python3.11 python3.10 python3 python"
|
||||||
fi
|
fi
|
||||||
done
|
for candidate in $CANDIDATES; do
|
||||||
|
if command -v "$candidate" &>/dev/null && "$candidate" -c "import sys; sys.exit(0 if sys.version_info[0]==3 else 1)" &>/dev/null; then
|
||||||
|
PYTHON="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
if [[ -z "$PYTHON" ]]; then
|
if [[ -z "$PYTHON" ]]; then
|
||||||
echo "Error: No working Python 3 found."
|
echo "Error: No working Python 3 found."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Using Python: $PYTHON ($($PYTHON --version 2>&1))"
|
echo "Using Python: $PYTHON ($("$PYTHON" --version 2>&1))"
|
||||||
|
|
||||||
# --- Create virtual environment if it doesn't exist ---
|
# --- Create virtual environment if it doesn't exist ---
|
||||||
VENV_DIR="$BACKEND_DIR_ABSPATH/.venv"
|
VENV_DIR="$BACKEND_DIR_ABSPATH/.venv"
|
||||||
SENTINEL="$VENV_DIR/.openswarm_installed"
|
SENTINEL="$VENV_DIR/.openswarm_installed"
|
||||||
|
|
||||||
|
# Resolve the venv interpreter by OS layout instead of `source activate`,
|
||||||
|
# whose path (bin/ vs Scripts/) and shell semantics differ across
|
||||||
|
# platforms. Calling the venv python directly is portable and avoids the
|
||||||
|
# activate-script fork entirely.
|
||||||
|
if [[ "$IS_WIN" == "1" ]]; then
|
||||||
|
VENV_PY="$VENV_DIR/Scripts/python.exe"
|
||||||
|
else
|
||||||
|
VENV_PY="$VENV_DIR/bin/python"
|
||||||
|
fi
|
||||||
|
|
||||||
# Fast path on every restart: if .venv exists AND we've already
|
# Fast path on every restart: if .venv exists AND we've already
|
||||||
# installed the workspace's deps once, skip the entire venv-create +
|
# installed the workspace's deps once, skip the entire venv-create +
|
||||||
# pip-install dance (saves ~25s per workspace cold-restart). The
|
# pip-install dance (saves ~25s per workspace cold-restart). The
|
||||||
@@ -47,7 +79,6 @@ SENTINEL="$VENV_DIR/.openswarm_installed"
|
|||||||
# and retries.
|
# and retries.
|
||||||
if [[ -d "$VENV_DIR" && -f "$SENTINEL" ]]; then
|
if [[ -d "$VENV_DIR" && -f "$SENTINEL" ]]; then
|
||||||
echo "Dependencies already installed — skipping venv create + pip install."
|
echo "Dependencies already installed — skipping venv create + pip install."
|
||||||
source "$VENV_DIR/bin/activate"
|
|
||||||
else
|
else
|
||||||
if [[ ! -d "$VENV_DIR" ]]; then
|
if [[ ! -d "$VENV_DIR" ]]; then
|
||||||
echo "Creating virtual environment..."
|
echo "Creating virtual environment..."
|
||||||
@@ -57,16 +88,15 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
source "$VENV_DIR/bin/activate"
|
|
||||||
|
|
||||||
# --- Install Python dependencies ---
|
# --- Install Python dependencies ---
|
||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
cd "$BACKEND_DIR_ABSPATH"
|
cd "$BACKEND_DIR_ABSPATH"
|
||||||
if [[ -n "${OPENSWARM_DEBUGGER_PATH:-}" && -d "$OPENSWARM_DEBUGGER_PATH" ]]; then
|
if [[ -n "${OPENSWARM_DEBUGGER_PATH:-}" && -d "$OPENSWARM_DEBUGGER_PATH" ]]; then
|
||||||
echo "Installing OpenSwarm debugger (swarm_debug) from $OPENSWARM_DEBUGGER_PATH"
|
echo "Installing OpenSwarm debugger (swarm_debug) from $OPENSWARM_DEBUGGER_PATH"
|
||||||
pip install -e "$OPENSWARM_DEBUGGER_PATH"
|
"$VENV_PY" -m pip install -e "$OPENSWARM_DEBUGGER_PATH"
|
||||||
fi
|
fi
|
||||||
pip install -e .
|
"$VENV_PY" -m pip install -e .
|
||||||
if [[ $? -ne 0 ]]; then
|
if [[ $? -ne 0 ]]; then
|
||||||
echo "Error: Failed to install Python dependencies."
|
echo "Error: Failed to install Python dependencies."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -84,4 +114,4 @@ fi
|
|||||||
# clean SIGTERM and restarts via this same script.
|
# clean SIGTERM and restarts via this same script.
|
||||||
echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT:-8324} ..."
|
echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT:-8324} ..."
|
||||||
cd "$BACKEND_DIR_ABSPATH/.."
|
cd "$BACKEND_DIR_ABSPATH/.."
|
||||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}"
|
"$VENV_PY" -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}"
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ FRONTEND_DIR_ABSPATH="$(dirname "$RUN_FRONTEND_ABSPATH")"
|
|||||||
|
|
||||||
cd "$FRONTEND_DIR_ABSPATH"
|
cd "$FRONTEND_DIR_ABSPATH"
|
||||||
|
|
||||||
|
# Put the bundled Node on PATH so `npm`, `node`, and the vite child
|
||||||
|
# processes all resolve even on a machine with no system Node. The
|
||||||
|
# packaged Electron shell exports OPENSWARM_NODE_PATH (e.g.
|
||||||
|
# .../node/x64/node.exe on Windows, .../node/<arch>/bin/node on POSIX);
|
||||||
|
# its directory holds node + the npm/npx shims. Dev leaves it unset and
|
||||||
|
# falls back to system Node on PATH.
|
||||||
|
NPM="npm"
|
||||||
|
if [[ -n "${OPENSWARM_NODE_PATH:-}" && -x "${OPENSWARM_NODE_PATH}" ]]; then
|
||||||
|
NODE_DIR="$(dirname "$OPENSWARM_NODE_PATH")"
|
||||||
|
export PATH="$NODE_DIR:$PATH"
|
||||||
|
# Windows bundles npm.cmd next to node.exe; POSIX bundles an `npm` shim
|
||||||
|
# in the same bin/ dir. Prefer the colocated one, else trust PATH.
|
||||||
|
if [[ -f "$NODE_DIR/npm.cmd" ]]; then
|
||||||
|
NPM="$NODE_DIR/npm.cmd"
|
||||||
|
elif [[ -x "$NODE_DIR/npm" ]]; then
|
||||||
|
NPM="$NODE_DIR/npm"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Fast path: the seeder usually symlinks node_modules to a shared warm
|
# Fast path: the seeder usually symlinks node_modules to a shared warm
|
||||||
# cache (~/.openswarm/cache/webapp_template_node_modules/<hash>), so the
|
# cache (~/.openswarm/cache/webapp_template_node_modules/<hash>), so the
|
||||||
# dependency install has already been done once and we can skip straight
|
# dependency install has already been done once and we can skip straight
|
||||||
@@ -28,11 +47,23 @@ if [ -d node_modules ] && [ -n "$(ls -A node_modules 2>/dev/null)" ]; then
|
|||||||
echo "Dependencies already present — skipping install."
|
echo "Dependencies already present — skipping install."
|
||||||
else
|
else
|
||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
npm install --prefer-offline --no-audit --no-fund
|
"$NPM" install --prefer-offline --no-audit --no-fund
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building with development mode..."
|
echo "Building with development mode..."
|
||||||
npm run dev
|
# Prefer `npm run dev` (honors package.json script + flags). But the
|
||||||
|
# packaged build ships node.exe WITHOUT npm, so on a machine with no
|
||||||
|
# system npm we fall back to invoking vite directly through the bundled
|
||||||
|
# node — node_modules is already populated (warm-cache symlink or seed),
|
||||||
|
# so vite's bin is present and this needs no package manager at all.
|
||||||
|
if command -v "$NPM" &>/dev/null || [[ "$NPM" != "npm" ]]; then
|
||||||
|
"$NPM" run dev
|
||||||
|
elif [[ -n "${OPENSWARM_NODE_PATH:-}" && -x "${OPENSWARM_NODE_PATH}" && -f node_modules/vite/bin/vite.js ]]; then
|
||||||
|
echo "npm not found; running vite directly via bundled node."
|
||||||
|
"$OPENSWARM_NODE_PATH" node_modules/vite/bin/vite.js
|
||||||
|
else
|
||||||
|
"$NPM" run dev
|
||||||
|
fi
|
||||||
|
|
||||||
# exit back to the dir that we were in before
|
# exit back to the dir that we were in before
|
||||||
cd -
|
cd -
|
||||||
|
|||||||
Reference in New Issue
Block a user