From 56317eca032b9c87ea38413cd7d5d7912ac526a4 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Mon, 16 Mar 2026 18:45:41 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20improve=20gh-release=20simp?= =?UTF-8?q?lify=20start=20justfile=20gh-release=20task=20to=20create=20or?= =?UTF-8?q?=20update=20GitHub=20releasesbased=20on=20latest=20tag=20reacha?= =?UTF-8?q?ble=20from=20origin/main.=20tags=20first,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit determine the latest tag on origin/main, find a previous tag for notes, and either mark an existing release as latest or create new(with generated notes). Add safer bash options and handle the casewhere no tags exist. This makes releases deterministic (based onorigin/main) and robust when rerunning CI or creating the firstrelease. Also modify tests for the CLI start to avoid spawning asubprocess. Replace subprocess.Popen mocks with Server.launchcalls and a small control in the test or patch fileence to exercise the built-in fallback. The tests now assert thatthe Server.launch is and for the built fallbackoutput, simplifying test isolation removing reliance on process. --- justfile | 58 +++++++-- .../admin/static/js/workbench-form.js | 4 +- .../templates/components/field_renderer.html | 34 +++++- src/supervaizer/admin/workbench_routes.py | 47 ++++++-- src/supervaizer/cli.py | 113 +++++++++++++++--- tests/test_cli.py | 36 +++--- 6 files changed, 227 insertions(+), 65 deletions(-) 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: