diff --git a/justfile b/justfile index 490d63a..e6905d2 100644 --- a/justfile +++ b/justfile @@ -147,18 +147,50 @@ release: just gh-release @echo "✅ Release complete! Main branch and tags pushed to remote" -# Create GitHub release for the current version +# Create or update GitHub release for the latest tag on origin/main gh-release: #!/usr/bin/env bash - VERSION=$(grep '^VERSION = ' src/supervaizer/__version__.py | cut -d'"' -f2) - TAG="v${VERSION}" - echo "Creating GitHub release ${TAG}..." - PREV_TAG=$(git tag --sort=-creatordate | grep -v "^${TAG}$" | head -1) - NOTES=$(git log "${PREV_TAG}..${TAG}" --oneline --no-merges | grep -v "Bump version") - gh release create "${TAG}" \ - --repo supervaize/supervaizer \ - --title "${TAG}" \ - --latest \ - --generate-notes \ - --notes-start-tag "${PREV_TAG}" - echo "✅ GitHub release ${TAG} created" + set -euo pipefail + + echo "Fetching latest tags from origin..." + git fetch origin --tags + + # Find the latest tag reachable from origin/main + LATEST_TAG=$(git describe --tags --abbrev=0 origin/main) + + if [ -z "${LATEST_TAG}" ]; then + echo "❌ No tags found on origin/main. Aborting." + exit 1 + fi + + echo "Using latest tag on origin/main: ${LATEST_TAG}" + + # Find previous tag (for release notes range) + PREV_TAG=$(git tag --merged origin/main --sort=-creatordate | grep -v "^${LATEST_TAG}$" | head -1 || true) + + # If a release already exists, just mark it as latest; otherwise create it + if gh release view "${LATEST_TAG}" --repo supervaize/supervaizer >/dev/null 2>&1; then + echo "GitHub release ${LATEST_TAG} already exists. Marking as latest..." + gh release edit "${LATEST_TAG}" \ + --repo supervaize/supervaizer \ + --latest + echo "✅ GitHub release ${LATEST_TAG} updated as latest" + else + echo "Creating GitHub release ${LATEST_TAG}..." + if [ -n "${PREV_TAG}" ]; then + gh release create "${LATEST_TAG}" \ + --repo supervaize/supervaizer \ + --title "${LATEST_TAG}" \ + --latest \ + --generate-notes \ + --notes-start-tag "${PREV_TAG}" + else + # First release: no previous tag + gh release create "${LATEST_TAG}" \ + --repo supervaize/supervaizer \ + --title "${LATEST_TAG}" \ + --latest \ + --generate-notes + fi + echo "✅ GitHub release ${LATEST_TAG} created" + fi diff --git a/src/supervaizer/admin/static/js/workbench-form.js b/src/supervaizer/admin/static/js/workbench-form.js index ad1a9d6..80fb58c 100644 --- a/src/supervaizer/admin/static/js/workbench-form.js +++ b/src/supervaizer/admin/static/js/workbench-form.js @@ -61,7 +61,9 @@ class WorkbenchForm { const elements = document.querySelectorAll(selector); elements.forEach(el => { const value = el.type === 'checkbox' ? el.checked : (el.value || '').toString().trim(); - if (el.type === 'checkbox' ? !value : value === '') { + // Skip validation for env-set fields — backend will fill them + const envSet = el.dataset.envSet === 'true'; + if (el.type === 'checkbox' ? !value : (value === '' && !envSet)) { el.classList.add('border-red-500'); valid = false; } else { diff --git a/src/supervaizer/admin/templates/components/field_renderer.html b/src/supervaizer/admin/templates/components/field_renderer.html index 8bcdfe5..e2bca5f 100644 --- a/src/supervaizer/admin/templates/components/field_renderer.html +++ b/src/supervaizer/admin/templates/components/field_renderer.html @@ -128,18 +128,44 @@ {% macro render_parameter_field(param, prefix="param_") %}
- +
+ + {% if param.from_env %} + + + .env + + {% endif %} +
{% if param.description %}

{{ param.description }}

