diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c6f65f..e9162ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,15 +22,15 @@ repos: - id: check-toml - id: debug-statements - # Add mypy type checking - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.1 + # Use project mypy (pyproject dev extra) so hook version matches uv.lock + - repo: local hooks: - id: mypy - language: python - additional_dependencies: [".[dev]"] - args: [] - exclude: ^(tests/|tools/) + name: mypy + entry: uv run mypy src/supervaizer + language: system + pass_filenames: false + types: [python] # Add copyright headers to files - repo: https://github.com/Lucas-C/pre-commit-hooks diff --git a/AGENTS.md b/AGENTS.md index 2f9a9ad..79b9d25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ This is the canonical agent guide for the Supervaizer controller repo. Supervaiz ## Working Rules - Prefer simple, typed Python changes that match existing FastAPI/Pydantic patterns. +- Do not import from inside functions, methods, or local scopes unless it is absolutely required to avoid a concrete circular import, optional dependency, or startup-cost problem. Prefer module-level imports by default, and document the reason when a local import is unavoidable. - **No guessing / no implicit fallbacks:** when protocol versions, workspace identity, action/resource contracts, authentication, or transport configuration are missing or inconsistent, fail with a clear error that names the missing configuration. Do not infer another context, broaden scope, or silently fall back. - Use `just` recipes from this repo for local commands. - Use `uv` for Python environment and package operations. @@ -31,6 +32,8 @@ Reference specific personas when requesting work: ## Learned User Preferences - When preparing a merge to `main` or a release, keep `docs/CHANGELOG.md` **Unreleased** accurate; on request, align listed dependency or tooling changes with the delta since the previous git tag (including `pyproject.toml`). +- Prefer `docs/CHANGELOG.md` `Unreleased` entries grouped into `Added` / `Changed` / `Fixed` (instead of custom feature headings). +- Dependabot PRs should target `develop`, not `main` (set `target-branch: "develop"` in `.github/dependabot.yml`). ## Learned Workspace Facts @@ -80,7 +83,7 @@ Ask. Refusing to act is always safer than taking an action that bypasses these r # GitNexus — Code Intelligence -This project is indexed by GitNexus as **supervaizer** (6117 symbols, 11434 relationships, 278 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **supervaizer** (6225 symbols, 11375 relationships, 276 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/docs/2026_05_SUPERVAIZER_v2.md b/docs/2026_05_SUPERVAIZER_v2.md index 942afb2..7a09daa 100644 --- a/docs/2026_05_SUPERVAIZER_v2.md +++ b/docs/2026_05_SUPERVAIZER_v2.md @@ -2,7 +2,7 @@ > **Created:** 2026-05-16 -> **Updated:** 2026-05-18 +> **Updated:** 2026-05-22 Supervaizer v2 is the new operation contract between an agent controller and Supervaize Studio. @@ -431,3 +431,72 @@ New agents should model Studio integration through v2 from the start. - Use `awaiting` state for HITL. - Return stable external ids in Job/Case/Step/Artifact snapshots. - Keep business validation inside agent actions, not inside Studio-specific code paths. + +## Implementation Status: Agent Interviewer Reference Agent + +As of 2026-05-22, Agent Interviewer is the reference implementation for the +v2 operating model. The current implementation is not just discovery metadata; +Studio can call the agent through A2A JSON-RPC, render A2UI surfaces, and sync +job state back from the agent. + +Implemented across the local Runwaize repos: + +- Supervaizer SDK exposes the v2 registration builder, A2A Agent Card metadata, + JSON-RPC action dispatch, surface dispatch, resource/dataset/action contracts, + workspace authorization verification, and clear handler-blocking errors. +- Studio ingests v2 registration data, renders generic resources, datasets, + surfaces, HITL forms, artifacts, job analytics, case lanes, and job-state + snapshots, and no longer relies on v1 dynamic job-start choices for v2 agents. +- Agent Interviewer declares campaigns, contacts, prompts, scenarios, + campaign_contacts, campaign datasets, transcript/synthesis artifacts, setup / + work / deliverable lanes, job analytics, workspace binding actions, and + campaign-specific surfaces through the v2 contract. +- Studio-to-agent calls for workspace-scoped actions use Workspace Agent Grants + and Studio-signed workspace authorization tokens. Raw tenant or workspace + slugs are not authority. +- Campaign starts return a top-level `job_state` snapshot so Studio can + materialize setup cases immediately, then converge through `job.sync`. +- Contact enrollment import is modeled as a generic `ResourceImport` surface. + Studio communicates and validates file structure; Agent Interviewer owns the + import format, tenant validation, persistence, and returned job state. +- Agent Interviewer job analytics are Vega-Lite based and currently focus on + the campaign-scoped session-duration-over-time chart for Studio job detail. +- Public interview configuration failures must be caught before live interview + startup. A missing or empty configured campaign prompt is a campaign + configuration error, not a network interruption. + +Deliberate decisions: + +- v2 does not preserve v1 dynamic-choice, job-poll, or legacy case-update + behavior unless explicitly required. For the current v2 workstream, enforce + v2 workspace operations whenever the Supervaize controller path is active. +- No guessing and no implicit fallback: missing workspace authorization, + missing workspace binding, mismatched server/agent id, missing scopes, and + missing campaign prompt configuration must fail with explicit errors. +- Studio owns generic rendering and acceptance records. Agents own business + vocabulary and must validate every business mutation. +- `server.register.details.server_id` is the authoritative controller identity. + It should survive agent restarts through registration, not by manual user + configuration. +- If `SUPERVAIZER_API_KEY` is set, Supervaizer uses it. If it is not set, the + agent developer may allow generation. Startup must verify Studio persisted + the effective key; mismatches fail startup instead of producing repeated + `/a2a` 401s later. + +Current gaps to consider next: + +- Make contract-change reacceptance smarter: already accepted grants should be + refreshable automatically when only non-expanding registration metadata + changes; expanded scopes or data access still require explicit acceptance. +- Strengthen e2e coverage for the entire blue-sky flow: share agent, accept + workspace binding, create mission, create campaign job, import contacts, run + setup case, start interview, sync transcript/synthesis, render analytics, and + revoke access. +- Add clearer Studio operator recovery for invalid campaign configuration, + missing workspace binding, missing signing key, wrong server id, and revoked + grants. +- Complete production deployment hardening for split public/controller runtimes, + Cloud Run startup probes, Secret Manager requirements, and dashboarded + registration-handshake health. +- Decide how Studio should present historical jobs when grants are revoked or + when an agent/server is replaced. diff --git a/docs/2026_05_WORKSPACE_AGENT_GRANTS.md b/docs/2026_05_WORKSPACE_AGENT_GRANTS.md index aea5368..93411b6 100644 --- a/docs/2026_05_WORKSPACE_AGENT_GRANTS.md +++ b/docs/2026_05_WORKSPACE_AGENT_GRANTS.md @@ -1,7 +1,7 @@ # Workspace Agent Grants > **Created:** 2026-05-18 -> **Updated:** 2026-05-18 +> **Updated:** 2026-05-22 This document plans the Supervaizer v2 authorization model for shared agents. @@ -429,3 +429,49 @@ Studio should surface these failures to operators as configuration or authorizat - An agent without local persistence can still verify every request. - Revoked grants stop authorizing requests after token expiry, and immediately for operations that use introspection. - Studio and agent_interviewer show clear errors for missing grant, missing scope, and missing workspace binding. + +## Current Implementation Notes + +As of 2026-05-22, the implementation has moved beyond this plan in the local +Runwaize repos: + +- Studio stores explicit workspace-agent acceptance and revocation state, + including who accepted the agent and when. +- Studio mints workspace authorization tokens for v2 calls and Supervaizer + verifies them before dispatching workspace-scoped handlers. +- Agent Interviewer requires verified workspace context for v2 resources, + datasets, job start, sync, HITL, and artifacts. It must not resolve access + from raw `workspace_slug` or `tenant_slug`. +- Agent-side workspace binding is generic in the v2 contract. Agent Interviewer + maps the generic `agent_workspace_ref` to its tenant configuration record, + but that tenant terminology is not exposed as a protocol requirement. +- Bootstrap binding actions are allowed before a workspace grant exists, but + only under normal Studio-to-agent transport authentication. Every other + workspace-scoped operation fails closed without a valid workspace token. + +Important learning from local restarts and multi-instance concerns: + +- A grant must not be bound to an ephemeral process instance id. Use the + registered Studio server identity from `server.register.details.server_id` + and let registration refresh update the server record transparently. +- Agent developers should not have to manually set Studio server ids. If the + effective registered server identity changes, Studio should refresh the + association or offer an explicit reset/refresh action to the operator. +- Registration handshake failures must be startup failures, not latent runtime + failures. The agent should fail to start when Studio did not persist the + effective controller API key or when the handshake response is missing the + data required to verify Studio-to-agent calls. +- Contract fingerprint checks should distinguish expanding changes from + harmless refreshes. Requiring manual reacceptance on every Studio restart is + too strict and creates operational noise. + +Open product decisions: + +- Which registration changes automatically refresh an accepted grant, and which + require explicit reacceptance? +- Should revocation immediately cancel running jobs, mark them failed in + Studio, or only block new calls while preserving active agent-side work? +- How should Studio present stale accepted grants when a server is replaced by + a new deployment with the same public URL and agent identity? +- Should high-risk operations introspect grant status on every call, or is + short-lived token expiry sufficient for the MVP? diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d987052..69ce251 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,8 +1,5 @@ # Supervaizer Changelog -> **Created:** 2025-08-05 -> **Updated:** 2026-05-20 - All notable changes to this project will be documented in this file. > The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). @@ -15,6 +12,32 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed + +- **FastAPI lifespan cleanup** — Controller shutdown now cancels the scheduled-step background loop and waits briefly for it to stop. +- **Server module refactor** — Split server startup configuration, runtime server info persistence, Studio registration handshakes, and scheduled-step loop management out of `server.py` into focused modules while preserving the public `Server` behavior. +- **Supervaizer v2 agent methods** — SDK agents can now declare optional standard actions such as `agent.refresh` plus custom agent actions through the same `AgentMethods` structure used for job methods, and the A2A runtime registers those handlers automatically. + +### Tests + +- `tests/test_server.py` — scheduler task cancellation and bounded shutdown waiting during FastAPI lifespan shutdown. +- `tests/test_server_refactor_modules.py` — parity coverage for the extracted server configuration, server info, registration, handshake, and scheduled-step helpers. +- `tests/test_a2a.py` — standard and custom agent method dispatch through the v2 A2A controller. +- `tests/test_agent.py` — agent-level v2 method registration and contract validation. +- `tests/test_contracts.py` — typed agent method contract serialization. + +### Tests + +- `tests/test_common.py` — structured JSON log output for API access-denial records +- `just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 663 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 136s | + ## [1.1.1] - 2026-05-20 ### Changed diff --git a/pyproject.toml b/pyproject.toml index ecd9c9a..ed18084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "cryptography>=48.0.0", "demjson3>=3.0.0", "deprecated>=1.3.0", - "fastapi>=0.136.1", + "fastapi>=0.136.3", "httpx>=0.28.1", "jinja2>=3.1.6", "loguru>=0.7.3", @@ -23,10 +23,10 @@ dependencies = [ "pyyaml>=6.0.0", "rich>=15.0.0", "shortuuid>=1.0.0", - "sse-starlette>=3.4.3", + "sse-starlette>=3.4.4", "tinydb>=4.8.2", "typer>=0.25.1", - "uvicorn>=0.46.0", + "uvicorn>=0.48.0", ] license = "MPL-2.0" classifiers = [ @@ -50,7 +50,7 @@ supervaizer = "supervaizer.cli:app" [project.optional-dependencies] deploy = [ - "boto3>=1.43.6", + "boto3>=1.43.14", "docker>=7.0.0", "google-cloud-artifact-registry>=1.21.0", "google-cloud-run>=0.16.0", @@ -69,7 +69,7 @@ dev = [ "pytest-sugar>=1.1.1", "pytest>=9.0.3", "respx>=0.23.1", - "ruff>=0.15.12", + "ruff>=0.15.14", "yamllint>=1.38.0", "types-deprecated>=1.3.1", "types-PyYAML>=6.0.0", diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 921614f..a293f09 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -78,6 +78,12 @@ "supervaizer.contracts", "SUPERVAIZER_V2_CONTRACT_VERSION", ), + "AGENT_CUSTOM_ACTION_PREFIX": ( + "supervaizer.contracts", + "AGENT_CUSTOM_ACTION_PREFIX", + ), + "AGENT_REFRESH_ACTION": ("supervaizer.contracts", "AGENT_REFRESH_ACTION"), + "AGENT_REFRESH_EFFECT": ("supervaizer.contracts", "AGENT_REFRESH_EFFECT"), "WORKSPACE_BINDING_CREATE_ACTION": ( "supervaizer.contracts", "WORKSPACE_BINDING_CREATE_ACTION", @@ -123,6 +129,8 @@ "V2A2UISubmitDefinition": ("supervaizer.contracts", "V2A2UISubmitDefinition"), "V2AgentCapabilities": ("supervaizer.contracts", "V2AgentCapabilities"), "V2AgentIdentity": ("supervaizer.contracts", "V2AgentIdentity"), + "V2AgentMethod": ("supervaizer.contracts", "V2AgentMethod"), + "V2AgentMethods": ("supervaizer.contracts", "V2AgentMethods"), "V2ArtifactRef": ("supervaizer.contracts", "V2ArtifactRef"), "V2ArtifactTypeDefinition": ( "supervaizer.contracts", diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 1aa0ca7..81910f5 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -14,6 +14,7 @@ import json import re from enum import Enum +from importlib import import_module from typing import ( TYPE_CHECKING, Any, @@ -36,7 +37,12 @@ from supervaizer.__version__ import VERSION from supervaizer.case import CaseNodes from supervaizer.common import ApiSuccess, SvBaseModel, log -from supervaizer.contracts import SupervaizerV2AgentRegistrationContract +from supervaizer.contracts import ( + SupervaizerV2AgentRegistrationContract, + V2ActionRequest, + V2AgentMethod, + V2AgentMethods, +) from supervaizer.data_resource import DataResource from supervaizer.event import JobStartConfirmationEvent from supervaizer.job import Job, JobContext, JobResponse @@ -695,6 +701,11 @@ class AgentAbstract(SvBaseModel): default=None, description="Optional Supervaizer v2 registration contract for A2A/A2UI Studio integrations", ) + v2_methods: V2AgentMethods | None = Field( + default=None, + description="Optional agent-level Supervaizer v2 method declarations", + exclude=True, + ) model_config = cast( ConfigDict, {"reference_group": "Core", "arbitrary_types_allowed": True} @@ -727,6 +738,7 @@ def __init__( supervaizer_v2_registration: SupervaizerV2AgentRegistrationContract | dict[str, Any] | None = None, + v2_methods: V2AgentMethods | dict[str, Any] | None = None, **kwargs: Any, ) -> None: """ @@ -771,6 +783,17 @@ def __init__( f"({expected_id!r}); omit id or use the expected value" ) + v2_registration = supervaizer_v2_registration + if isinstance(v2_registration, dict): + v2_registration = SupervaizerV2AgentRegistrationContract.model_validate( + v2_registration + ) + v2_method_declarations = v2_methods + if isinstance(v2_method_declarations, dict): + v2_method_declarations = V2AgentMethods.model_validate( + v2_method_declarations + ) + # Initialize using Pydantic's mechanism super().__init__( name=name, @@ -791,11 +814,13 @@ def __init__( max_execution_time=max_execution_time, custom_routes=custom_routes, data_resources=data_resources or [], - supervaizer_v2_registration=supervaizer_v2_registration, + supervaizer_v2_registration=v2_registration, + v2_methods=v2_method_declarations, **kwargs, ) self._validate_supervaizer_v2_identity() + self._apply_v2_method_capabilities() seen_resource_names: set[str] = set() for r in self.data_resources: @@ -821,6 +846,17 @@ def _validate_supervaizer_v2_identity(self) -> None: f"{declared_slug!r} != {self.slug!r}" ) + def _apply_v2_method_capabilities(self) -> None: + if self.supervaizer_v2_registration is None or self.v2_methods is None: + return + actions = [ + *self.supervaizer_v2_registration.capabilities.actions, + *self.v2_methods.action_ids, + ] + self.supervaizer_v2_registration.capabilities.actions = list( + dict.fromkeys(actions) + ) + @property def slug(self) -> str: return slugify(self.name) @@ -975,6 +1011,37 @@ def _declared_method_paths(self) -> set[str]: methods.extend(self.methods.custom.values()) return {method.method for method in methods if method is not None} + @property + def v2_action_ids(self) -> list[str]: + if self.v2_methods is None: + return [] + return self.v2_methods.action_ids + + def v2_method_for_action(self, action: str) -> V2AgentMethod | None: + if self.v2_methods is None: + return None + return self.v2_methods.method_for_action(action) + + def _declared_v2_method_paths(self) -> set[str]: + if self.v2_methods is None: + return set() + methods = [self.v2_methods.refresh, *self.v2_methods.custom.values()] + return {method.method for method in methods if method is not None} + + def execute_v2_action_method(self, action: str, request: V2ActionRequest) -> Any: + agent_method = self.v2_method_for_action(action) + if agent_method is None: + raise ValueError(f"Agent v2 action is not declared on agent: {action}") + if agent_method.method not in self._declared_v2_method_paths(): + raise ValueError( + f"Agent v2 method path is not declared on agent: {agent_method.method}" + ) + + module_name, func_name = agent_method.method.rsplit(".", 1) + module = import_module(module_name) + action_method = getattr(module, func_name) + return action_method(request=request, **agent_method.params) + def job_start( self, job: Job, diff --git a/src/supervaizer/common.py b/src/supervaizer/common.py index 7aefeaa..7dc4550 100644 --- a/src/supervaizer/common.py +++ b/src/supervaizer/common.py @@ -17,7 +17,7 @@ import sys import traceback from collections.abc import Callable -from typing import Any, TextIO, TypeVar +from typing import Any, TextIO, TypeVar, cast import demjson3 from cryptography.hazmat.primitives import hashes @@ -54,7 +54,10 @@ def configure_controller_logging( log.remove() if structured_logging_enabled(): return log.add( - lambda message: _write_structured_log(message.record, sink), + lambda message: _write_structured_log( + cast(dict[str, Any], message.record), + sink, + ), level=log_level, ) return log.add( diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index e0a6290..8c821a5 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -13,11 +13,12 @@ from __future__ import annotations +import re from collections.abc import Iterable from enum import StrEnum from typing import Any, Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator CONTROLLER_CONTRACT_VERSION = "1.0" API_VERSION = "v1" @@ -28,6 +29,10 @@ WORKSPACE_BINDING_OPTIONS_ACTION = "workspace_binding.options" WORKSPACE_BINDING_CREATE_ACTION = "workspace_binding.create" WORKSPACE_BINDING_CREATE_SURFACE = "workspace_binding.create" +AGENT_REFRESH_ACTION = "agent.refresh" +AGENT_REFRESH_EFFECT = "agent.refreshed" +AGENT_CUSTOM_ACTION_PREFIX = "agent.custom." +_AGENT_CUSTOM_METHOD_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$") class ContractModel(BaseModel): @@ -310,6 +315,47 @@ class V2AgentCapabilities(ContractModel): artifact_types: list[V2ArtifactTypeDefinition] = Field(default_factory=list) +class V2AgentMethod(ContractModel): + method: str + params: dict[str, Any] = Field(default_factory=dict) + description: str | None = None + is_async: bool = False + timeout: int | None = 600 + + +class V2AgentMethods(ContractModel): + refresh: V2AgentMethod | None = None + custom: dict[str, V2AgentMethod] = Field(default_factory=dict) + + @field_validator("custom") + @classmethod + def validate_custom_method_names( + cls, value: dict[str, V2AgentMethod] + ) -> dict[str, V2AgentMethod]: + for name in value: + if not _AGENT_CUSTOM_METHOD_KEY_RE.fullmatch(name): + raise ValueError( + "agent custom method keys may only contain letters, numbers, " + "underscores, and hyphens" + ) + return value + + @property + def action_ids(self) -> list[str]: + actions: list[str] = [] + if self.refresh is not None: + actions.append(AGENT_REFRESH_ACTION) + actions.extend(f"{AGENT_CUSTOM_ACTION_PREFIX}{name}" for name in self.custom) + return actions + + def method_for_action(self, action: str) -> V2AgentMethod | None: + if action == AGENT_REFRESH_ACTION: + return self.refresh + if action.startswith(AGENT_CUSTOM_ACTION_PREFIX): + return self.custom.get(action.removeprefix(AGENT_CUSTOM_ACTION_PREFIX)) + return None + + class V2JobSyncPolicy(ContractModel): action: str = "job.sync" supported_statuses: list[str] = Field(default_factory=list) @@ -524,6 +570,7 @@ def build_v2_agent_registration( datasets: Iterable[V2DatasetDefinition | dict[str, Any]] = (), dashboards: Iterable[V2DashboardDefinition | dict[str, Any]] = (), workspace_binding: V2WorkspaceBindingDefinition | dict[str, Any] | None = None, + agent_methods: V2AgentMethods | dict[str, Any] | None = None, case_lanes: Iterable[V2CaseLaneDefinition | dict[str, Any]] = (), artifact_types: Iterable[V2ArtifactTypeDefinition | dict[str, Any]] = (), job_policy: V2JobPolicy | dict[str, Any] | None = None, @@ -538,6 +585,7 @@ def build_v2_agent_registration( dataset_definitions = _contract_list(datasets, V2DatasetDefinition) dashboard_definitions = _contract_list(dashboards, V2DashboardDefinition) workspace_binding_definition = _workspace_binding(workspace_binding) + agent_method_definitions = _agent_methods(agent_methods) sync_policy = _job_policy(job_policy) capability_surfaces = _unique_strings([ @@ -553,6 +601,7 @@ def build_v2_agent_registration( *_dataset_action_ids(dataset_definitions), *(_job_sync_actions(sync_policy)), *_workspace_binding_action_ids(workspace_binding_definition), + *_agent_method_action_ids(agent_method_definitions), ]) return SupervaizerV2AgentRegistrationContract( @@ -623,6 +672,16 @@ def _workspace_binding( return V2WorkspaceBindingDefinition.model_validate(value) +def _agent_methods( + value: V2AgentMethods | dict[str, Any] | None, +) -> V2AgentMethods | None: + if value is None: + return None + if isinstance(value, V2AgentMethods): + return value + return V2AgentMethods.model_validate(value) + + def _unique_strings(values: Iterable[str]) -> list[str]: seen: set[str] = set() result: list[str] = [] @@ -672,6 +731,12 @@ def _job_sync_actions(job_policy: V2JobPolicy) -> list[str]: return [job_policy.sync.action] +def _agent_method_action_ids(agent_methods: V2AgentMethods | None) -> list[str]: + if agent_methods is None: + return [] + return agent_methods.action_ids + + def _workspace_binding_action_ids( workspace_binding: V2WorkspaceBindingDefinition | None, ) -> list[str]: diff --git a/src/supervaizer/scheduled_steps.py b/src/supervaizer/scheduled_steps.py new file mode 100644 index 0000000..24bcbcb --- /dev/null +++ b/src/supervaizer/scheduled_steps.py @@ -0,0 +1,53 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from supervaizer.common import log + +if TYPE_CHECKING: + from supervaizer.server import Server + +SCHEDULED_STEP_POLL_SECONDS = 60 + + +def _execute_scheduled_method(method_path: str, params: dict[str, Any]) -> Any: + """Execute a method by its full dotted path.""" + module_name, func_name = method_path.rsplit(".", 1) + module = __import__(module_name, fromlist=[func_name]) + method = getattr(module, func_name) + return method(**params) + + +async def _run_scheduled_step_loop(server: Server) -> None: + """Poll for due scheduled steps and execute them.""" + from supervaizer.case import Cases + + while True: + await asyncio.sleep(SCHEDULED_STEP_POLL_SECONDS) + try: + cases = Cases() + due_steps = cases.get_due_scheduled_steps() + for _case, _step_index, update in due_steps: + if not update.scheduled_method: + continue + try: + object.__setattr__(update, "scheduled_status", "executing") + log.info(f"[Scheduled step] Executing: {update.name}") + _execute_scheduled_method( + update.scheduled_method, + update.scheduled_params or {}, + ) + object.__setattr__(update, "scheduled_status", "completed") + log.info(f"[Scheduled step] Completed: {update.name}") + except Exception as exc: + object.__setattr__(update, "scheduled_status", "failed") + log.error(f"[Scheduled step] Failed: {update.name}: {exc}") + except Exception as exc: + log.error(f"[Scheduled step loop] Error: {exc}") diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 6097eb5..db1393d 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -16,15 +16,11 @@ import time import uuid from collections.abc import AsyncIterator, Callable -from contextlib import asynccontextmanager -from datetime import datetime # <-- REMOVED: Path (no longer needed) -from hashlib import sha256 +from contextlib import asynccontextmanager, suppress from typing import Any, ClassVar, TypeVar, cast from urllib.parse import urlunparse -from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey from fastapi import FastAPI, HTTPException, Request, Security, status from fastapi.exceptions import RequestValidationError @@ -32,7 +28,7 @@ from fastapi.security import APIKeyHeader # <-- REMOVED: Jinja2Templates (home page moved to routers/public.py) -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import ConfigDict, Field, field_validator from rich import inspect from supervaizer.__version__ import VERSION @@ -53,7 +49,6 @@ from supervaizer.contracts import ( API_VERSION, V2WorkspaceAuthorizationSettings, - controller_contract_info, ) from supervaizer.instructions import display_instructions from supervaizer.protocol.a2a.controller import ( @@ -68,7 +63,31 @@ create_public_router, ) # <-- ADDED from supervaizer.routes import get_server # <-- MODIFIED: removed per-router imports -from supervaizer.storage import StorageManager, load_running_entities_on_startup +from supervaizer.scheduled_steps import ( + _execute_scheduled_method as _execute_scheduled_method, + _run_scheduled_step_loop, +) +from supervaizer.server_config import ( + _controller_key_fingerprint, + _env_bool as _env_bool, + _get_or_create_private_key, + _get_or_create_server_id, + _resolve_workspace_authorization_settings, +) +from supervaizer.server_info import ( + ServerInfo as ServerInfo, + get_server_info_from_live as get_server_info_from_live, + get_server_info_from_storage as get_server_info_from_storage, + save_server_info_to_storage, +) +from supervaizer.server_registration import build_server_registration_info +from supervaizer.storage import load_running_entities_on_startup +from supervaizer.studio_handshake import ( + apply_workspace_authorization_agent_bindings, + apply_workspace_authorization_handshake, + validate_registration_handshake, + validate_studio_a2a_workspace_authorization, +) from supervaizer.workspace_authorization import ( validate_workspace_authorization_settings, ) @@ -76,206 +95,14 @@ insp = inspect T = TypeVar("T") - -# Additional imports for server persistence +SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS = 5.0 -def _get_or_create_server_id() -> str: - """Use SUPERVAIZER_SERVER_ID from env if set; else create uuid and set env.""" - existing = os.getenv("SUPERVAIZER_SERVER_ID") - if existing and len(existing) > 5: - return existing - new_id = str(uuid.uuid4()) - os.environ["SUPERVAIZER_SERVER_ID"] = new_id - return new_id - - -def _controller_key_fingerprint(api_key: str | None) -> str | None: - if not api_key: - return None - return sha256(api_key.encode("utf-8")).hexdigest()[:12] - - -def _resolve_workspace_authorization_settings( - explicit_settings: V2WorkspaceAuthorizationSettings | dict[str, Any] | None, -) -> V2WorkspaceAuthorizationSettings: - if explicit_settings is not None: - return V2WorkspaceAuthorizationSettings.model_validate(explicit_settings) - return V2WorkspaceAuthorizationSettings( - enabled=_env_bool("SUPERVAIZER_WORKSPACE_AUTH_REQUIRED", default=False), - issuer=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_ISSUER") or None, - audience=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_AUDIENCE") or None, - public_key_pem=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY") or None, - jwks_url=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL") or None, - leeway_seconds=int( - os.getenv("SUPERVAIZER_WORKSPACE_AUTH_LEEWAY_SECONDS", "30") - ), - ) +def _agent_v2_method_handler(agent: Agent, action: str) -> ActionHandler: + def handler(request: Any) -> Any: + return agent.execute_v2_action_method(action, request) - -def _env_bool(name: str, *, default: bool) -> bool: - raw_value = os.getenv(name) - if raw_value is None: - return default - return raw_value.strip().lower() in {"1", "true", "yes", "on"} - - -def _get_or_create_private_key() -> RSAPrivateKey: - """Use SUPERVAIZER_PRIVATE_KEY from env if set; else create key and set env.""" - pem = os.getenv("SUPERVAIZER_PRIVATE_KEY") - if pem and len(pem) > 5: - try: - key = serialization.load_pem_private_key( - pem.encode("utf-8"), - password=None, - backend=default_backend(), - ) - return cast(RSAPrivateKey, key) - except Exception as e: - log.warning( - f"[Server] Invalid SUPERVAIZER_PRIVATE_KEY, generating new key: {e}" - ) - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=default_backend(), - ) - pem_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - os.environ["SUPERVAIZER_PRIVATE_KEY"] = pem_bytes.decode("utf-8") - log.info("[Server] Generated new RSA private key and set SUPERVAIZER_PRIVATE_KEY") - return private_key - - -class ServerInfo(BaseModel): - """Complete server information for storage.""" - - id: str = "server_instance" # Fixed ID for singleton - host: str - port: int - api_version: str - environment: str - agents: list[dict[str, str]] - start_time: float - created_at: str - updated_at: str - - -def save_server_info_to_storage(server_instance: "Server") -> None: - """Save server information to storage.""" - try: - storage = StorageManager() - - # Get agent information - agents = [] - if hasattr(server_instance, "agents") and server_instance.agents: - for agent in server_instance.agents: - agents.append({ - "name": agent.name, - "description": agent.description, - "version": agent.version, - "api_path": agent.path, - "slug": agent.slug, - "instructions_path": agent.instructions_path, - }) - - # Create server info - server_info = ServerInfo( - id="server_instance", - host=getattr(server_instance, "host", "N/A"), - port=getattr(server_instance, "port", 0), - api_version=API_VERSION, - environment=os.getenv("SUPERVAIZER_ENVIRONMENT", "development"), - agents=agents, - start_time=time.time(), - created_at=datetime.now().isoformat(), - updated_at=datetime.now().isoformat(), - ) - - # Save to storage under the fixed singleton id so retrieval works - storage.save_object("ServerInfo", server_info.model_dump()) - - log.info( - f"[Server] Server info saved to storage: {server_info.host}:{server_info.port}" - ) - - except Exception as e: - log.error(f"[Server] Failed to save server info to storage: {e}") - - -def get_server_info_from_storage() -> ServerInfo | None: - """Get server information from storage.""" - storage = StorageManager() - server_data = storage.get_object_by_id("ServerInfo", "server_instance") - - if server_data: - return ServerInfo.model_validate(server_data) - return None - - -def get_server_info_from_live(server_instance: "Server") -> ServerInfo: - """Build ServerInfo from a live Server instance (for when storage has no ServerInfo, e.g. no persistence).""" - agents = [] - if hasattr(server_instance, "agents") and server_instance.agents: - for agent in server_instance.agents: - agents.append({ - "name": agent.name, - "description": agent.description, - "version": agent.version, - "api_path": agent.path, - "slug": agent.slug, - "instructions_path": agent.instructions_path, - }) - start_time = getattr(server_instance, "_start_time", time.time()) - return ServerInfo( - host=getattr(server_instance, "host", "N/A"), - port=getattr(server_instance, "port", 0), - api_version=API_VERSION, - environment=os.getenv("SUPERVAIZER_ENVIRONMENT", "development"), - agents=agents, - start_time=start_time, - created_at=datetime.now().isoformat(), - updated_at=datetime.now().isoformat(), - ) - - -def _execute_scheduled_method(method_path: str, params: dict) -> Any: - """Execute a method by its full dotted path (module.func).""" - module_name, func_name = method_path.rsplit(".", 1) - module = __import__(module_name, fromlist=[func_name]) - method = getattr(module, func_name) - return method(**params) - - -async def _run_scheduled_step_loop(server: "Server") -> None: - """Poll for due scheduled steps every 60 seconds and execute them.""" - from supervaizer.case import Cases - - while True: - await asyncio.sleep(60) - try: - cases = Cases() - due_steps = cases.get_due_scheduled_steps() - for _case, _step_index, update in due_steps: - if not update.scheduled_method: - continue - try: - object.__setattr__(update, "scheduled_status", "executing") - log.info(f"[Scheduled step] Executing: {update.name}") - _execute_scheduled_method( - update.scheduled_method, - update.scheduled_params or {}, - ) - object.__setattr__(update, "scheduled_status", "completed") - log.info(f"[Scheduled step] Completed: {update.name}") - except Exception as exc: - object.__setattr__(update, "scheduled_status", "failed") - log.error(f"[Scheduled step] Failed: {update.name}: {exc}") - except Exception as exc: - log.error(f"[Scheduled step loop] Error: {exc}") + return handler class ServerAbstract(SvBaseModel): @@ -520,9 +347,29 @@ def __init__( openapi_url = "/openapi.json" @asynccontextmanager - async def _lifespan(app: FastAPI) -> AsyncIterator[None]: - asyncio.create_task(_run_scheduled_step_loop(self)) - yield + async def _lifespan(_app: FastAPI) -> AsyncIterator[None]: + # Keep a task handle so shutdown can stop the scheduler cleanly. + scheduled_step_task = asyncio.create_task( + _run_scheduled_step_loop(self), + name="supervaizer-scheduled-step-loop", + ) + try: + yield + finally: + # Give the scheduler a bounded chance to observe cancellation. + scheduled_step_task.cancel() + done, pending = await asyncio.wait( + {scheduled_step_task}, + timeout=SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS, + ) + if pending: + log.warning( + "[Scheduled step] Shutdown timed out while waiting for " + "the scheduler task to stop" + ) + if done: + with suppress(asyncio.CancelledError): + await scheduled_step_task app = FastAPI( lifespan=_lifespan, @@ -605,6 +452,7 @@ async def validation_exception_handler( # Store server instance on app state before building routers self.app.state.server = self # <-- MOVED earlier (was after route mount) + self._register_agent_v2_method_handlers() # Activate API + A2A routes when supervisor account or local mode is set if self.supervisor_account or local_mode: @@ -708,30 +556,7 @@ def uri(self) -> str: @property def registration_info(self) -> dict[str, Any]: """Get registration info for the server.""" - assert self.public_key is not None, "Public key not initialized" - contract = controller_contract_info() - return { - "server_id": self.server_id, - "url": self.public_url, - "uri": self.uri, - "api_version": API_VERSION, - "controller_version": VERSION, - **contract, - "environment": self.environment, - "public_key": str( - self.public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ).decode("utf-8") - ), - "api_key": self.api_key, - "docs": { - "swagger": f"{self.public_url}{self.app.docs_url}", - "redoc": f"{self.public_url}{self.app.redoc_url}", - "openapi": f"{self.public_url}{self.app.openapi_url}", - }, - "agents": [agent.registration_info for agent in self.agents], - } + return build_server_registration_info(self) def launch(self, log_level: str | None = "INFO") -> None: if log_level: @@ -806,132 +631,20 @@ def instructions(self) -> None: ) def _validate_registration_handshake(self, result: ApiSuccess) -> None: - detail = result.detail if isinstance(result.detail, dict) else {} - response_object = detail.get("object") - if not isinstance(response_object, dict): - raise RuntimeError( - "Studio registration handshake failed: server.register response did not " - "include a response object. Studio-to-agent API key persistence could not " - "be verified." - ) - handshake = response_object.get("supervaizer_handshake") - if not isinstance(handshake, dict): - response_keys = sorted(str(key) for key in response_object.keys()) - raise RuntimeError( - "Studio registration handshake failed: server.register response did not " - "include supervaizer_handshake. Studio-to-agent API key persistence could " - "not be verified. Check that SUPERVAIZE_API_URL points to a Studio " - "instance that supports the Supervaizer v2 registration handshake. " - f"response_keys={response_keys}" - ) - if handshake.get("controller_api_key_match") is True: - self._apply_workspace_authorization_handshake(handshake) - log.info( - "[Server launch] Studio registration handshake verified " - f"server_id={handshake.get('server_id')} " - f"controller_key_fingerprint={_controller_key_fingerprint(self.api_key)}" - ) - return - raise RuntimeError( - "Studio registration handshake failed: Studio did not persist the controller API key " - f"for server_id={handshake.get('server_id')}. " - f"controller_key_fingerprint={_controller_key_fingerprint(self.api_key)} " - f"studio_fingerprint={handshake.get('stored_controller_api_key_fingerprint')} " - f"reason={handshake.get('reason')}" - ) + validate_registration_handshake(self, result) def _validate_studio_a2a_workspace_authorization(self) -> None: - if not self.a2a_endpoints or self.supervisor_account is None: - return - if self.workspace_authorization.enabled: - return - raise RuntimeError( - "Studio-registered Supervaizer v2 A2A requires workspace authorization. " - "Set SUPERVAIZER_WORKSPACE_AUTH_REQUIRED=true and configure " - "SUPERVAIZER_WORKSPACE_AUTH_ISSUER plus either " - "SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY or SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL." - ) + validate_studio_a2a_workspace_authorization(self) def _apply_workspace_authorization_handshake( self, handshake: dict[str, Any] ) -> None: - if not self.workspace_authorization.enabled: - return - - workspace_authorization = handshake.get("workspace_authorization") - if not isinstance(workspace_authorization, dict): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization is enabled " - "but supervaizer_handshake.workspace_authorization is missing." - ) - - audience = workspace_authorization.get("audience") - if not isinstance(audience, str) or not audience.strip(): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization is enabled " - "but supervaizer_handshake.workspace_authorization.audience is missing." - ) - - configured_audience = self.workspace_authorization.audience - if configured_audience and configured_audience != audience: - raise RuntimeError( - "Studio registration handshake failed: configured workspace authorization " - "audience does not match Studio's server audience." - ) - - self.workspace_authorization = self.workspace_authorization.model_copy( - update={"audience": audience} - ) - agent_bindings = workspace_authorization.get("agents") - if not isinstance(agent_bindings, list): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization is enabled " - "but supervaizer_handshake.workspace_authorization.agents is missing." - ) - self._apply_workspace_authorization_agent_bindings(agent_bindings) + apply_workspace_authorization_handshake(self, handshake) def _apply_workspace_authorization_agent_bindings( self, agent_bindings: list[Any] ) -> None: - bindings_by_slug: dict[str, str] = {} - for binding in agent_bindings: - if not isinstance(binding, dict): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - "binding must be an object." - ) - agent_id = binding.get("id") - agent_slug = binding.get("slug") - if not isinstance(agent_id, str) or not agent_id.strip(): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - "binding is missing id." - ) - if not isinstance(agent_slug, str) or not agent_slug.strip(): - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - "binding is missing slug." - ) - bindings_by_slug[agent_slug] = agent_id - - missing_agents = [] - for agent in self.agents: - studio_agent_id = bindings_by_slug.get(agent.slug) - if not studio_agent_id: - missing_agents.append(agent.slug) - continue - if agent.server_agent_id and agent.server_agent_id != studio_agent_id: - raise RuntimeError( - "Studio registration handshake failed: workspace authorization agent " - f"id mismatch for slug={agent.slug}." - ) - agent.server_agent_id = studio_agent_id - - if missing_agents: - raise RuntimeError( - "Studio registration handshake failed: workspace authorization did not " - f"return Studio agent id(s) for slug(s): {', '.join(missing_agents)}" - ) + apply_workspace_authorization_agent_bindings(self, agent_bindings) def decrypt(self, encrypted_parameters: str) -> str: """Decrypt parameters using the server's private key.""" @@ -954,6 +667,15 @@ def register_v2_action( register_v2_action_handler(self, action, handler, agent_slug=agent_slug) return handler + def _register_agent_v2_method_handlers(self) -> None: + for agent in self.agents: + for action in agent.v2_action_ids: + self.register_v2_action( + action, + _agent_v2_method_handler(agent, action), + agent_slug=agent.slug, + ) + def v2_action( self, action: str, *, agent_slug: str | None = None ) -> Callable[[ActionHandler], ActionHandler]: diff --git a/src/supervaizer/server_config.py b/src/supervaizer/server_config.py new file mode 100644 index 0000000..d17df9f --- /dev/null +++ b/src/supervaizer/server_config.py @@ -0,0 +1,90 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import os +import uuid +from hashlib import sha256 +from typing import Any, cast + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + +from supervaizer.common import log +from supervaizer.contracts import V2WorkspaceAuthorizationSettings + + +def _get_or_create_server_id() -> str: + """Use SUPERVAIZER_SERVER_ID from env if set; else create uuid and set env.""" + existing = os.getenv("SUPERVAIZER_SERVER_ID") + if existing and len(existing) > 5: + return existing + new_id = str(uuid.uuid4()) + os.environ["SUPERVAIZER_SERVER_ID"] = new_id + return new_id + + +def _controller_key_fingerprint(api_key: str | None) -> str | None: + if not api_key: + return None + return sha256(api_key.encode("utf-8")).hexdigest()[:12] + + +def _resolve_workspace_authorization_settings( + explicit_settings: V2WorkspaceAuthorizationSettings | dict[str, Any] | None, +) -> V2WorkspaceAuthorizationSettings: + if explicit_settings is not None: + return V2WorkspaceAuthorizationSettings.model_validate(explicit_settings) + return V2WorkspaceAuthorizationSettings( + enabled=_env_bool("SUPERVAIZER_WORKSPACE_AUTH_REQUIRED", default=False), + issuer=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_ISSUER") or None, + audience=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_AUDIENCE") or None, + public_key_pem=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY") or None, + jwks_url=os.getenv("SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL") or None, + leeway_seconds=int( + os.getenv("SUPERVAIZER_WORKSPACE_AUTH_LEEWAY_SECONDS", "30") + ), + ) + + +def _env_bool(name: str, *, default: bool) -> bool: + raw_value = os.getenv(name) + if raw_value is None: + return default + return raw_value.strip().lower() in {"1", "true", "yes", "on"} + + +def _get_or_create_private_key() -> RSAPrivateKey: + """Use SUPERVAIZER_PRIVATE_KEY from env if set; else create key and set env.""" + pem = os.getenv("SUPERVAIZER_PRIVATE_KEY") + if pem and len(pem) > 5: + try: + key = serialization.load_pem_private_key( + pem.encode("utf-8"), + password=None, + backend=default_backend(), + ) + return cast(RSAPrivateKey, key) + except Exception as e: + log.warning( + f"[Server] Invalid SUPERVAIZER_PRIVATE_KEY, generating new key: {e}" + ) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend(), + ) + pem_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + os.environ["SUPERVAIZER_PRIVATE_KEY"] = pem_bytes.decode("utf-8") + log.info("[Server] Generated new RSA private key and set SUPERVAIZER_PRIVATE_KEY") + return private_key diff --git a/src/supervaizer/server_info.py b/src/supervaizer/server_info.py new file mode 100644 index 0000000..03aac9f --- /dev/null +++ b/src/supervaizer/server_info.py @@ -0,0 +1,100 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import os +import time +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + +from supervaizer.common import log +from supervaizer.contracts import API_VERSION +from supervaizer.storage import StorageManager + +SERVER_INFO_ID = "server_instance" +SERVER_INFO_KIND = "ServerInfo" + + +class ServerInfo(BaseModel): + """Complete server information for storage.""" + + id: str = SERVER_INFO_ID + host: str + port: int + api_version: str + environment: str + agents: list[dict[str, str]] + start_time: float + created_at: str + updated_at: str + + +def save_server_info_to_storage(server_instance: Any) -> None: + """Save server information to storage.""" + try: + storage = StorageManager() + server_info = _build_server_info( + server_instance, + start_time=time.time(), + ) + storage.save_object(SERVER_INFO_KIND, server_info.model_dump()) + log.info( + f"[Server] Server info saved to storage: {server_info.host}:{server_info.port}" + ) + except Exception as e: + log.error(f"[Server] Failed to save server info to storage: {e}") + + +def get_server_info_from_storage() -> ServerInfo | None: + """Get server information from storage.""" + storage = StorageManager() + server_data = storage.get_object_by_id(SERVER_INFO_KIND, SERVER_INFO_ID) + if server_data: + return ServerInfo.model_validate(server_data) + return None + + +def get_server_info_from_live(server_instance: Any) -> ServerInfo: + """Build server information from a live server instance.""" + return _build_server_info( + server_instance, + start_time=getattr(server_instance, "_start_time", time.time()), + ) + + +def _build_server_info(server_instance: Any, *, start_time: float) -> ServerInfo: + timestamp = datetime.now().isoformat() + return ServerInfo( + id=SERVER_INFO_ID, + host=getattr(server_instance, "host", "N/A"), + port=getattr(server_instance, "port", 0), + api_version=API_VERSION, + environment=os.getenv("SUPERVAIZER_ENVIRONMENT", "development"), + agents=_build_agent_info(server_instance), + start_time=start_time, + created_at=timestamp, + updated_at=timestamp, + ) + + +def _build_agent_info(server_instance: Any) -> list[dict[str, str]]: + agents = getattr(server_instance, "agents", None) + if not agents: + return [] + return [ + { + "name": agent.name, + "description": agent.description, + "version": agent.version, + "api_path": agent.path, + "slug": agent.slug, + "instructions_path": agent.instructions_path, + } + for agent in agents + ] diff --git a/src/supervaizer/server_registration.py b/src/supervaizer/server_registration.py new file mode 100644 index 0000000..8ea4d20 --- /dev/null +++ b/src/supervaizer/server_registration.py @@ -0,0 +1,42 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +from typing import Any + +from cryptography.hazmat.primitives import serialization + +from supervaizer.__version__ import VERSION +from supervaizer.contracts import API_VERSION, controller_contract_info + + +def build_server_registration_info(server: Any) -> dict[str, Any]: + """Build the Studio-compatible server.register payload details.""" + assert server.public_key is not None, "Public key not initialized" + contract = controller_contract_info() + return { + "server_id": server.server_id, + "url": server.public_url, + "uri": server.uri, + "api_version": API_VERSION, + "controller_version": VERSION, + **contract, + "environment": server.environment, + "public_key": str( + server.public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + ), + "api_key": server.api_key, + "docs": { + "swagger": f"{server.public_url}{server.app.docs_url}", + "redoc": f"{server.public_url}{server.app.redoc_url}", + "openapi": f"{server.public_url}{server.app.openapi_url}", + }, + "agents": [agent.registration_info for agent in server.agents], + } diff --git a/src/supervaizer/studio_handshake.py b/src/supervaizer/studio_handshake.py new file mode 100644 index 0000000..e75ed4e --- /dev/null +++ b/src/supervaizer/studio_handshake.py @@ -0,0 +1,144 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +from typing import Any + +from supervaizer.common import ApiSuccess, log +from supervaizer.server_config import _controller_key_fingerprint + + +def validate_registration_handshake(server: Any, result: ApiSuccess) -> None: + detail = result.detail if isinstance(result.detail, dict) else {} + response_object = detail.get("object") + if not isinstance(response_object, dict): + raise RuntimeError( + "Studio registration handshake failed: server.register response did not " + "include a response object. Studio-to-agent API key persistence could not " + "be verified." + ) + handshake = response_object.get("supervaizer_handshake") + if not isinstance(handshake, dict): + response_keys = sorted(str(key) for key in response_object.keys()) + raise RuntimeError( + "Studio registration handshake failed: server.register response did not " + "include supervaizer_handshake. Studio-to-agent API key persistence could " + "not be verified. Check that SUPERVAIZE_API_URL points to a Studio " + "instance that supports the Supervaizer v2 registration handshake. " + f"response_keys={response_keys}" + ) + if handshake.get("controller_api_key_match") is True: + apply_workspace_authorization_handshake(server, handshake) + log.info( + "[Server launch] Studio registration handshake verified " + f"server_id={handshake.get('server_id')} " + f"controller_key_fingerprint={_controller_key_fingerprint(server.api_key)}" + ) + return + raise RuntimeError( + "Studio registration handshake failed: Studio did not persist the controller API key " + f"for server_id={handshake.get('server_id')}. " + f"controller_key_fingerprint={_controller_key_fingerprint(server.api_key)} " + f"studio_fingerprint={handshake.get('stored_controller_api_key_fingerprint')} " + f"reason={handshake.get('reason')}" + ) + + +def validate_studio_a2a_workspace_authorization(server: Any) -> None: + if not server.a2a_endpoints or server.supervisor_account is None: + return + if server.workspace_authorization.enabled: + return + raise RuntimeError( + "Studio-registered Supervaizer v2 A2A requires workspace authorization. " + "Set SUPERVAIZER_WORKSPACE_AUTH_REQUIRED=true and configure " + "SUPERVAIZER_WORKSPACE_AUTH_ISSUER plus either " + "SUPERVAIZER_WORKSPACE_AUTH_PUBLIC_KEY or SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL." + ) + + +def apply_workspace_authorization_handshake( + server: Any, handshake: dict[str, Any] +) -> None: + if not server.workspace_authorization.enabled: + return + + workspace_authorization = handshake.get("workspace_authorization") + if not isinstance(workspace_authorization, dict): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization is missing." + ) + + audience = workspace_authorization.get("audience") + if not isinstance(audience, str) or not audience.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization.audience is missing." + ) + + configured_audience = server.workspace_authorization.audience + if configured_audience and configured_audience != audience: + raise RuntimeError( + "Studio registration handshake failed: configured workspace authorization " + "audience does not match Studio's server audience." + ) + + server.workspace_authorization = server.workspace_authorization.model_copy( + update={"audience": audience} + ) + agent_bindings = workspace_authorization.get("agents") + if not isinstance(agent_bindings, list): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization is enabled " + "but supervaizer_handshake.workspace_authorization.agents is missing." + ) + apply_workspace_authorization_agent_bindings(server, agent_bindings) + + +def apply_workspace_authorization_agent_bindings( + server: Any, agent_bindings: list[Any] +) -> None: + bindings_by_slug: dict[str, str] = {} + for binding in agent_bindings: + if not isinstance(binding, dict): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding must be an object." + ) + agent_id = binding.get("id") + agent_slug = binding.get("slug") + if not isinstance(agent_id, str) or not agent_id.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding is missing id." + ) + if not isinstance(agent_slug, str) or not agent_slug.strip(): + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + "binding is missing slug." + ) + bindings_by_slug[agent_slug] = agent_id + + missing_agents = [] + for agent in server.agents: + studio_agent_id = bindings_by_slug.get(agent.slug) + if not studio_agent_id: + missing_agents.append(agent.slug) + continue + if agent.server_agent_id and agent.server_agent_id != studio_agent_id: + raise RuntimeError( + "Studio registration handshake failed: workspace authorization agent " + f"id mismatch for slug={agent.slug}." + ) + agent.server_agent_id = studio_agent_id + + if missing_agents: + raise RuntimeError( + "Studio registration handshake failed: workspace authorization did not " + f"return Studio agent id(s) for slug(s): {', '.join(missing_agents)}" + ) diff --git a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json index 3d2ccda..b923eb8 100644 --- a/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json +++ b/tests/fixtures/supervaizer_v2/agent_interviewer_mvp.json @@ -46,7 +46,7 @@ "job.start", "job.stop", "job.sync", - "campaigns.sync", + "agent.refresh", "artifact.get", "resource.campaigns.list", "resource.campaigns.create", diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 8f704b3..1b00411 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -21,7 +21,15 @@ from cryptography.hazmat.primitives.asymmetric import ed25519, rsa from fastapi.testclient import TestClient -from supervaizer import Agent, Server +from supervaizer import ( + AGENT_REFRESH_ACTION, + AGENT_REFRESH_EFFECT, + Agent, + Server, + V2AgentMethod, + V2AgentMethods, + build_v2_agent_registration, +) from supervaizer.access import API_KEYS from supervaizer.contracts import ( V2ActionRequest, @@ -56,6 +64,13 @@ from supervaizer.workspace_authorization import WORKSPACE_AUTHORIZATION_HEADER +def _test_agent_refresh(request: V2ActionRequest) -> dict[str, object]: + return { + "status": "ok", + "effects": [{"type": AGENT_REFRESH_EFFECT, "request_id": request.request_id}], + } + + def _a2a_write_headers(server: Server) -> dict[str, str]: return {"X-API-Key": server.api_key or ""} @@ -1622,6 +1637,57 @@ def preview_job_start(request: V2ActionRequest) -> dict[str, object]: } +def test_server_registers_agent_v2_method_handlers() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + agent = Agent( + name="Agent Name", + version="1.0.0", + supervaizer_v2_registration=build_v2_agent_registration( + agent_id="agent-name", + agent_slug="agent-name", + display_name="Agent Name", + agent_card_url="/.well-known/agents/v1.0.0/agent-name_agent.json", + controller_url="/a2a", + a2ui_catalog_version="test.0", + ), + v2_methods=V2AgentMethods( + refresh=V2AgentMethod(method="tests.test_a2a._test_agent_refresh") + ), + ) + server = Server( + agents=[agent], + private_key=private_key, + api_key="test-api-key", + admin_interface=False, + ) + headers = _authorized_a2a_headers( + server, + agent_slug=agent.slug, + scopes=[SUPERVAIZER_ACTION_INVOKE_METHOD, AGENT_REFRESH_ACTION], + ) + client = TestClient(server.app) + + response = client.post( + "/a2a", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "rpc-agent-refresh", + "method": SUPERVAIZER_ACTION_INVOKE_METHOD, + "params": _v2_action_payload( + action=AGENT_REFRESH_ACTION, + agent_slug=agent.slug, + ), + }, + ) + + assert response.status_code == 200 + assert response.json()["result"] == { + "status": "ok", + "effects": [{"type": AGENT_REFRESH_EFFECT, "request_id": "request-1"}], + } + + def test_server_v2_surface_decorator_registers_handler(server_fixture: Server) -> None: agent_slug = server_fixture.agents[0].slug headers = _authorized_a2a_headers( diff --git a/tests/test_agent.py b/tests/test_agent.py index f27c41e..318752e 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -19,7 +19,16 @@ import pytest from pydantic import BaseModel, ValidationError -from supervaizer import Agent, AgentMethod, AgentMethods, ApiSuccess, Server +from supervaizer import ( + AGENT_REFRESH_ACTION, + Agent, + AgentMethod, + AgentMethods, + ApiSuccess, + Server, + V2AgentMethod, + V2AgentMethods, +) from supervaizer.agent import AgentMethodField, AgentMethodsAbstract, FieldTypeEnum from supervaizer.job import Job, JobContext, JobResponse from supervaizer.lifecycle import EntityStatus @@ -1237,6 +1246,39 @@ def test_agent_accepts_v2_registration_with_matching_slug() -> None: assert agent.supervaizer_v2_registration.agent.slug == "agent-name" +def test_agent_v2_methods_are_added_to_v2_capabilities() -> None: + agent = Agent( + name="Agent Name", + version="1.0.0", + supervaizer_v2_registration={ + "agent": { + "id": "agent_name", + "slug": "agent-name", + "display_name": "Agent Name", + }, + "versions": { + "a2ui_version": "v0.8", + "a2ui_catalog_version": "test.0", + "a2a_version": "0.2.6", + }, + "a2a": { + "agent_card_url": "/.well-known/agents/v1.0.0/agent-name_agent.json", + "controller_url": "/a2a", + }, + "capabilities": {"actions": ["job.sync"]}, + }, + v2_methods=V2AgentMethods( + refresh=V2AgentMethod(method="tests.test_agent.refresh_agent") + ), + ) + + assert agent.supervaizer_v2_registration is not None + assert agent.supervaizer_v2_registration.capabilities.actions == [ + "job.sync", + AGENT_REFRESH_ACTION, + ] + + def test_agent_rejects_v2_registration_with_mismatched_slug() -> None: """Agent rejects a v2 registration whose declared slug differs from runtime slug.""" with pytest.raises( diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 79f38ee..5cbe2d2 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -17,6 +17,7 @@ from pydantic import ValidationError from supervaizer.contracts import ( + AGENT_REFRESH_ACTION, API_VERSION, AgentMethodsContract, ControllerEndpoint, @@ -27,6 +28,8 @@ V2A2UIResourceImportDocument, V2ActionRequest, V2ActionResult, + V2AgentMethod, + V2AgentMethods, V2AwaitingState, V2CaseSnapshot, V2DashboardWidgetDataRef, @@ -262,7 +265,7 @@ def test_v2_agent_interviewer_registration_fixture() -> None: assert ( "mission.agent.surface.scenario_builder" in registration.capabilities.surfaces ) - assert "campaigns.sync" in registration.capabilities.actions + assert AGENT_REFRESH_ACTION in registration.capabilities.actions assert "resource.campaign_contacts.create" in registration.capabilities.actions assert "resource.campaign_contacts.delete" in registration.capabilities.actions assert "resource.contacts.import" in registration.capabilities.actions @@ -454,6 +457,30 @@ def test_build_v2_agent_registration_derives_capabilities() -> None: assert registration.capabilities.case_lanes[0].default is True +def test_build_v2_agent_registration_derives_agent_method_actions() -> None: + registration = build_v2_agent_registration( + agent_id="hello", + agent_slug="hello-world", + display_name="Hello World", + agent_card_url="/.well-known/agents/v1/hello-world_agent.json", + controller_url="/a2a", + a2ui_catalog_version="supervaizer-v2-local.0", + agent_methods=V2AgentMethods( + refresh=V2AgentMethod(method="hello_agent.refresh"), + custom={ + "reindex": V2AgentMethod(method="hello_agent.reindex"), + "dry-run": V2AgentMethod(method="hello_agent.dry_run"), + }, + ), + ) + + assert registration.capabilities.actions == [ + AGENT_REFRESH_ACTION, + "agent.custom.reindex", + "agent.custom.dry-run", + ] + + def test_v2_workspace_binding_required_requires_mode() -> None: with pytest.raises(ValidationError, match="at least one mode"): V2WorkspaceBindingDefinition(required=True) @@ -671,6 +698,8 @@ def test_v2_contract_models_are_public_sdk_exports() -> None: assert supervaizer.V2A2ATransport.__name__ == "V2A2ATransport" assert supervaizer.V2A2UIResourceImportDocument is V2A2UIResourceImportDocument assert supervaizer.V2ActionRequest is V2ActionRequest + assert supervaizer.V2AgentMethod is V2AgentMethod + assert supervaizer.V2AgentMethods is V2AgentMethods assert supervaizer.V2DashboardDefinition.__name__ == "V2DashboardDefinition" assert supervaizer.V2DashboardWidgetDataRef is V2DashboardWidgetDataRef assert supervaizer.V2DashboardWidgetDefinition is V2DashboardWidgetDefinition diff --git a/tests/test_server.py b/tests/test_server.py index 6185ce2..4a0f4d8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -10,6 +10,7 @@ # If a copy of the MPL was not distributed with this file, you can obtain one at # https://mozilla.org/MPL/2.0/. +import asyncio import base64 import json import os @@ -24,6 +25,7 @@ from fastapi.testclient import TestClient from rich import inspect +import supervaizer.server as server_module from supervaizer import Server from supervaizer.__version__ import VERSION from supervaizer.agent import Agent @@ -371,6 +373,144 @@ def test_server_generated_api_key_is_exported_for_reload( assert os.environ["SUPERVAIZER_API_KEY"] == server.api_key +@pytest.mark.asyncio +async def test_server_lifespan_cancels_scheduled_step_task( + agent_fixture: Agent, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SUPERVAIZER_LOCAL_MODE", "false") + created_task_names: list[str | None] = [] + waited_tasks: list[set[object]] = [] + waited_timeouts: list[float | None] = [] + + class FakeScheduledStepTask: + def __init__(self) -> None: + self.cancelled = False + self.awaited = False + + def cancel(self) -> None: + self.cancelled = True + + def __await__(self) -> Any: + async def _cancelled() -> None: + self.awaited = True + raise asyncio.CancelledError + + return _cancelled().__await__() + + scheduled_step_task = FakeScheduledStepTask() + + def fake_create_task( + coro: object, *, name: str | None = None, **_kwargs: Any + ) -> FakeScheduledStepTask: + if hasattr(coro, "close"): + coro.close() + created_task_names.append(name) + return scheduled_step_task + + async def fake_wait( + tasks: set[object], *, timeout: float | None = None + ) -> tuple[set[object], set[object]]: + waited_tasks.append(set(tasks)) + waited_timeouts.append(timeout) + return set(tasks), set() + + monkeypatch.setattr( + Server.__init__.__globals__["asyncio"], + "create_task", + fake_create_task, + ) + monkeypatch.setattr( + Server.__init__.__globals__["asyncio"], + "wait", + fake_wait, + ) + + server = Server( + agents=[agent_fixture], + supervisor_account=None, + admin_interface=False, + host="localhost", + port=8001, + environment="test", + api_key="test-key", + ) + + async with server.app.router.lifespan_context(server.app): + assert created_task_names == ["supervaizer-scheduled-step-loop"] + assert scheduled_step_task.cancelled is False + + assert scheduled_step_task.cancelled is True + assert scheduled_step_task.awaited is True + assert waited_tasks == [{scheduled_step_task}] + assert waited_timeouts == [server_module.SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS] + + +@pytest.mark.asyncio +async def test_server_lifespan_leaves_shutdown_after_scheduler_timeout( + agent_fixture: Agent, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SUPERVAIZER_LOCAL_MODE", "false") + + class PendingScheduledStepTask: + def __init__(self) -> None: + self.cancelled = False + self.awaited = False + + def cancel(self) -> None: + self.cancelled = True + + def __await__(self) -> Any: + async def _unexpected() -> None: + self.awaited = True + raise AssertionError("pending scheduler task should not be awaited") + + return _unexpected().__await__() + + pending_task = PendingScheduledStepTask() + + def fake_create_task( + coro: object, *, name: str | None = None, **_kwargs: Any + ) -> PendingScheduledStepTask: + if hasattr(coro, "close"): + coro.close() + return pending_task + + async def fake_wait( + tasks: set[object], *, timeout: float | None = None + ) -> tuple[set[object], set[object]]: + assert timeout == server_module.SCHEDULED_STEP_SHUTDOWN_TIMEOUT_SECONDS + return set(), set(tasks) + + monkeypatch.setattr( + Server.__init__.__globals__["asyncio"], + "create_task", + fake_create_task, + ) + monkeypatch.setattr( + Server.__init__.__globals__["asyncio"], + "wait", + fake_wait, + ) + + server = Server( + agents=[agent_fixture], + supervisor_account=None, + admin_interface=False, + host="localhost", + port=8001, + environment="test", + api_key="test-key", + ) + + async with server.app.router.lifespan_context(server.app): + assert pending_task.cancelled is False + + assert pending_task.cancelled is True + assert pending_task.awaited is False + + def test_server_decrypt(server_fixture: Server) -> None: unencrypted_parameters = str({"KEY": "VALUE"}) encrypted_parameters = server_fixture.encrypt(unencrypted_parameters) diff --git a/tests/test_server_refactor_modules.py b/tests/test_server_refactor_modules.py new file mode 100644 index 0000000..288db8b --- /dev/null +++ b/tests/test_server_refactor_modules.py @@ -0,0 +1,143 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +from typing import Any + +import pytest + +import supervaizer.scheduled_steps as scheduled_steps +import supervaizer.server as server_module +import supervaizer.server_config as server_config +import supervaizer.server_info as server_info +from supervaizer import Server +from supervaizer.common import ApiSuccess +from supervaizer.server_registration import build_server_registration_info +from supervaizer.studio_handshake import validate_registration_handshake + + +def test_server_module_reexports_scheduled_step_helpers() -> None: + assert ( + server_module._execute_scheduled_method + is scheduled_steps._execute_scheduled_method + ) + assert ( + server_module._run_scheduled_step_loop + is scheduled_steps._run_scheduled_step_loop + ) + + +def test_server_module_reexports_config_helpers() -> None: + assert server_module._env_bool is server_config._env_bool + assert ( + server_module._controller_key_fingerprint + is server_config._controller_key_fingerprint + ) + + +def test_server_module_reexports_server_info_helpers() -> None: + assert server_module.ServerInfo is server_info.ServerInfo + assert ( + server_module.get_server_info_from_storage + is server_info.get_server_info_from_storage + ) + assert ( + server_module.get_server_info_from_live is server_info.get_server_info_from_live + ) + + +def test_execute_scheduled_method_calls_dotted_function() -> None: + result = scheduled_steps._execute_scheduled_method( + "tests.test_server_refactor_modules._scheduled_step_target", + {"value": "ok"}, + ) + + assert result == "scheduled-ok" + + +def test_resolve_workspace_authorization_settings_reads_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_REQUIRED", "yes") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_ISSUER", "https://studio.test") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_AUDIENCE", "server-audience") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_JWKS_URL", "https://jwks.test") + monkeypatch.setenv("SUPERVAIZER_WORKSPACE_AUTH_LEEWAY_SECONDS", "9") + + settings = server_config._resolve_workspace_authorization_settings(None) + + assert settings.enabled is True + assert settings.issuer == "https://studio.test" + assert settings.audience == "server-audience" + assert settings.jwks_url == "https://jwks.test" + assert settings.leeway_seconds == 9 + + +def test_get_server_info_from_live_uses_server_start_time( + server_fixture: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SUPERVAIZER_ENVIRONMENT", "module-test") + object.__setattr__(server_fixture, "_start_time", 123.0) + + info = server_info.get_server_info_from_live(server_fixture) + + assert info.start_time == 123.0 + assert info.environment == "module-test" + assert info.agents == [ + { + "name": server_fixture.agents[0].name, + "description": server_fixture.agents[0].description, + "version": server_fixture.agents[0].version, + "api_path": server_fixture.agents[0].path, + "slug": server_fixture.agents[0].slug, + "instructions_path": server_fixture.agents[0].instructions_path, + } + ] + + +def test_server_info_storage_round_trip( + server_fixture: Server, + storage_manager: Any, +) -> None: + storage_manager.reset_storage() + + server_info.save_server_info_to_storage(server_fixture) + stored_info = server_info.get_server_info_from_storage() + + assert stored_info is not None + assert stored_info.id == server_info.SERVER_INFO_ID + assert stored_info.host == server_fixture.host + + +def test_registration_builder_matches_server_property(server_fixture: Server) -> None: + assert ( + build_server_registration_info(server_fixture) + == server_fixture.registration_info + ) + + +def test_validate_registration_handshake_function_accepts_key_match( + server_fixture: Server, +) -> None: + result = ApiSuccess( + message="POST Event SERVER_REGISTER sent", + detail={ + "object": { + "supervaizer_handshake": { + "server_id": "server-1", + "controller_api_key_match": True, + } + } + }, + ) + + validate_registration_handshake(server_fixture, result) + + +def _scheduled_step_target(value: str) -> str: + return f"scheduled-{value}" diff --git a/uv.lock b/uv.lock index dfb7726..cd5f062 100644 --- a/uv.lock +++ b/uv.lock @@ -157,30 +157,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.6" +version = "1.43.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/4b/616367e871ce3f1cb3e8545a97736b6331b9fb081497f2d44c5b2aa6959d/boto3-1.43.14.tar.gz", hash = "sha256:5c0a994b3182061ee101812e721100717a4d664f9f4ceaf4a86b6d032ce9fc2d", size = 113142, upload-time = "2026-05-22T19:28:47.861Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" }, + { url = "https://files.pythonhosted.org/packages/cb/00/59cb9329c18e2d3aa23062ceaa87d065f2e81e7d2931df24d64e9a7815aa/boto3-1.43.14-py3-none-any.whl", hash = "sha256:574335744656cfed0b362a0a0467aaf2eb2bf15526edcd02d31d3c661f4b09e4", size = 140536, upload-time = "2026-05-22T19:28:46.49Z" }, ] [[package]] name = "botocore" -version = "1.43.6" +version = "1.43.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/3c/798d2f7deb118241930c7c6bcfb0b970d3f0245bf580700663199aeed2c3/botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516", size = 15382604, upload-time = "2026-05-22T19:28:36.363Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, + { url = "https://files.pythonhosted.org/packages/27/7e/6e64821077cd2efc4aa51b7d638fb6d48e1c7c450201c529fbaf1de8bfd3/botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658", size = 15061424, upload-time = "2026-05-22T19:28:32.682Z" }, ] [[package]] @@ -500,7 +500,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.1" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -509,9 +509,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] @@ -1770,27 +1770,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -1856,15 +1856,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.4.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/13/3cafb96bceb02949f265bbdf1cbcea2810271ae709e4aa35e980f90c07fd/sse_starlette-3.4.3.tar.gz", hash = "sha256:a7f6d87cf482cf38b911c31075811c7f8b4efbada8ac9d5199a8e239fed513c9", size = 35247, upload-time = "2026-05-11T17:23:41.987Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/a4/c888212b19dd432110d4a78dbc5e6c1bc7476e5fff2f2df2ea9f298b0003/sse_starlette-3.4.3-py3-none-any.whl", hash = "sha256:bf8a90d76192062f01b55095593606bfc7edd0e3ad481339a6e16e7890bc9367", size = 16514, upload-time = "2026-05-11T17:23:40.352Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] [[package]] @@ -1937,14 +1937,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "art", specifier = ">=6.5" }, - { name = "boto3", marker = "extra == 'deploy'", specifier = ">=1.43.6" }, + { name = "boto3", marker = "extra == 'deploy'", specifier = ">=1.43.14" }, { name = "boto3", marker = "extra == 'dev'", specifier = ">=1.42.73" }, { name = "cryptography", specifier = ">=48.0.0" }, { name = "demjson3", specifier = ">=3.0.0" }, { name = "deprecated", specifier = ">=1.3.0" }, { name = "docker", marker = "extra == 'deploy'", specifier = ">=7.0.0" }, { name = "docker", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "fastapi", specifier = ">=0.136.1" }, + { name = "fastapi", specifier = ">=0.136.3" }, { name = "google-cloud-artifact-registry", marker = "extra == 'deploy'", specifier = ">=1.21.0" }, { name = "google-cloud-run", marker = "extra == 'deploy'", specifier = ">=0.16.0" }, { name = "google-cloud-secret-manager", marker = "extra == 'deploy'", specifier = ">=2.28.0" }, @@ -1968,15 +1968,15 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.23.1" }, { name = "rich", specifier = ">=15.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.12" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.14" }, { name = "shortuuid", specifier = ">=1.0.0" }, - { name = "sse-starlette", specifier = ">=3.4.3" }, + { name = "sse-starlette", specifier = ">=3.4.4" }, { name = "tinydb", specifier = ">=4.8.2" }, { name = "typer", specifier = ">=0.25.1" }, { name = "types-deprecated", marker = "extra == 'dev'", specifier = ">=1.3.1" }, { name = "types-python-slugify", marker = "extra == 'dev'", specifier = ">=8.0.2.20240310" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, - { name = "uvicorn", specifier = ">=0.46.0" }, + { name = "uvicorn", specifier = ">=0.48.0" }, { name = "yamllint", marker = "extra == 'dev'", specifier = ">=1.38.0" }, ] provides-extras = ["deploy", "dev"] @@ -2147,15 +2147,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.46.0" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, ] [[package]]