From 188fada51453188f0e56e2d4382143455096b310 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:29:19 +0100 Subject: [PATCH 1/8] feat: implement first-run setup wizard (#452) Add a multi-step setup wizard that guides new users through initial configuration: admin account creation, LLM provider setup, company creation (with template support), and first agent creation. Backend: - SetupController with 5 endpoints: status (unauthenticated), templates, company, agent, complete - setup_complete boolean setting in the api namespace - Setup event constants for structured logging - Auth exclude path for /setup/status Frontend: - 5-step wizard: Welcome, Admin, Provider, Company, Agent + Complete - Setup Pinia store with status caching and step management - Router guard integration (redirect to /setup when incomplete) - Reuses existing provider form patterns and auth composables CLI: - synthorg setup command: resets setup_complete flag and opens browser Closes #452 Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/cmd/setup.go | 123 ++++++ src/synthorg/api/app.py | 1 + src/synthorg/api/controllers/__init__.py | 3 + src/synthorg/api/controllers/setup.py | 490 +++++++++++++++++++++ src/synthorg/api/openapi.py | 1 + src/synthorg/observability/events/setup.py | 26 ++ src/synthorg/settings/definitions/api.py | 13 + tests/unit/api/controllers/test_setup.py | 323 ++++++++++++++ tests/unit/observability/test_events.py | 1 + web/src/__tests__/views/SetupPage.test.ts | 136 +++--- web/src/api/endpoints/setup.ts | 35 ++ web/src/api/types.ts | 31 ++ web/src/components/setup/SetupAdmin.vue | 117 +++++ web/src/components/setup/SetupAgent.vue | 237 ++++++++++ web/src/components/setup/SetupCompany.vue | 124 ++++++ web/src/components/setup/SetupComplete.vue | 47 ++ web/src/components/setup/SetupProvider.vue | 289 ++++++++++++ web/src/components/setup/SetupWelcome.vue | 32 ++ web/src/router/guards.ts | 47 +- web/src/router/index.ts | 1 - web/src/stores/setup.ts | 83 ++++ web/src/views/SetupPage.vue | 253 +++++++---- 22 files changed, 2247 insertions(+), 166 deletions(-) create mode 100644 cli/cmd/setup.go create mode 100644 src/synthorg/api/controllers/setup.py create mode 100644 src/synthorg/observability/events/setup.py create mode 100644 tests/unit/api/controllers/test_setup.py create mode 100644 web/src/api/endpoints/setup.ts create mode 100644 web/src/components/setup/SetupAdmin.vue create mode 100644 web/src/components/setup/SetupAgent.vue create mode 100644 web/src/components/setup/SetupCompany.vue create mode 100644 web/src/components/setup/SetupComplete.vue create mode 100644 web/src/components/setup/SetupProvider.vue create mode 100644 web/src/components/setup/SetupWelcome.vue create mode 100644 web/src/stores/setup.ts diff --git a/cli/cmd/setup.go b/cli/cmd/setup.go new file mode 100644 index 0000000000..c6b49053e3 --- /dev/null +++ b/cli/cmd/setup.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/Aureliolo/synthorg/cli/internal/config" + "github.com/Aureliolo/synthorg/cli/internal/docker" + "github.com/Aureliolo/synthorg/cli/internal/ui" + "github.com/spf13/cobra" +) + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Re-open the first-run setup wizard", + Long: `Reset the setup_complete flag and open the setup wizard in the browser. + +This is useful when you want to re-configure providers, company settings, +or add agents through the guided setup flow. Requires the SynthOrg stack +to be running ('synthorg start').`, + RunE: runSetup, +} + +func init() { + rootCmd.AddCommand(setupCmd) +} + +func runSetup(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + dir := resolveDataDir() + + state, err := config.Load(dir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + safeDir, err := safeStateDir(state) + if err != nil { + return err + } + composePath := filepath.Join(safeDir, "compose.yml") + if _, err := os.Stat(composePath); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("not initialized -- run 'synthorg init' first") + } + + out := ui.NewUI(cmd.OutOrStdout()) + + // Verify Docker is available and containers are running. + info, err := docker.Detect(ctx) + if err != nil { + return fmt.Errorf("docker not available: %w", err) + } + + psOut, err := docker.ComposeExecOutput(ctx, info, safeDir, "ps", "--format", "json") + if err != nil || psOut == "" { + return fmt.Errorf("no containers running -- run 'synthorg start' first") + } + + // Reset the setup_complete flag via the settings API. + out.Step("Resetting setup flag...") + if err := resetSetupFlag(ctx, state); err != nil { + out.Warn(fmt.Sprintf("Could not reset setup flag: %v", err)) + out.Hint("You can manually delete the api.setup_complete setting.") + } else { + out.Success("Setup flag reset") + } + + // Open browser to the setup page. + setupURL := fmt.Sprintf("http://localhost:%d/setup", state.WebPort) + out.Step(fmt.Sprintf("Opening %s", setupURL)) + if err := openBrowser(ctx, setupURL); err != nil { + out.Warn(fmt.Sprintf("Could not open browser: %v", err)) + out.Hint(fmt.Sprintf("Open %s manually in your browser.", setupURL)) + } + + return nil +} + +// resetSetupFlag calls DELETE /api/v1/settings/api/setup_complete to reset +// the first-run flag so the setup wizard re-appears. +func resetSetupFlag(ctx context.Context, state config.State) error { + url := fmt.Sprintf("http://localhost:%d/api/v1/settings/api/setup_complete", state.BackendPort) + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + return fmt.Errorf("API returned status %d", resp.StatusCode) + } + return nil +} + +// openBrowser opens a URL in the default browser. +func openBrowser(ctx context.Context, url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + cmd = exec.CommandContext(ctx, "open", url) + default: + cmd = exec.CommandContext(ctx, "xdg-open", url) + } + return cmd.Start() +} diff --git a/src/synthorg/api/app.py b/src/synthorg/api/app.py index 44229c1aa9..1ab58b67f1 100644 --- a/src/synthorg/api/app.py +++ b/src/synthorg/api/app.py @@ -653,6 +653,7 @@ def _build_middleware(api_config: ApiConfig) -> list[Middleware]: "^/api$", f"^{prefix}/auth/setup$", f"^{prefix}/auth/login$", + f"^{prefix}/setup/status$", ) ) # Always ensure the WS upgrade path is excluded — the WS handler diff --git a/src/synthorg/api/controllers/__init__.py b/src/synthorg/api/controllers/__init__.py index d2812a520e..6b446d805d 100644 --- a/src/synthorg/api/controllers/__init__.py +++ b/src/synthorg/api/controllers/__init__.py @@ -20,6 +20,7 @@ from synthorg.api.controllers.projects import ProjectController from synthorg.api.controllers.providers import ProviderController from synthorg.api.controllers.settings import SettingsController +from synthorg.api.controllers.setup import SetupController from synthorg.api.controllers.tasks import TaskController from synthorg.api.controllers.ws import ws_handler @@ -42,6 +43,7 @@ CollaborationController, CoordinationController, SettingsController, + SetupController, BackupController, ) @@ -66,6 +68,7 @@ "ProjectController", "ProviderController", "SettingsController", + "SetupController", "TaskController", "ws_handler", ] diff --git a/src/synthorg/api/controllers/setup.py b/src/synthorg/api/controllers/setup.py new file mode 100644 index 0000000000..f0233b2664 --- /dev/null +++ b/src/synthorg/api/controllers/setup.py @@ -0,0 +1,490 @@ +"""First-run setup controller -- status, templates, company, agent, complete.""" + +import json +from typing import Any, Self + +from litestar import Controller, get, post +from litestar.datastructures import State # noqa: TC002 +from litestar.status_codes import HTTP_201_CREATED +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from synthorg.api.dto import ApiResponse +from synthorg.api.errors import ApiValidationError, NotFoundError +from synthorg.api.guards import require_read_access, require_write_access +from synthorg.api.state import AppState # noqa: TC001 +from synthorg.core.enums import SeniorityLevel +from synthorg.core.types import NotBlankStr # noqa: TC001 +from synthorg.observability import get_logger +from synthorg.observability.events.setup import ( + SETUP_AGENT_CREATED, + SETUP_COMPANY_CREATED, + SETUP_COMPLETED, + SETUP_STATUS_CHECKED, + SETUP_TEMPLATES_LISTED, +) + +logger = get_logger(__name__) + + +# ── Request / Response DTOs ────────────────────────────────── + + +class SetupStatusResponse(BaseModel): + """First-run setup status. + + Attributes: + needs_admin: True if no admin user exists yet. + needs_setup: True if setup has not been completed. + has_providers: True if at least one provider is configured. + """ + + model_config = ConfigDict(frozen=True) + + needs_admin: bool + needs_setup: bool + has_providers: bool + + +class TemplateInfoResponse(BaseModel): + """Summary of an available company template. + + Attributes: + name: Template identifier. + display_name: Human-readable name. + description: Short description. + source: Where the template was found. + """ + + model_config = ConfigDict(frozen=True) + + name: NotBlankStr + display_name: NotBlankStr + description: str + source: str + + +class SetupCompanyRequest(BaseModel): + """Company creation payload for first-run setup. + + Attributes: + company_name: Company display name. + template_name: Optional template to apply (None = blank company). + """ + + model_config = ConfigDict(frozen=True) + + company_name: NotBlankStr = Field(max_length=200) + template_name: str | None = Field(default=None, max_length=100) + + +class SetupCompanyResponse(BaseModel): + """Company creation result. + + Attributes: + company_name: The company name that was set. + template_applied: Name of the template that was applied, if any. + department_count: Number of departments created. + """ + + model_config = ConfigDict(frozen=True) + + company_name: str + template_applied: str | None + department_count: int + + +class SetupAgentRequest(BaseModel): + """Agent creation payload for first-run setup. + + Attributes: + name: Agent display name. + role: Agent role name. + level: Seniority level. + personality_preset: Personality preset name. + model_provider: Provider name for the agent's model. + model_id: Model identifier from that provider. + department: Department to assign the agent to. + budget_limit_monthly: Optional monthly budget limit in USD. + """ + + model_config = ConfigDict(frozen=True) + + name: NotBlankStr = Field(max_length=200) + role: NotBlankStr = Field(max_length=100) + level: SeniorityLevel = Field(default=SeniorityLevel.MID) + personality_preset: str = Field(default="pragmatic_builder", max_length=100) + model_provider: NotBlankStr = Field(max_length=100) + model_id: NotBlankStr = Field(max_length=200) + department: NotBlankStr = Field(default="engineering", max_length=100) + budget_limit_monthly: float | None = Field(default=None, ge=0.0) + + @model_validator(mode="after") + def _validate_preset_exists(self) -> Self: + """Validate the personality preset name at parse time.""" + from synthorg.templates.presets import PERSONALITY_PRESETS # noqa: PLC0415 + + key = self.personality_preset.strip().lower() + if key not in PERSONALITY_PRESETS: + available = sorted(PERSONALITY_PRESETS) + msg = ( + f"Unknown personality preset {self.personality_preset!r}. " + f"Available: {available}" + ) + raise ValueError(msg) + return self + + +class SetupAgentResponse(BaseModel): + """Agent creation result. + + Attributes: + name: Agent display name. + role: Agent role. + department: Assigned department. + model_provider: LLM provider name. + model_id: Model identifier. + """ + + model_config = ConfigDict(frozen=True) + + name: str + role: str + department: str + model_provider: str + model_id: str + + +# ── Controller ─────────────────────────────────────────────── + + +class SetupController(Controller): + """First-run setup wizard endpoints.""" + + path = "/setup" + tags = ("setup",) + + @get("/status") + async def get_status( + self, + state: State, + ) -> ApiResponse[SetupStatusResponse]: + """Check whether first-run setup is needed. + + This endpoint is unauthenticated so the frontend can determine + whether to show the setup wizard before any user exists. + + Args: + state: Application state. + + Returns: + Setup status envelope. + """ + app_state: AppState = state.app_state + persistence = app_state.persistence + + user_count = await persistence.users.count() + needs_admin = user_count == 0 + + settings_svc = app_state.settings_service + try: + entry = await settings_svc.get_entry("api", "setup_complete") + needs_setup = entry.value != "true" + except Exception: + # If settings service is unavailable, assume setup needed. + needs_setup = True + + has_providers = ( + app_state.has_provider_registry and len(app_state.provider_registry) > 0 + ) + + logger.debug( + SETUP_STATUS_CHECKED, + needs_admin=needs_admin, + needs_setup=needs_setup, + has_providers=has_providers, + ) + + return ApiResponse( + data=SetupStatusResponse( + needs_admin=needs_admin, + needs_setup=needs_setup, + has_providers=has_providers, + ), + ) + + @get( + "/templates", + guards=[require_read_access], + ) + async def get_templates( + self, + state: State, # noqa: ARG002 + ) -> ApiResponse[tuple[TemplateInfoResponse, ...]]: + """List available company templates for setup. + + Args: + state: Application state. + + Returns: + Template list envelope. + """ + from synthorg.templates.loader import list_templates # noqa: PLC0415 + + templates = list_templates() + result = tuple( + TemplateInfoResponse( + name=t.name, + display_name=t.display_name, + description=t.description, + source=t.source, + ) + for t in templates + ) + + logger.debug(SETUP_TEMPLATES_LISTED, count=len(result)) + return ApiResponse(data=result) + + @post( + "/company", + status_code=HTTP_201_CREATED, + guards=[require_write_access], + ) + async def create_company( + self, + data: SetupCompanyRequest, + state: State, + ) -> ApiResponse[SetupCompanyResponse]: + """Create company configuration during first-run setup. + + Persists the company name and optionally applies a template + to create department structure. + + Args: + data: Company creation payload. + state: Application state. + + Returns: + Company creation result envelope. + """ + app_state: AppState = state.app_state + settings_svc = app_state.settings_service + + # Set company name. + await settings_svc.set("company", "company_name", data.company_name) + + department_count = 0 + template_applied: str | None = None + + if data.template_name is not None: + template_applied = data.template_name + departments_json = _extract_template_departments(data.template_name) + if departments_json: + await settings_svc.set( + "company", + "departments", + departments_json, + ) + department_count = len(json.loads(departments_json)) + + logger.info( + SETUP_COMPANY_CREATED, + company_name=data.company_name, + template=template_applied, + department_count=department_count, + ) + + return ApiResponse( + data=SetupCompanyResponse( + company_name=data.company_name, + template_applied=template_applied, + department_count=department_count, + ), + ) + + @post( + "/agent", + status_code=HTTP_201_CREATED, + guards=[require_write_access], + ) + async def create_agent( + self, + data: SetupAgentRequest, + state: State, + ) -> ApiResponse[SetupAgentResponse]: + """Create the first agent during first-run setup. + + Builds an agent configuration and persists it to the + company settings. + + Args: + data: Agent creation payload. + state: Application state. + + Returns: + Agent creation result envelope. + """ + app_state: AppState = state.app_state + settings_svc = app_state.settings_service + + # Validate provider exists via management service. + provider_mgmt = app_state.provider_management + providers = await provider_mgmt.list_providers() + if data.model_provider not in providers: + msg = f"Provider {data.model_provider!r} not found" + raise NotFoundError(msg) + + # Validate model exists in the provider. + provider_config = providers[data.model_provider] + model_ids = {m.id for m in provider_config.models} + if data.model_id not in model_ids: + msg = ( + f"Model {data.model_id!r} not found in provider {data.model_provider!r}" + ) + raise ApiValidationError(msg) + + # Build agent config dict (matches config.schema.AgentConfig). + from synthorg.templates.presets import ( # noqa: PLC0415 + get_personality_preset, + ) + + personality_dict = get_personality_preset(data.personality_preset) + agent_config: dict[str, Any] = { + "name": data.name, + "role": data.role, + "department": data.department, + "level": data.level.value, + "personality": personality_dict, + "model": { + "provider": data.model_provider, + "model_id": data.model_id, + }, + } + + # Append to existing agents list in settings. + existing_agents = await _get_existing_agents(settings_svc) + existing_agents.append(agent_config) + await settings_svc.set( + "company", + "agents", + json.dumps(existing_agents), + ) + + logger.info( + SETUP_AGENT_CREATED, + agent_name=data.name, + role=data.role, + provider=data.model_provider, + model=data.model_id, + ) + + return ApiResponse( + data=SetupAgentResponse( + name=data.name, + role=data.role, + department=data.department, + model_provider=data.model_provider, + model_id=data.model_id, + ), + ) + + @post( + "/complete", + guards=[require_write_access], + ) + async def complete_setup( + self, + state: State, + ) -> ApiResponse[dict[str, bool]]: + """Mark first-run setup as complete. + + Validates that at least one provider is configured before + allowing completion. + + Args: + state: Application state. + + Returns: + Success envelope. + """ + app_state: AppState = state.app_state + + if not app_state.has_provider_registry or len(app_state.provider_registry) == 0: + msg = "At least one provider must be configured before completing setup" + raise ApiValidationError(msg) + + settings_svc = app_state.settings_service + await settings_svc.set("api", "setup_complete", "true") + + logger.info(SETUP_COMPLETED) + + return ApiResponse(data={"setup_complete": True}) + + +# ── Helpers ────────────────────────────────────────────────── + + +def _extract_template_departments(template_name: str) -> str: + """Load a template and extract its departments as a JSON string. + + Args: + template_name: Template name to load. + + Returns: + JSON array of department dicts, or empty string if template + has no departments. + + Raises: + NotFoundError: If the template does not exist. + ApiValidationError: If the template cannot be parsed. + """ + from synthorg.templates.errors import ( # noqa: PLC0415 + TemplateNotFoundError, + TemplateRenderError, + TemplateValidationError, + ) + from synthorg.templates.loader import load_template # noqa: PLC0415 + + try: + loaded = load_template(template_name) + except TemplateNotFoundError as exc: + msg = f"Template {template_name!r} not found" + raise NotFoundError(msg) from exc + except (TemplateRenderError, TemplateValidationError) as exc: + msg = f"Template {template_name!r} is invalid: {exc}" + raise ApiValidationError(msg) from exc + + departments = loaded.template.departments + if not departments: + return "" + + # Convert TemplateDepartmentConfig objects to JSON-serializable dicts. + dept_list: list[dict[str, Any]] = [] + for d in departments: + entry: dict[str, Any] = {"name": getattr(d, "name", "")} + budget = getattr(d, "budget_percent", 0) + if budget is not None: + entry["budget_percent"] = budget + dept_list.append(entry) + return json.dumps(dept_list) if dept_list else "" + + +async def _get_existing_agents( + settings_svc: Any, +) -> list[dict[str, Any]]: + """Read the current agents list from settings. + + Args: + settings_svc: Settings service instance. + + Returns: + Mutable list of agent config dicts (empty if none set). + """ + try: + entry = await settings_svc.get_entry("company", "agents") + if entry.value is not None: + parsed = json.loads(entry.value) + if isinstance(parsed, list): + return parsed + except Exception: + logger.debug("setup.agents.read_fallback", reason="no_existing_agents") + return [] diff --git a/src/synthorg/api/openapi.py b/src/synthorg/api/openapi.py index 7496aa1f96..75a59741bf 100644 --- a/src/synthorg/api/openapi.py +++ b/src/synthorg/api/openapi.py @@ -51,6 +51,7 @@ "/health", "/auth/setup", "/auth/login", + "/setup/status", ) # HTTP methods that mutate state. Includes DELETE for 400/403 injection; diff --git a/src/synthorg/observability/events/setup.py b/src/synthorg/observability/events/setup.py new file mode 100644 index 0000000000..d4299f0d6d --- /dev/null +++ b/src/synthorg/observability/events/setup.py @@ -0,0 +1,26 @@ +"""Setup event constants for structured logging. + +Constants follow the ``setup..`` naming convention +and are passed as the first argument to ``logger.info()``/``logger.debug()`` +calls in the first-run setup flow. +""" + +from typing import Final + +# Status check +SETUP_STATUS_CHECKED: Final[str] = "setup.status.checked" + +# Company creation during setup +SETUP_COMPANY_CREATED: Final[str] = "setup.company.created" + +# Agent creation during setup +SETUP_AGENT_CREATED: Final[str] = "setup.agent.created" + +# Setup completion +SETUP_COMPLETED: Final[str] = "setup.flow.completed" + +# Setup reset (via CLI or settings delete) +SETUP_RESET: Final[str] = "setup.flow.reset" + +# Template listing +SETUP_TEMPLATES_LISTED: Final[str] = "setup.templates.listed" diff --git a/src/synthorg/settings/definitions/api.py b/src/synthorg/settings/definitions/api.py index 3d223ad435..676cd1a6eb 100644 --- a/src/synthorg/settings/definitions/api.py +++ b/src/synthorg/settings/definitions/api.py @@ -157,3 +157,16 @@ yaml_path="api.auth.exclude_paths", ) ) + +# ── Setup ────────────────────────────────────────────────────── + +_r.register( + SettingDefinition( + namespace=SettingNamespace.API, + key="setup_complete", + type=SettingType.BOOLEAN, + default="false", + description="Whether first-run setup has been completed", + group="Setup", + ) +) diff --git a/tests/unit/api/controllers/test_setup.py b/tests/unit/api/controllers/test_setup.py new file mode 100644 index 0000000000..419c0059f9 --- /dev/null +++ b/tests/unit/api/controllers/test_setup.py @@ -0,0 +1,323 @@ +"""Tests for the first-run setup controller.""" + +from typing import Any + +import pytest +from litestar.testing import TestClient +from pydantic import ValidationError + +from tests.unit.api.conftest import make_auth_headers + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupStatus: + """GET /api/v1/setup/status -- unauthenticated status check.""" + + def test_returns_status_with_seeded_users( + self, + test_client: TestClient[Any], + ) -> None: + """With pre-seeded users, needs_admin is False.""" + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + data = body["data"] + assert data["needs_admin"] is False + assert data["needs_setup"] is True + assert data["has_providers"] is False + + def test_status_without_auth_header( + self, + test_client: TestClient[Any], + ) -> None: + """Status endpoint works without authentication.""" + saved_headers = dict(test_client.headers) + test_client.headers.pop("Authorization", None) + try: + resp = test_client.get("/api/v1/setup/status") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + finally: + test_client.headers.update(saved_headers) + + def test_status_response_fields( + self, + test_client: TestClient[Any], + ) -> None: + """Status response contains all required fields.""" + resp = test_client.get("/api/v1/setup/status") + data = resp.json()["data"] + assert "needs_admin" in data + assert "needs_setup" in data + assert "has_providers" in data + assert isinstance(data["needs_admin"], bool) + assert isinstance(data["needs_setup"], bool) + assert isinstance(data["has_providers"], bool) + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupTemplates: + """GET /api/v1/setup/templates -- list available templates.""" + + def test_returns_builtin_templates( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.get("/api/v1/setup/templates") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + templates = body["data"] + assert len(templates) >= 7 + names = {t["name"] for t in templates} + assert "solo_founder" in names + assert "startup" in names + + def test_template_fields( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.get("/api/v1/setup/templates") + body = resp.json() + for template in body["data"]: + assert "name" in template + assert "display_name" in template + assert "description" in template + assert "source" in template + + def test_requires_auth( + self, + test_client: TestClient[Any], + ) -> None: + test_client.headers.update(make_auth_headers("observer")) + resp = test_client.get("/api/v1/setup/templates") + assert resp.status_code == 200 + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupCompany: + """POST /api/v1/setup/company -- create company config.""" + + def test_blank_company( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={"company_name": "Test Corp"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + data = body["data"] + assert data["company_name"] == "Test Corp" + assert data["template_applied"] is None + assert data["department_count"] == 0 + + def test_company_with_template( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={ + "company_name": "My Startup", + "template_name": "solo_founder", + }, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["success"] is True + data = body["data"] + assert data["company_name"] == "My Startup" + assert data["template_applied"] == "solo_founder" + assert data["department_count"] >= 1 + + def test_invalid_template( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={ + "company_name": "Test Corp", + "template_name": "nonexistent_template", + }, + ) + assert resp.status_code == 404 + + def test_blank_company_name_rejected( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/company", + json={"company_name": " "}, + ) + # Pydantic NotBlankStr validation returns 400 + assert resp.status_code == 400 + + def test_requires_write_access( + self, + test_client: TestClient[Any], + ) -> None: + test_client.headers.update(make_auth_headers("observer")) + resp = test_client.post( + "/api/v1/setup/company", + json={"company_name": "Test Corp"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupAgent: + """POST /api/v1/setup/agent -- create first agent.""" + + def test_nonexistent_provider( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/agent", + json={ + "name": "Alice Chen", + "role": "CEO", + "model_provider": "nonexistent", + "model_id": "model-001", + }, + ) + assert resp.status_code == 404 + + def test_invalid_personality_preset( + self, + test_client: TestClient[Any], + ) -> None: + resp = test_client.post( + "/api/v1/setup/agent", + json={ + "name": "Alice Chen", + "role": "CEO", + "personality_preset": "nonexistent_preset", + "model_provider": "test", + "model_id": "model-001", + }, + ) + # Pydantic model_validator returns 400 + assert resp.status_code == 400 + + def test_requires_write_access( + self, + test_client: TestClient[Any], + ) -> None: + test_client.headers.update(make_auth_headers("observer")) + resp = test_client.post( + "/api/v1/setup/agent", + json={ + "name": "Alice Chen", + "role": "CEO", + "model_provider": "test", + "model_id": "model-001", + }, + ) + assert resp.status_code == 403 + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupComplete: + """POST /api/v1/setup/complete -- mark setup as done.""" + + def test_complete_without_provider_fails( + self, + test_client: TestClient[Any], + ) -> None: + """Completing setup without providers returns 400.""" + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 422 + + def test_requires_write_access( + self, + test_client: TestClient[Any], + ) -> None: + test_client.headers.update(make_auth_headers("observer")) + resp = test_client.post("/api/v1/setup/complete") + assert resp.status_code == 403 + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestSetupDTOs: + """Unit tests for setup DTO validation.""" + + def test_setup_agent_request_valid_preset(self) -> None: + from synthorg.api.controllers.setup import SetupAgentRequest + + req = SetupAgentRequest( + name="Alice", + role="CEO", + personality_preset="visionary_leader", + model_provider="test-provider", + model_id="model-001", + ) + assert req.personality_preset == "visionary_leader" + + def test_setup_agent_request_invalid_preset(self) -> None: + from pydantic import ValidationError + + from synthorg.api.controllers.setup import SetupAgentRequest + + with pytest.raises(ValidationError, match="personality preset"): + SetupAgentRequest( + name="Alice", + role="CEO", + personality_preset="nonexistent", + model_provider="test-provider", + model_id="model-001", + ) + + def test_setup_company_request_defaults(self) -> None: + from synthorg.api.controllers.setup import SetupCompanyRequest + + req = SetupCompanyRequest(company_name="Test Corp") + assert req.template_name is None + + def test_setup_status_response_frozen(self) -> None: + from synthorg.api.controllers.setup import SetupStatusResponse + + resp = SetupStatusResponse( + needs_admin=True, + needs_setup=True, + has_providers=False, + ) + with pytest.raises(ValidationError): + resp.needs_admin = False # type: ignore[misc] + + +@pytest.mark.unit +@pytest.mark.timeout(30) +class TestExtractTemplateDepartments: + """Unit tests for the _extract_template_departments helper.""" + + def test_valid_template(self) -> None: + from synthorg.api.controllers.setup import _extract_template_departments + + result = _extract_template_departments("solo_founder") + assert result != "" + import json + + departments = json.loads(result) + assert len(departments) >= 1 + assert departments[0]["name"] in {"executive", "engineering"} + + def test_invalid_template(self) -> None: + from synthorg.api.controllers.setup import _extract_template_departments + from synthorg.api.errors import NotFoundError + + with pytest.raises(NotFoundError): + _extract_template_departments("nonexistent_template") diff --git a/tests/unit/observability/test_events.py b/tests/unit/observability/test_events.py index faa66e816c..15a75f667a 100644 --- a/tests/unit/observability/test_events.py +++ b/tests/unit/observability/test_events.py @@ -229,6 +229,7 @@ def test_all_domain_modules_discovered(self) -> None: "checkpoint", "context_budget", "settings", + "setup", } discovered = {info.name for info in pkgutil.iter_modules(events.__path__)} assert discovered == expected diff --git a/web/src/__tests__/views/SetupPage.test.ts b/web/src/__tests__/views/SetupPage.test.ts index db3ba20c16..6aa68eb35d 100644 --- a/web/src/__tests__/views/SetupPage.test.ts +++ b/web/src/__tests__/views/SetupPage.test.ts @@ -3,10 +3,8 @@ import { mount, flushPromises } from '@vue/test-utils' import { setActivePinia, createPinia } from 'pinia' import { defineComponent, h } from 'vue' -const mockRouterPush = vi.fn() - vi.mock('vue-router', () => ({ - useRouter: () => ({ push: mockRouterPush }), + useRouter: () => ({ push: vi.fn() }), RouterLink: { props: ['to'], template: '' }, })) @@ -28,7 +26,7 @@ vi.mock('primevue/inputtext', () => ({ vi.mock('primevue/button', () => ({ default: defineComponent({ - props: ['label', 'icon', 'type', 'loading', 'disabled'], + props: ['label', 'icon', 'type', 'loading', 'disabled', 'severity', 'size', 'outlined'], emits: ['click'], setup(props, { emit }) { return () => @@ -45,6 +43,25 @@ vi.mock('primevue/button', () => ({ }), })) +vi.mock('primevue/select', () => ({ + default: defineComponent({ + props: ['modelValue', 'options', 'optionLabel', 'optionValue', 'placeholder', 'disabled'], + emits: ['update:modelValue'], + setup(props) { + return () => h('select', {}, props.placeholder ?? '') + }, + }), +})) + +vi.mock('primevue/tag', () => ({ + default: defineComponent({ + props: ['value', 'severity'], + setup(props) { + return () => h('span', {}, props.value) + }, + }), +})) + vi.mock('@/router', () => ({ router: { currentRoute: { value: { path: '/setup' } }, @@ -58,8 +75,28 @@ vi.mock('vue', async () => { return { ...actual, onUnmounted: vi.fn() } }) +// Mock the setup API to return a default status +vi.mock('@/api/endpoints/setup', () => ({ + getSetupStatus: vi.fn().mockResolvedValue({ + needs_admin: true, + needs_setup: true, + has_providers: false, + }), + listTemplates: vi.fn().mockResolvedValue([]), + createCompany: vi.fn().mockResolvedValue({ company_name: 'Test', template_applied: null, department_count: 0 }), + createAgent: vi.fn().mockResolvedValue({ name: 'Agent', role: 'CEO', department: 'exec', model_provider: 'p', model_id: 'm' }), + completeSetup: vi.fn().mockResolvedValue({ setup_complete: true }), +})) + +// Mock providers API +vi.mock('@/api/endpoints/providers', () => ({ + listProviders: vi.fn().mockResolvedValue({}), + listPresets: vi.fn().mockResolvedValue([]), + createFromPreset: vi.fn().mockResolvedValue({}), + testConnection: vi.fn().mockResolvedValue({ success: true, latency_ms: 42 }), +})) + import SetupPage from '@/views/SetupPage.vue' -import { useAuthStore } from '@/stores/auth' describe('SetupPage', () => { beforeEach(() => { @@ -68,83 +105,62 @@ describe('SetupPage', () => { localStorage.clear() }) - it('renders setup heading and form fields', () => { - const wrapper = mount(SetupPage) - expect(wrapper.text()).toContain('Initial Setup') - expect(wrapper.text()).toContain('Create the first admin (CEO) account') - expect(wrapper.find('#username').exists()).toBe(true) - expect(wrapper.find('#password').exists()).toBe(true) - expect(wrapper.find('#confirm').exists()).toBe(true) - }) - - it('renders labels for all form fields', () => { + it('renders welcome step after status loads', async () => { const wrapper = mount(SetupPage) - expect(wrapper.find('label[for="username"]').exists()).toBe(true) - expect(wrapper.find('label[for="password"]').exists()).toBe(true) - expect(wrapper.find('label[for="confirm"]').exists()).toBe(true) + await flushPromises() + expect(wrapper.text()).toContain('Welcome to SynthOrg') }) - it('disables submit button when fields are empty', () => { + it('renders step indicator', async () => { const wrapper = mount(SetupPage) - const submitBtn = wrapper.find('button[type="submit"]') - expect(submitBtn.attributes('disabled')).toBeDefined() + await flushPromises() + // Should show step counter + expect(wrapper.text()).toContain('Step 1 of') }) - it('shows password mismatch error', async () => { + it('shows get started button in welcome step', async () => { const wrapper = mount(SetupPage) - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('password123456') - await wrapper.find('#confirm').setValue('differentpass12') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(wrapper.text()).toContain('Passwords do not match') + const btn = wrapper.find('button') + expect(btn.exists()).toBe(true) + expect(btn.text()).toContain('Get Started') }) - it('shows minimum password length error', async () => { + it('advances to admin step after clicking get started', async () => { const wrapper = mount(SetupPage) - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('short') - await wrapper.find('#confirm').setValue('short') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(wrapper.text()).toContain('Password must be at least') + // Click the "Get Started" button + const btn = wrapper.find('button') + await btn.trigger('click') + await flushPromises() + // Should now show admin step (since needs_admin is true) + expect(wrapper.text()).toContain('Admin') }) - it('calls auth.setup and navigates to / on success', async () => { + it('shows wizard container with step dots', async () => { const wrapper = mount(SetupPage) - const auth = useAuthStore() - auth.setup = vi.fn().mockResolvedValue({ token: 'tok', expires_in: 3600 }) - - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('securepassword1') - await wrapper.find('#confirm').setValue('securepassword1') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(auth.setup).toHaveBeenCalledWith('admin', 'securepassword1') - expect(mockRouterPush).toHaveBeenCalledWith('/') + // The step indicator should have numbered dots + const dots = wrapper.findAll('.rounded-full') + expect(dots.length).toBeGreaterThanOrEqual(1) }) - it('shows error message on setup failure', async () => { + it('includes multiple steps in the wizard', async () => { const wrapper = mount(SetupPage) - const auth = useAuthStore() - auth.setup = vi.fn().mockRejectedValue(new Error('Admin already exists')) - - await wrapper.find('#username').setValue('admin') - await wrapper.find('#password').setValue('securepassword1') - await wrapper.find('#confirm').setValue('securepassword1') - await wrapper.find('form').trigger('submit') await flushPromises() - - expect(wrapper.text()).toContain('Admin already exists') + // Should show "Step X of Y" where Y >= 4 (welcome + admin + provider + company + agent) + const text = wrapper.text() + const match = text.match(/Step \d+ of (\d+)/) + expect(match).toBeTruthy() + if (match) { + expect(parseInt(match[1])).toBeGreaterThanOrEqual(4) + } }) - it('has sign-in link pointing to /login', () => { + it('renders branding logo', async () => { const wrapper = mount(SetupPage) - const link = wrapper.find('a[href="/login"]') - expect(link.exists()).toBe(true) - expect(link.text()).toContain('Already have an account? Sign in') + await flushPromises() + // The welcome step includes the "S" logo + expect(wrapper.text()).toContain('S') }) }) diff --git a/web/src/api/endpoints/setup.ts b/web/src/api/endpoints/setup.ts new file mode 100644 index 0000000000..70eadd6d27 --- /dev/null +++ b/web/src/api/endpoints/setup.ts @@ -0,0 +1,35 @@ +import { apiClient, unwrap } from '../client' +import type { + ApiResponse, + SetupAgentRequest, + SetupCompanyRequest, + SetupStatusResponse, + TemplateInfoResponse, +} from '../types' + +// Note: getSetupStatus doesn't need auth -- the endpoint is public. +// apiClient already handles missing auth tokens gracefully (no header sent). +export async function getSetupStatus(): Promise { + const response = await apiClient.get>('/setup/status') + return unwrap(response) +} + +export async function listTemplates(): Promise { + const response = await apiClient.get>('/setup/templates') + return unwrap(response) +} + +export async function createCompany(data: SetupCompanyRequest): Promise<{ company_name: string; template_applied: string | null; department_count: number }> { + const response = await apiClient.post>('/setup/company', data) + return unwrap(response) +} + +export async function createAgent(data: SetupAgentRequest): Promise<{ name: string; role: string; department: string; model_provider: string; model_id: string }> { + const response = await apiClient.post>('/setup/agent', data) + return unwrap(response) +} + +export async function completeSetup(): Promise<{ setup_complete: boolean }> { + const response = await apiClient.post>('/setup/complete') + return unwrap(response) +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index fb029cc3a0..aad6b28675 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -818,3 +818,34 @@ export interface PaginationParams { offset?: number limit?: number } + +// ── Setup ─────────────────────────────────────────────────── + +export interface SetupStatusResponse { + needs_admin: boolean + needs_setup: boolean + has_providers: boolean +} + +export interface TemplateInfoResponse { + name: string + display_name: string + description: string + source: 'builtin' | 'user' +} + +export interface SetupCompanyRequest { + company_name: string + template_name: string | null +} + +export interface SetupAgentRequest { + name: string + role: string + level: SeniorityLevel + personality_preset: string + model_provider: string + model_id: string + department: string + budget_limit_monthly: number | null +} diff --git a/web/src/components/setup/SetupAdmin.vue b/web/src/components/setup/SetupAdmin.vue new file mode 100644 index 0000000000..de92bd90cb --- /dev/null +++ b/web/src/components/setup/SetupAdmin.vue @@ -0,0 +1,117 @@ + + + diff --git a/web/src/components/setup/SetupAgent.vue b/web/src/components/setup/SetupAgent.vue new file mode 100644 index 0000000000..8ce0bac78d --- /dev/null +++ b/web/src/components/setup/SetupAgent.vue @@ -0,0 +1,237 @@ + + + diff --git a/web/src/components/setup/SetupCompany.vue b/web/src/components/setup/SetupCompany.vue new file mode 100644 index 0000000000..2ae48c5256 --- /dev/null +++ b/web/src/components/setup/SetupCompany.vue @@ -0,0 +1,124 @@ + + + diff --git a/web/src/components/setup/SetupComplete.vue b/web/src/components/setup/SetupComplete.vue new file mode 100644 index 0000000000..8a052c4d94 --- /dev/null +++ b/web/src/components/setup/SetupComplete.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/src/components/setup/SetupProvider.vue b/web/src/components/setup/SetupProvider.vue new file mode 100644 index 0000000000..28268ca8c4 --- /dev/null +++ b/web/src/components/setup/SetupProvider.vue @@ -0,0 +1,289 @@ + + + diff --git a/web/src/components/setup/SetupWelcome.vue b/web/src/components/setup/SetupWelcome.vue new file mode 100644 index 0000000000..03c1512940 --- /dev/null +++ b/web/src/components/setup/SetupWelcome.vue @@ -0,0 +1,32 @@ + + + diff --git a/web/src/router/guards.ts b/web/src/router/guards.ts index 2a221cff65..46d708a8bf 100644 --- a/web/src/router/guards.ts +++ b/web/src/router/guards.ts @@ -1,13 +1,17 @@ import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { useSetupStore } from '@/stores/setup' /** - * Navigation guard that redirects unauthenticated users to /login. - * Uses route.meta.requiresAuth to determine access control: - * - Routes with requiresAuth: false are public (login, setup) - * - All other routes require authentication - * Redirects authenticated users away from public auth pages. - * Preserves intended destination via `redirect` query param. + * Navigation guard that handles setup flow and authentication. + * + * Priority order: + * 1. Setup check -- if setup is needed, redirect non-setup routes to /setup + * 2. Auth check -- unauthenticated users go to /login for protected routes + * 3. Password change enforcement -- mustChangePassword forces settings page + * + * The /setup route is always accessible when setup is needed, regardless of + * auth status. When setup is complete, /setup redirects to /. */ export function authGuard( to: RouteLocationNormalized, @@ -15,9 +19,36 @@ export function authGuard( next: NavigationGuardNext, ): void { const auth = useAuthStore() + const setup = useSetupStore() + + // ── Setup routing ──────────────────────────────────────── + // If status hasn't been fetched yet (null), allow navigation to proceed. + // The setup page will fetch on mount; other pages work normally until + // status is known. + + if (setup.status !== null) { + // Setup is needed -- funnel everything to /setup + if (setup.isSetupNeeded) { + if (to.name !== 'setup' && to.name !== 'login') { + next({ name: 'setup' }) + return + } + // Allow /setup and /login to proceed + next() + return + } + + // Setup is complete -- redirect /setup to / + if (to.name === 'setup') { + next('/') + return + } + } + + // ── Auth routing ───────────────────────────────────────── if (to.meta.requiresAuth === false) { - // If already authenticated, redirect away from login/setup + // If already authenticated, redirect away from login if (auth.isAuthenticated) { next('/') return @@ -32,7 +63,7 @@ export function authGuard( return } - // Enforce mustChangePassword — always normalize to settings?tab=user (password form) + // Enforce mustChangePassword -- always normalize to settings?tab=user (password form) if (auth.mustChangePassword && !(to.name === 'settings' && to.query.tab === 'user')) { next({ name: 'settings', query: { tab: 'user' } }) return diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 65b4dbea3b..ca0dbc3a43 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -14,7 +14,6 @@ const router = createRouter({ path: '/setup', name: 'setup', component: () => import('@/views/SetupPage.vue'), - meta: { requiresAuth: false }, }, { path: '/', diff --git a/web/src/stores/setup.ts b/web/src/stores/setup.ts new file mode 100644 index 0000000000..c40e883dc1 --- /dev/null +++ b/web/src/stores/setup.ts @@ -0,0 +1,83 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as setupApi from '@/api/endpoints/setup' +import { getErrorMessage } from '@/utils/errors' +import type { SetupStatusResponse, TemplateInfoResponse } from '@/api/types' + +export const useSetupStore = defineStore('setup', () => { + const status = ref(null) + const currentStep = ref(0) + const templates = ref([]) + const loading = ref(false) + const error = ref(null) + + const isSetupNeeded = computed(() => !!status.value?.needs_setup) + const isAdminNeeded = computed(() => !!status.value?.needs_admin) + + async function fetchStatus() { + loading.value = true + error.value = null + try { + status.value = await setupApi.getSetupStatus() + } catch (err) { + error.value = getErrorMessage(err) + } finally { + loading.value = false + } + } + + async function fetchTemplates() { + error.value = null + try { + templates.value = await setupApi.listTemplates() + } catch (err) { + error.value = getErrorMessage(err) + } + } + + function nextStep() { + currentStep.value++ + } + + function prevStep() { + if (currentStep.value > 0) { + currentStep.value-- + } + } + + function setStep(n: number) { + currentStep.value = n + } + + async function markComplete() { + loading.value = true + error.value = null + try { + await setupApi.completeSetup() + if (status.value) { + status.value = { ...status.value, needs_setup: false } + } + } catch (err) { + error.value = getErrorMessage(err) + throw err + } finally { + loading.value = false + } + } + + return { + status, + currentStep, + templates, + loading, + error, + isSetupNeeded, + isAdminNeeded, + fetchStatus, + fetchTemplates, + nextStep, + prevStep, + setStep, + markComplete, + } +}) diff --git a/web/src/views/SetupPage.vue b/web/src/views/SetupPage.vue index 987487b47e..bb59bf138d 100644 --- a/web/src/views/SetupPage.vue +++ b/web/src/views/SetupPage.vue @@ -1,120 +1,179 @@ From a0ece244c8477d39ef09e9309e5a4451b3c20353 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:50:15 +0100 Subject: [PATCH 2/8] fix: address 22 review findings across backend, frontend, CLI, and docs Pre-reviewed by 6 agents, 22 findings addressed: Backend (6 fixes): - Replace bare string event name with SETUP_AGENTS_READ_FALLBACK constant - Add warning log for settings service unavailability in status check - Add MemoryError/RecursionError guards on except Exception blocks - Wire budget_limit_monthly into agent config dict - Type _get_existing_agents parameter as SettingsService - Use spread operator instead of in-place list mutation Frontend (6 fixes): - Remove duplicate ProviderTestButton, use single test button with dedicated loading state - Restore requiresAuth: false on /setup route for fresh install flow - Replace @change with watch on role Select in SetupAgent - Await fetchTemplates in SetupCompany onMounted - Add named SetupCompanyResponse/SetupAgentResponse types - Add MAX_STEPS bounds check in setup store nextStep CLI (3 fixes): - Drain response body before close with io.LimitReader - Reap browser child process with goroutine Wait - Remove docker.Detect error double-wrapping Docs (7 fixes): - CLAUDE.md: add setup controller, setup/ components, setup store, setup CLI command, events.setup to logging examples - operations.md: add setup CLI command and /api/v1/setup API surface Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 10 ++++---- cli/cmd/setup.go | 14 ++++++++--- docs/design/operations.md | 3 ++- src/synthorg/api/controllers/setup.py | 28 ++++++++++++++++------ src/synthorg/observability/events/setup.py | 6 +++++ web/src/api/endpoints/setup.ts | 10 ++++---- web/src/api/types.ts | 14 +++++++++++ web/src/components/setup/SetupAgent.vue | 12 ++++------ web/src/components/setup/SetupCompany.vue | 4 ++-- web/src/components/setup/SetupProvider.vue | 8 ++++--- web/src/router/index.ts | 1 + web/src/stores/setup.ts | 6 ++++- 12 files changed, 83 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 29202d2671..d1c17096d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ curl http://localhost:3000/api/v1/health # backend (via web proxy) ```text src/synthorg/ - api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop) + api/ # Litestar REST + WebSocket API (controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, setup endpoint (first-run wizard: status check, template listing, company/agent creation, completion gate), RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation)), AppState hot-reload slots (provider_registry, model_router with swap methods, provider_management), settings dispatcher lifecycle, logging bootstrap (_bootstrap_app_logging, SYNTHORG_LOG_DIR env var override, called before all other setup in create_app), service auto-wiring (auto_wire.py: Phase 1 at construction -- message bus/cost tracker/provider registry/task engine; Phase 2 in on_startup after persistence connects -- settings service + config resolver + provider management), lifecycle helpers (lifecycle.py: _safe_startup, _safe_shutdown, _cleanup_on_failure, _init_persistence, _try_stop) auth/ # Authentication subpackage (controller, service, middleware, JWT + API key + WS ticket store, models, config, secret resolution) backup/ # Backup and restore -- scheduled/manual/lifecycle backups of persistence DB, agent memory, and company config. BackupService orchestrator, BackupScheduler (periodic asyncio task), RetentionManager (count + age pruning), tar.gz compression, SHA-256 checksums, manifest tracking, validated restore with atomic rollback and safety backup handlers/ # ComponentHandler protocol + concrete handlers: PersistenceComponentHandler (SQLite VACUUM INTO), MemoryComponentHandler (copytree), ConfigComponentHandler (copy2) @@ -140,10 +140,10 @@ src/synthorg/ web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard src/ api/ # Axios client, endpoint modules, TypeScript types (mirrors backend Pydantic models) - components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, tasks/) + components/ # Vue components organized by feature (agents/, approvals/, budget/, common/, dashboard/, layout/, messages/, org-chart/, providers/, setup/, tasks/) composables/ # Reusable composition functions (useAuth, useLoginLockout, usePolling, useOptimisticUpdate, useWebSocketSubscription) router/ # Vue Router config with auth guards - stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers) + stores/ # Pinia stores (auth, agents, tasks, budget, messages, meetings, approvals, websocket, analytics, company, providers, setup) styles/ # Global CSS and PrimeVue theme configuration utils/ # Constants, formatters, error helpers views/ # Page-level components (LoginPage, SetupPage, DashboardPage, OrgChartPage, TaskBoardPage, MessageFeedPage, ApprovalQueuePage, AgentProfilesPage, AgentDetailPage, BudgetPanelPage, MeetingLogsPage, ArtifactBrowserPage, SettingsPage) @@ -151,7 +151,7 @@ web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard cli/ # Go CLI binary (cross-platform, manages Docker lifecycle) main.go # Entry point - cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, uninstall, version, config, completion-install, backup); root flags: --data-dir, --skip-verify + cmd/ # Cobra commands (init, start, stop, status, logs, doctor, update, uninstall, version, config, completion-install, backup, setup); root flags: --data-dir, --skip-verify internal/ version/ # Build-time version vars (ldflags-injected) config/ # Data dir resolution (XDG/macOS/Windows), persisted state (JSON) @@ -199,7 +199,7 @@ site/ # Astro landing page (synthorg.io) - **Every module** with business logic MUST have: `from synthorg.observability import get_logger` then `logger = get_logger(__name__)` - **Never** use `import logging` / `logging.getLogger()` / `print()` in application code - **Variable name**: always `logger` (not `_logger`, not `log`) -- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`, `GIT_COMMAND_START` from `events.git`, `CONTEXT_BUDGET_FILL_UPDATED` from `events.context_budget`, `BACKUP_STARTED` from `events.backup`). Each domain has its own module -- see `src/synthorg/observability/events/` for the full inventory of constants. Import directly: `from synthorg.observability.events. import EVENT_CONSTANT` +- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`, `GIT_COMMAND_START` from `events.git`, `CONTEXT_BUDGET_FILL_UPDATED` from `events.context_budget`, `BACKUP_STARTED` from `events.backup`, `SETUP_COMPLETED` from `events.setup`). Each domain has its own module -- see `src/synthorg/observability/events/` for the full inventory of constants. Import directly: `from synthorg.observability.events. import EVENT_CONSTANT` - **Structured kwargs**: always `logger.info(EVENT, key=value)` — never `logger.info("msg %s", val)` - **All error paths** must log at WARNING or ERROR with context before raising - **All state transitions** must log at INFO diff --git a/cli/cmd/setup.go b/cli/cmd/setup.go index c6b49053e3..117e66a3d6 100644 --- a/cli/cmd/setup.go +++ b/cli/cmd/setup.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "os" "os/exec" @@ -55,7 +56,7 @@ func runSetup(cmd *cobra.Command, _ []string) error { // Verify Docker is available and containers are running. info, err := docker.Detect(ctx) if err != nil { - return fmt.Errorf("docker not available: %w", err) + return err } psOut, err := docker.ComposeExecOutput(ctx, info, safeDir, "ps", "--format", "json") @@ -100,7 +101,10 @@ func resetSetupFlag(ctx context.Context, state config.State) error { if err != nil { return fmt.Errorf("request failed: %w", err) } - defer func() { _ = resp.Body.Close() }() + defer func() { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024)) + _ = resp.Body.Close() + }() if resp.StatusCode >= 400 { return fmt.Errorf("API returned status %d", resp.StatusCode) @@ -119,5 +123,9 @@ func openBrowser(ctx context.Context, url string) error { default: cmd = exec.CommandContext(ctx, "xdg-open", url) } - return cmd.Start() + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() // reap child, prevent zombie + return nil } diff --git a/docs/design/operations.md b/docs/design/operations.md index e848b16e57..890d88d1c7 100644 --- a/docs/design/operations.md +++ b/docs/design/operations.md @@ -974,7 +974,7 @@ future CLI tool are thin clients that call the API -- they contain no business l from GitHub Releases with automatic re-exec, compose template refresh with diff approval, container image update with version matching), `doctor` (diagnostics + bug report URL), `uninstall`, `version`, `config`, `completion-install`, - `backup` (create/list/restore via backend API). + `backup` (create/list/restore via backend API), `setup` (re-open first-run wizard). Built with Cobra + charmbracelet/huh. Distributed via GoReleaser + install scripts (`curl | sh` for Linux/macOS, `irm | iex` for Windows). @@ -998,6 +998,7 @@ future CLI tool are thin clients that call the API -- they contain no business l | `/api/v1/analytics` | Performance metrics, dashboards | | `/api/v1/settings` | Runtime-editable configuration (9 namespaces), schema discovery | | `GET /api/v1/providers`, `POST /api/v1/providers`, `PUT /api/v1/providers/{name}`, `DELETE /api/v1/providers/{name}`, `POST /api/v1/providers/{name}/test`, `GET /api/v1/providers/presets`, `POST /api/v1/providers/from-preset` | Provider CRUD, connection testing, presets, 4 auth types (api_key, oauth, custom_header, none) | +| `/api/v1/setup` | First-run setup wizard: status check (public), template listing, company/agent creation, completion gate | | `/api/v1/admin/backups` | Manual backup, list, detail, delete | | `/api/v1/ws` | WebSocket for real-time updates (ticket auth via `?ticket=`) | | `POST /api/v1/auth/ws-ticket` | Exchange JWT for one-time WebSocket connection ticket | diff --git a/src/synthorg/api/controllers/setup.py b/src/synthorg/api/controllers/setup.py index f0233b2664..4fd4d32f44 100644 --- a/src/synthorg/api/controllers/setup.py +++ b/src/synthorg/api/controllers/setup.py @@ -1,7 +1,7 @@ """First-run setup controller -- status, templates, company, agent, complete.""" import json -from typing import Any, Self +from typing import TYPE_CHECKING, Any, Self from litestar import Controller, get, post from litestar.datastructures import State # noqa: TC002 @@ -17,12 +17,17 @@ from synthorg.observability import get_logger from synthorg.observability.events.setup import ( SETUP_AGENT_CREATED, + SETUP_AGENTS_READ_FALLBACK, SETUP_COMPANY_CREATED, SETUP_COMPLETED, SETUP_STATUS_CHECKED, + SETUP_STATUS_SETTINGS_UNAVAILABLE, SETUP_TEMPLATES_LISTED, ) +if TYPE_CHECKING: + from synthorg.settings.service import SettingsService + logger = get_logger(__name__) @@ -189,8 +194,13 @@ async def get_status( try: entry = await settings_svc.get_entry("api", "setup_complete") needs_setup = entry.value != "true" + except MemoryError, RecursionError: + raise except Exception: - # If settings service is unavailable, assume setup needed. + logger.warning( + SETUP_STATUS_SETTINGS_UNAVAILABLE, + exc_info=True, + ) needs_setup = True has_providers = ( @@ -359,14 +369,16 @@ async def create_agent( "model_id": data.model_id, }, } + if data.budget_limit_monthly is not None: + agent_config["budget_limit_monthly"] = data.budget_limit_monthly - # Append to existing agents list in settings. + # Create new list with the agent appended (immutability convention). existing_agents = await _get_existing_agents(settings_svc) - existing_agents.append(agent_config) + updated_agents = [*existing_agents, agent_config] await settings_svc.set( "company", "agents", - json.dumps(existing_agents), + json.dumps(updated_agents), ) logger.info( @@ -469,7 +481,7 @@ def _extract_template_departments(template_name: str) -> str: async def _get_existing_agents( - settings_svc: Any, + settings_svc: SettingsService, ) -> list[dict[str, Any]]: """Read the current agents list from settings. @@ -485,6 +497,8 @@ async def _get_existing_agents( parsed = json.loads(entry.value) if isinstance(parsed, list): return parsed + except MemoryError, RecursionError: + raise except Exception: - logger.debug("setup.agents.read_fallback", reason="no_existing_agents") + logger.debug(SETUP_AGENTS_READ_FALLBACK, reason="no_existing_agents") return [] diff --git a/src/synthorg/observability/events/setup.py b/src/synthorg/observability/events/setup.py index d4299f0d6d..7b7c3736b7 100644 --- a/src/synthorg/observability/events/setup.py +++ b/src/synthorg/observability/events/setup.py @@ -24,3 +24,9 @@ # Template listing SETUP_TEMPLATES_LISTED: Final[str] = "setup.templates.listed" + +# Agents list read fallback (no existing agents in settings) +SETUP_AGENTS_READ_FALLBACK: Final[str] = "setup.agents.read_fallback" + +# Status check fallback (settings service unavailable) +SETUP_STATUS_SETTINGS_UNAVAILABLE: Final[str] = "setup.status.settings_unavailable" diff --git a/web/src/api/endpoints/setup.ts b/web/src/api/endpoints/setup.ts index 70eadd6d27..aac390425f 100644 --- a/web/src/api/endpoints/setup.ts +++ b/web/src/api/endpoints/setup.ts @@ -2,7 +2,9 @@ import { apiClient, unwrap } from '../client' import type { ApiResponse, SetupAgentRequest, + SetupAgentResponse, SetupCompanyRequest, + SetupCompanyResponse, SetupStatusResponse, TemplateInfoResponse, } from '../types' @@ -19,13 +21,13 @@ export async function listTemplates(): Promise { return unwrap(response) } -export async function createCompany(data: SetupCompanyRequest): Promise<{ company_name: string; template_applied: string | null; department_count: number }> { - const response = await apiClient.post>('/setup/company', data) +export async function createCompany(data: SetupCompanyRequest): Promise { + const response = await apiClient.post>('/setup/company', data) return unwrap(response) } -export async function createAgent(data: SetupAgentRequest): Promise<{ name: string; role: string; department: string; model_provider: string; model_id: string }> { - const response = await apiClient.post>('/setup/agent', data) +export async function createAgent(data: SetupAgentRequest): Promise { + const response = await apiClient.post>('/setup/agent', data) return unwrap(response) } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index aad6b28675..faa2c10599 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -849,3 +849,17 @@ export interface SetupAgentRequest { department: string budget_limit_monthly: number | null } + +export interface SetupCompanyResponse { + company_name: string + template_applied: string | null + department_count: number +} + +export interface SetupAgentResponse { + name: string + role: string + department: string + model_provider: string + model_id: string +} diff --git a/web/src/components/setup/SetupAgent.vue b/web/src/components/setup/SetupAgent.vue index 8ce0bac78d..51947b4dbf 100644 --- a/web/src/components/setup/SetupAgent.vue +++ b/web/src/components/setup/SetupAgent.vue @@ -1,5 +1,5 @@ diff --git a/web/src/components/setup/SetupProvider.vue b/web/src/components/setup/SetupProvider.vue index 28268ca8c4..01ee8b8fc9 100644 --- a/web/src/components/setup/SetupProvider.vue +++ b/web/src/components/setup/SetupProvider.vue @@ -6,7 +6,6 @@ import Tag from 'primevue/tag' import { useProviderStore } from '@/stores/providers' import { getErrorMessage } from '@/utils/errors' import type { ProviderPreset, TestConnectionResponse } from '@/api/types' -import ProviderTestButton from '@/components/providers/ProviderTestButton.vue' const emit = defineEmits<{ next: [] @@ -20,6 +19,7 @@ const baseUrl = ref('') const apiKey = ref('') const error = ref(null) const creating = ref(false) +const testing = ref(false) const createdProviderName = ref(null) const testPassed = ref(false) const testResult = ref(null) @@ -101,6 +101,7 @@ async function handleAddProvider() { async function handleTestComplete() { if (!createdProviderName.value) return + testing.value = true try { const res = await store.testConnection(createdProviderName.value) testResult.value = res @@ -119,6 +120,8 @@ async function handleTestComplete() { } testPassed.value = false error.value = getErrorMessage(err) + } finally { + testing.value = false } } @@ -244,13 +247,12 @@ onMounted(async () => {