{% endif %} + {% if param.from_env and param.is_secret %} + {# Secret from env: don't expose value in HTML, show masked placeholder #} + + {% elif param.from_env %} + {# Non-secret from env: show the value, green border #} + + {% else %} + {% endif %}
{% endmacro %} diff --git a/src/supervaizer/admin/workbench_routes.py b/src/supervaizer/admin/workbench_routes.py index 26731be..69794a6 100644 --- a/src/supervaizer/admin/workbench_routes.py +++ b/src/supervaizer/admin/workbench_routes.py @@ -71,16 +71,19 @@ def get_job_cases(job: Job) -> List[Any]: return list(Cases().get_job_cases(job.id).values()) -def get_agent_parameters_from_env(agent: Agent) -> Dict[str, str]: - """Pre-fill parameter values from environment variables.""" - values = {} +def get_agent_parameters_from_env(agent: Agent) -> Dict[str, Dict[str, str]]: + """Pre-fill parameter values from environment variables. + + Returns dict of {name: {"value": str, "from_env": bool}}. + """ + values: Dict[str, Dict[str, str]] = {} if agent.parameters_setup: for name, param in agent.parameters_setup.definitions.items(): env_val = os.environ.get(name, "") if env_val: - values[name] = env_val + values[name] = {"value": env_val, "from_env": True} elif param.value: - values[name] = param.value + values[name] = {"value": param.value, "from_env": False} return values @@ -138,12 +141,14 @@ async def workbench_page(request: Request, slug: str) -> Response: if agent.parameters_setup: env_values = get_agent_parameters_from_env(agent) for name, param in agent.parameters_setup.definitions.items(): + env_info = env_values.get(name, {}) parameters.append({ "name": param.name, "description": param.description, "is_required": param.is_required, "is_secret": param.is_secret, - "value": env_values.get(name, ""), + "value": env_info.get("value", ""), + "from_env": env_info.get("from_env", False), }) job_fields = [] @@ -200,10 +205,16 @@ async def workbench_start_job(request: Request, slug: str) -> Response: parameters = body.get("parameters", {}) fields = body.get("fields", {}) - # Set parameters via os.environ (matching agent convention) + # Set parameters via os.environ (matching agent convention). + # Fall back to env values for empty fields (local mode pre-fill). if agent.parameters_setup: - for name, value in parameters.items(): - if name in agent.parameters_setup.definitions: + env_values = get_agent_parameters_from_env(agent) + for name in agent.parameters_setup.definitions: + value = parameters.get(name, "") + if not value: + env_info = env_values.get(name, {}) + value = env_info.get("value", "") + if value: agent.parameters_setup.definitions[name].set_value(value) # Create job context and job (Job.__init__ auto-registers in Jobs() singleton) @@ -219,9 +230,23 @@ async def workbench_start_job(request: Request, slug: str) -> Response: ) # Build agent_parameters as list of dicts (matching AbstractJob.agent_parameters type) + # Merge submitted values with env fallbacks + merged_params = {} + if agent.parameters_setup: + for name in agent.parameters_setup.definitions: + value = parameters.get(name, "") + if not value: + env_info = env_values.get(name, {}) + value = env_info.get("value", "") + if value: + merged_params[name] = value + # Also include any extra params not in definitions + for k, v in parameters.items(): + if v and k not in merged_params: + merged_params[k] = v agent_params_list = ( - [{"name": k, "value": v} for k, v in parameters.items()] - if parameters + [{"name": k, "value": v} for k, v in merged_params.items()] + if merged_params else None ) diff --git a/src/supervaizer/cli.py b/src/supervaizer/cli.py index 6493396..e62d9ff 100644 --- a/src/supervaizer/cli.py +++ b/src/supervaizer/cli.py @@ -7,10 +7,7 @@ import asyncio import os import shutil -import signal -import subprocess import sys -from typing import Any from pathlib import Path from typing import Optional from supervaizer.deploy.cli import deploy_app @@ -150,6 +147,22 @@ def start( ), ) -> None: """Start the Supervaizer Controller server.""" + # In local mode, load .env file so agent parameters are available + if local: + env_file = os.path.join(os.getcwd(), ".env") + if os.path.isfile(env_file): + with open(env_file) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + # Don't override already-set env vars + if key and key not in os.environ: + os.environ[key] = value + # Set environment variables for the server configuration os.environ["SUPERVAIZER_HOST"] = host os.environ["SUPERVAIZER_PORT"] = str(port) @@ -163,12 +176,16 @@ def start( if local: os.environ["SUPERVAIZER_LOCAL_MODE"] = "true" + # In local mode, force public_url to localhost unless explicitly provided + if public_url is None: + public_url = f"http://{host}:{port}" + os.environ["SUPERVAIZER_PUBLIC_URL"] = public_url console.print( f"[bold green]Starting Supervaizer Controller v{VERSION}[/] (local test mode)" ) console.print("[dim]No Studio registration — agents run locally[/]") api_key_display = os.environ.get("SUPERVAIZER_API_KEY") or "local-dev" - base = public_url or f"http://{host}:{port}" + base = public_url console.print( f"[bold]API:[/] {base}/docs [bold]Admin/Workbench:[/] {base}/admin/" ) @@ -179,12 +196,11 @@ def start( os.environ.get("SUPERVAIZER_SCRIPT_PATH") or "supervaizer_control.py" ) + use_fallback_server = False if not os.path.exists(script_path): if local: - # Use fallback script (Hello World only) - import supervaizer.examples.local_server as fallback_module - - script_path = fallback_module.__file__ + # Use fallback: launch a Server with Hello World agent directly + use_fallback_server = True else: console.print(f"[bold red]Error:[/] {script_path} not found") console.print( @@ -194,22 +210,81 @@ def start( if not local: console.print(f"[bold green]Starting Supervaizer Controller v{VERSION}[/]") + + if use_fallback_server: + # No control script found — create a minimal Server with Hello World agent + console.print( + "[dim]No control script found — using built-in Hello World agent[/]" + ) + from supervaizer.server import Server as _Server + + server_instance = _Server( + agents=[], + supervisor_account=None, + a2a_endpoints=True, + admin_interface=True, + host=host, + port=port, + public_url=os.environ.get("SUPERVAIZER_PUBLIC_URL"), + debug=debug, + reload=reload, + environment=environment, + api_key=None, + ) + server_instance.launch(log_level=log_level) + return + console.print(f"Loading configuration from [bold]{script_path}[/]") - # Execute the script in a new Python process with proper signal handling + # Import the control script as a module and auto-launch the Server if needed. + # This allows scripts that only *define* a Server (without an `if __name__` + # guard calling launch()) to still work via `supervaizer start`. + import importlib.util + + spec = importlib.util.spec_from_file_location("supervaizer_control", script_path) + if spec is None or spec.loader is None: + console.print(f"[bold red]Error:[/] Could not load {script_path} as a module") + sys.exit(1) + + module = importlib.util.module_from_spec(spec) + + # Add the script's directory to sys.path so relative imports work + script_dir = os.path.dirname(os.path.abspath(script_path)) + if script_dir not in sys.path: + sys.path.insert(0, script_dir) + + spec.loader.exec_module(module) + + # Look for a Server instance that hasn't been launched yet. + # If the script already called launch() (via __main__ guard), uvicorn would + # be running and we'd never reach this point. So if we're here, we need to + # find the Server and call launch(). + from supervaizer.server import Server as _Server - def signal_handler(signum: int, frame: Any) -> None: - # Send the signal to the subprocess - if "process" in globals(): - globals()["process"].terminate() - sys.exit(0) + server_instance = None + for attr_name in dir(module): + obj = getattr(module, attr_name, None) + if isinstance(obj, _Server): + server_instance = obj + break - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) + if server_instance is None: + console.print( + "[bold red]Error:[/] No Server instance found in the control script. " + "Define a variable of type supervaizer.Server in your script." + ) + sys.exit(1) + + # Override Server attributes with CLI values. + # Python default arguments (os.getenv in Server.__init__) are evaluated at + # class definition time, so if the module was already imported before the CLI + # set env vars, the Server instance has stale defaults. + server_instance.host = host + server_instance.port = port + if public_url is not None: + server_instance.public_url = public_url - process = subprocess.Popen([sys.executable, script_path]) - globals()["process"] = process - process.wait() + server_instance.launch(log_level=log_level) def _create_instructions_file( diff --git a/tests/test_cli.py b/tests/test_cli.py index 4007c17..d221e2f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -65,28 +65,30 @@ def test_start_with_default_script_missing(self, runner: CliRunner) -> None: def test_start_local_sets_env_and_runs_script( self, runner: CliRunner, temp_script: str ) -> None: - """--local sets SUPERVAIZER_LOCAL_MODE and runs the script normally.""" - with patch("subprocess.Popen") as mock_popen: - mock_process = Mock() - mock_process.wait.return_value = 0 - mock_popen.return_value = mock_process + """--local sets SUPERVAIZER_LOCAL_MODE and imports the script, finds Server, calls launch().""" + with patch("supervaizer.server.Server.launch") as mock_launch: + # Write a real control script that creates a Server instance + with open(temp_script, "w") as f: + f.write( + "from supervaizer.server import Server\n" + "from supervaizer.examples.local_server import get_default_local_agent\n" + "sv_server = Server(agents=[get_default_local_agent()])\n" + ) result = runner.invoke(app, ["start", "--local", temp_script]) assert "local test mode" in result.stdout - mock_popen.assert_called_once() assert os.environ.get("SUPERVAIZER_LOCAL_MODE") == "true" + mock_launch.assert_called_once() def test_start_local_without_script_uses_fallback(self, runner: CliRunner) -> None: - """--local without script_path and no supervaizer_control.py uses fallback.""" - with patch("subprocess.Popen") as mock_popen: - mock_process = Mock() - mock_process.wait.return_value = 0 - mock_popen.return_value = mock_process - with patch("supervaizer.cli.os.path.exists", return_value=False): - result = runner.invoke(app, ["start", "--local"]) - assert "local test mode" in result.stdout - mock_popen.assert_called_once() - call_args = mock_popen.call_args[0][0] - assert "local_server.py" in call_args[1] + """--local without script_path and no supervaizer_control.py uses built-in fallback.""" + with ( + patch("supervaizer.server.Server.launch") as mock_launch, + patch("supervaizer.cli.os.path.exists", return_value=False), + ): + result = runner.invoke(app, ["start", "--local"]) + assert "local test mode" in result.stdout + assert "built-in Hello World agent" in result.stdout + mock_launch.assert_called_once() class TestCLIInstall: