Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion src/supervaizer/admin/static/js/workbench-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 30 additions & 4 deletions src/supervaizer/admin/templates/components/field_renderer.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,44 @@

{% macro render_parameter_field(param, prefix="param_") %}
<div class="mb-3">
<label for="{{ prefix }}{{ param.name }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ param.name }}
{% if param.is_required %}<span class="text-red-500">*</span>{% endif %}
</label>
<div class="flex items-center gap-2 mb-1">
<label for="{{ prefix }}{{ param.name }}" class="block text-sm font-medium text-gray-700">
{{ param.name }}
{% if param.is_required %}<span class="text-red-500">*</span>{% endif %}
</label>
{% if param.from_env %}
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-200" title="Value loaded from environment variable">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
.env
</span>
{% endif %}
</div>
{% if param.description %}
<p class="text-xs text-gray-500 mb-1">{{ param.description }}</p>
{% endif %}
{% if param.from_env and param.is_secret %}
{# Secret from env: don't expose value in HTML, show masked placeholder #}
<input type="password"
id="{{ prefix }}{{ param.name }}" name="{{ param.name }}"
placeholder="•••••••• (from .env)"
data-env-set="true"
class="w-full rounded-md border-emerald-300 bg-emerald-50/30 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
{% if param.is_required %}required{% endif %}>
{% elif param.from_env %}
{# Non-secret from env: show the value, green border #}
<input type="text"
id="{{ prefix }}{{ param.name }}" name="{{ param.name }}"
value="{{ param.value or '' }}"
data-env-set="true"
class="w-full rounded-md border-emerald-300 bg-emerald-50/30 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
{% if param.is_required %}required{% endif %}>
{% else %}
<input type="{{ 'password' if param.is_secret else 'text' }}"
id="{{ prefix }}{{ param.name }}" name="{{ param.name }}"
value="{{ param.value or '' }}"
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
{% if param.is_required %}required{% endif %}>
{% endif %}
</div>
{% endmacro %}

Expand Down
47 changes: 36 additions & 11 deletions src/supervaizer/admin/workbench_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand All @@ -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
)

Expand Down
113 changes: 94 additions & 19 deletions src/supervaizer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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/"
)
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Loading
Loading