From b5ca1daef021da81b3f800689038e5a675bf0711 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:03:16 +0300 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20feat(data-resource):=20add=20?= =?UTF-8?q?DataResource=20and=20DataResourceField=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/data_resource.py | 132 +++++++++++++++++++++++++++++++ tests/test_data_resource.py | 109 +++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/supervaizer/data_resource.py create mode 100644 tests/test_data_resource.py diff --git a/src/supervaizer/data_resource.py b/src/supervaizer/data_resource.py new file mode 100644 index 0000000..fa842bb --- /dev/null +++ b/src/supervaizer/data_resource.py @@ -0,0 +1,132 @@ +# Copyright (c) 2024-2025 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/. + +"""Data Resource model for exposing agent-owned CRUD endpoints to Studio. + +Agents declare DataResource objects on their Agent instance. The SDK +auto-generates FastAPI CRUD routes for each declared resource, secured +with the same API key as all other agent routes. +""" +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Callable + +from pydantic import Field, computed_field, model_validator + +from supervaizer.common import SvBaseModel + + +class Editable(StrEnum): + """Controls when Studio may edit a field.""" + + ALWAYS = "always" # Editable on create and update forms + CREATE_ONLY = "create_only" # Set on create; shown read-only on edit + NEVER = "never" # Agent-controlled; never shown in a form input + + +class DataResourceField(SvBaseModel): + """Describes a single field in a DataResource for Studio rendering.""" + + name: str = Field(description="Column/attribute name") + field_type: str = Field( + default="string", + description="One of: string, integer, boolean, date, datetime, text, email, url", + ) + label: str | None = Field(default=None, description="Human-readable label; defaults to name.title()") + required: bool = Field(default=False, description="Required on create form") + editable: Editable = Field(default=Editable.ALWAYS) + visible_on: list[str] = Field( + default_factory=lambda: ["list", "detail", "create", "edit"], + description="Views that render this field: list, detail, create, edit", + ) + description: str | None = Field(default=None, description="Help text shown in Studio") + related_resource: str | None = Field( + default=None, + description="Name of another DataResource this field FK-references", + ) + + @property + def display_label(self) -> str: + return self.label or self.name.replace("_", " ").title() + + +class DataResource(SvBaseModel): + """Declares a named data resource the agent exposes for Studio CRUD access. + + The agent provides callback functions for each operation. The SDK generates + the corresponding FastAPI routes automatically. + + Example:: + + contacts_resource = DataResource( + name="contacts", + display_name="Contacts", + fields=[ + DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), + DataResourceField(name="email", field_type="email", required=True), + ], + on_list=lambda: repo.list_all(), + on_get=lambda id: repo.get(id), + on_create=lambda data: repo.create(data), + on_update=lambda id, data: repo.update(id, data), + on_delete=lambda id: repo.delete(id), + ) + """ + + model_config = {"arbitrary_types_allowed": True} + + name: str = Field(description="URL-safe resource identifier, e.g. 'contacts'") + display_name: str = Field(default="") + description: str = Field(default="") + fields: list[DataResourceField] = Field(default_factory=list) + read_only: bool = Field(default=False) + importable: bool = Field(default=False, description="Enables CSV bulk import route") + # Callbacks — excluded from model serialization + on_list: Callable[[], list[dict[str, Any]]] | None = Field(default=None, exclude=True) + on_get: Callable[[str], dict[str, Any] | None] | None = Field(default=None, exclude=True) + on_create: Callable[[dict[str, Any]], dict[str, Any]] | None = Field(default=None, exclude=True) + on_update: Callable[[str, dict[str, Any]], dict[str, Any]] | None = Field(default=None, exclude=True) + on_delete: Callable[[str], bool] | None = Field(default=None, exclude=True) + on_import: Callable[[list[dict[str, Any]]], dict[str, Any]] | None = Field(default=None, exclude=True) + + @model_validator(mode="after") + def check_callbacks(self) -> "DataResource": + if self.on_list is None: + raise ValueError(f"DataResource '{self.name}' must define on_list") + if not self.read_only and self.on_create is None: + raise ValueError(f"Writable DataResource '{self.name}' must define on_create") + if self.importable and self.on_import is None: + raise ValueError(f"Importable DataResource '{self.name}' must define on_import") + return self + + @computed_field + @property + def operations(self) -> dict[str, bool]: + return { + "list": self.on_list is not None, + "get": self.on_get is not None, + "create": self.on_create is not None and not self.read_only, + "update": self.on_update is not None and not self.read_only, + "delete": self.on_delete is not None and not self.read_only, + "import": self.importable and self.on_import is not None, + } + + @property + def display_name_resolved(self) -> str: + return self.display_name or self.name.replace("_", " ").title() + + @property + def registration_info(self) -> dict[str, Any]: + return { + "name": self.name, + "display_name": self.display_name_resolved, + "description": self.description, + "fields": [f.model_dump() for f in self.fields], + "read_only": self.read_only, + "importable": self.importable, + "operations": self.operations, + } diff --git a/tests/test_data_resource.py b/tests/test_data_resource.py new file mode 100644 index 0000000..34626a9 --- /dev/null +++ b/tests/test_data_resource.py @@ -0,0 +1,109 @@ +# Copyright (c) 2024-2025 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/. + +import pytest +from supervaizer.data_resource import DataResource, DataResourceField, Editable + + +def test_field_defaults(): + f = DataResourceField(name="email") + assert f.field_type == "string" + assert f.editable == Editable.ALWAYS + assert f.visible_on == ["list", "detail", "create", "edit"] + assert f.required is False + + +def test_field_display_label_defaults_to_name_title(): + f = DataResourceField(name="first_name") + assert f.display_label == "First Name" + + +def test_field_display_label_custom(): + f = DataResourceField(name="first_name", label="Given Name") + assert f.display_label == "Given Name" + + +def test_data_resource_requires_on_list(): + with pytest.raises(ValueError, match="must define on_list"): + DataResource(name="contacts", fields=[]) + + +def test_data_resource_writable_requires_on_create(): + with pytest.raises(ValueError, match="must define on_create"): + DataResource(name="contacts", fields=[], on_list=lambda: [], read_only=False) + + +def test_data_resource_read_only_no_on_create_required(): + dr = DataResource(name="prompts", fields=[], on_list=lambda: [], read_only=True) + assert dr.operations["create"] is False + assert dr.operations["update"] is False + assert dr.operations["delete"] is False + assert dr.operations["list"] is True + + +def test_data_resource_importable_requires_on_import(): + with pytest.raises(ValueError, match="must define on_import"): + DataResource( + name="contacts", + fields=[], + on_list=lambda: [], + on_create=lambda d: {**d, "id": "1"}, + importable=True, + ) + + +def test_data_resource_operations_full(): + dr = DataResource( + name="contacts", + fields=[], + on_list=lambda: [], + on_get=lambda id: {"id": id}, + on_create=lambda d: {**d, "id": "new"}, + on_update=lambda id, d: {**d, "id": id}, + on_delete=lambda id: True, + ) + assert dr.operations == { + "list": True, + "get": True, + "create": True, + "update": True, + "delete": True, + "import": False, + } + + +def test_data_resource_registration_info(): + dr = DataResource( + name="contacts", + display_name="Contacts", + fields=[ + DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), + DataResourceField(name="email", field_type="email", required=True), + ], + on_list=lambda: [], + on_create=lambda d: {**d, "id": "1"}, + ) + info = dr.registration_info + assert info["name"] == "contacts" + assert info["display_name"] == "Contacts" + assert len(info["fields"]) == 2 + assert info["fields"][0]["name"] == "id" + assert info["fields"][0]["editable"] == "never" + assert info["fields"][1]["required"] is True + assert info["operations"]["create"] is True + assert info["operations"]["import"] is False + + +def test_data_resource_display_name_derived_from_name(): + dr = DataResource(name="contact_knowledge", fields=[], on_list=lambda: [], read_only=True) + assert dr.display_name_resolved == "Contact Knowledge" + + +def test_data_resource_callbacks_excluded_from_serialization(): + dr = DataResource(name="prompts", fields=[], on_list=lambda: [], read_only=True) + dumped = dr.model_dump() + assert "on_list" not in dumped + assert "on_create" not in dumped From 27875aee58d5d8e757cc65eea4eeb41c52533653 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:05:56 +0300 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=A8=20feat(job,case):=20add=20metad?= =?UTF-8?q?ata=20field=20to=20AbstractJob=20and=20CaseAbstractModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/case.py | 6 +++++- src/supervaizer/job.py | 6 +++++- tests/test_case.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/test_job.py | 25 +++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/supervaizer/case.py b/src/supervaizer/case.py index ae3feb2..34fec04 100644 --- a/src/supervaizer/case.py +++ b/src/supervaizer/case.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional import shortuuid -from pydantic import ConfigDict +from pydantic import ConfigDict, Field from supervaizer.common import SvBaseModel, log, singleton from supervaizer.lifecycle import EntityEvents, EntityStatus from supervaizer.storage import PersistentEntityLifecycle, StorageManager @@ -215,6 +215,7 @@ class CaseAbstractModel(SvBaseModel): total_cost: float = 0.0 final_delivery: Optional[Dict[str, Any]] = None finished_at: Optional[datetime] = None + metadata: Dict[str, Any] = Field(default_factory=dict, description="Agent-provided domain metadata (e.g. contact context)") class Case(CaseAbstractModel): @@ -361,6 +362,7 @@ def registration_info(self) -> Dict[str, Any]: "updates": [update.registration_info for update in self.updates], "total_cost": self.total_cost, "final_delivery": self.final_delivery, + "metadata": self.metadata, } @classmethod @@ -371,6 +373,7 @@ def start( account: "Account", description: str, case_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> "Case": """ Start a new case @@ -393,6 +396,7 @@ def start( name=name, description=description, status=EntityStatus.STOPPED, + metadata=metadata or {}, ) log.info(f"[Case created] {case.id}") diff --git a/src/supervaizer/job.py b/src/supervaizer/job.py index 573d23e..c2cc838 100644 --- a/src/supervaizer/job.py +++ b/src/supervaizer/job.py @@ -10,7 +10,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional -from pydantic import field_validator +from pydantic import Field, field_validator from supervaizer.__version__ import VERSION from supervaizer.common import SvBaseModel, log, singleton @@ -256,6 +256,7 @@ class AbstractJob(SvBaseModel): created_at: datetime | None = None agent_parameters: list[dict[str, Any]] | None = None case_ids: list[str] = [] # Foreign key relationship to cases + metadata: dict[str, Any] = Field(default_factory=dict, description="Agent-provided domain metadata (e.g. campaign context)") class Job(AbstractJob): @@ -349,6 +350,7 @@ def registration_info(self) -> Dict[str, Any]: "finished_at": self.finished_at.isoformat() if self.finished_at else "", "created_at": self.created_at.isoformat() if self.created_at else "", "case_ids": self.case_ids, + "metadata": self.metadata, } @classmethod @@ -358,6 +360,7 @@ def new( agent_name: str, agent_parameters: Optional[list[dict[str, Any]]] = None, name: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, ) -> "Job": """Create a new job @@ -394,6 +397,7 @@ def new( job_context=job_context, status=EntityStatus.STOPPED, agent_parameters=agent_parameters, + metadata=metadata or {}, ) # Transition from STOPPED to IN_PROGRESS diff --git a/tests/test_case.py b/tests/test_case.py index bf61450..93c52dc 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -392,3 +392,42 @@ def test_case_patch_step_no_index_match_still_sends_but_keeps_registry_unchanged assert case_fixture.updates[0] is prior assert orphan.index == 99 assert orphan.upsert is True + + +def test_case_metadata_default_is_empty_dict(case_fixture: Case) -> None: + """CaseAbstractModel.metadata defaults to empty dict.""" + assert case_fixture.metadata == {} + + +def test_case_registration_info_includes_metadata(account_fixture: Account) -> None: + """Case.registration_info includes the metadata field.""" + from uuid import uuid4 + + case = Case( + id=str(uuid4()), + job_id="job-meta-test", + account=account_fixture, + status=EntityStatus.IN_PROGRESS, + name="Meta Test Case", + description="Test", + metadata={"contact_email": "a@b.com", "language": "en"}, + ) + info = case.registration_info + assert info["metadata"] == {"contact_email": "a@b.com", "language": "en"} + + +def test_case_start_accepts_metadata( + account_fixture: Account, + mocker: MockerFixture, +) -> None: + """Case.start() passes metadata through to the Case instance.""" + mocker.patch("supervaizer.account_service.send_event", return_value=None) + meta = {"contact_email": "test@example.com", "language": "fr"} + case = Case.start( + job_id="job-start-meta", + name="Test Contact", + account=account_fixture, + description="Test interview", + metadata=meta, + ) + assert case.metadata == meta diff --git a/tests/test_job.py b/tests/test_job.py index a6620d5..f51f8c9 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -219,3 +219,28 @@ def test_get_job_include_persisted_not_found() -> None: mock_get.return_value = None # Simulate job not found in persistence result = Jobs().get_job(job_id, include_persisted=True) assert result is None + + +def test_job_metadata_default_is_empty_dict(context_fixture: JobContext) -> None: + """AbstractJob.metadata defaults to empty dict.""" + job = Job.new(job_context=context_fixture, agent_name="test-agent") + assert job.metadata == {} + + +def test_job_registration_info_includes_metadata(context_fixture: JobContext) -> None: + """Job.registration_info includes the metadata field.""" + meta = {"campaign_id": "c1", "phase": "interviews"} + job = Job.new(job_context=context_fixture, agent_name="test-agent", metadata=meta) + info = job.registration_info + assert info["metadata"] == {"campaign_id": "c1", "phase": "interviews"} + + +def test_job_new_accepts_metadata(context_fixture: JobContext) -> None: + """Job.new() passes metadata through to the Job instance.""" + meta = {"campaign_id": "c1", "scenario": "hr_screening"} + job = Job.new( + job_context=context_fixture, + agent_name="test-agent", + metadata=meta, + ) + assert job.metadata == meta From 3b06fa045016c305a38aad1c7f647b3da8532bec Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:34:35 +0300 Subject: [PATCH 03/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(data-resour?= =?UTF-8?q?ce):=20add=20FieldType=20enum,=20fix=20computed=5Ffield,=20clar?= =?UTF-8?q?ify=20optional=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/__init__.py | 5 +++++ src/supervaizer/data_resource.py | 38 +++++++++++++++++++++++++------- tests/test_data_resource.py | 12 +++++----- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 7244bfa..eaf2e47 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -26,6 +26,7 @@ CaseNodes, ) from supervaizer.common import ApiError, ApiResult, ApiSuccess +from supervaizer.data_resource import DataResource, DataResourceField, Editable, FieldType from supervaizer.event import ( AgentRegisterEvent, CaseStartEvent, @@ -69,6 +70,10 @@ "CaseStartEvent", "CaseUpdateEvent", "create_error_response", + "DataResource", + "DataResourceField", + "Editable", + "FieldType", "EntityEvents", "EntityEvents", "EntityLifecycle", diff --git a/src/supervaizer/data_resource.py b/src/supervaizer/data_resource.py index fa842bb..f119543 100644 --- a/src/supervaizer/data_resource.py +++ b/src/supervaizer/data_resource.py @@ -15,11 +15,24 @@ from enum import StrEnum from typing import Any, Callable -from pydantic import Field, computed_field, model_validator +from pydantic import Field, model_validator from supervaizer.common import SvBaseModel +class FieldType(StrEnum): + """Allowed field types for DataResourceField.""" + + STRING = "string" + INTEGER = "integer" + BOOLEAN = "boolean" + DATE = "date" + DATETIME = "datetime" + TEXT = "text" + EMAIL = "email" + URL = "url" + + class Editable(StrEnum): """Controls when Studio may edit a field.""" @@ -32,8 +45,8 @@ class DataResourceField(SvBaseModel): """Describes a single field in a DataResource for Studio rendering.""" name: str = Field(description="Column/attribute name") - field_type: str = Field( - default="string", + field_type: FieldType = Field( + default=FieldType.STRING, description="One of: string, integer, boolean, date, datetime, text, email, url", ) label: str | None = Field(default=None, description="Human-readable label; defaults to name.title()") @@ -67,13 +80,13 @@ class DataResource(SvBaseModel): display_name="Contacts", fields=[ DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), - DataResourceField(name="email", field_type="email", required=True), + DataResourceField(name="email", field_type=FieldType.EMAIL, required=True), ], on_list=lambda: repo.list_all(), - on_get=lambda id: repo.get(id), + on_get=lambda item_id: repo.get(item_id), on_create=lambda data: repo.create(data), - on_update=lambda id, data: repo.update(id, data), - on_delete=lambda id: repo.delete(id), + on_update=lambda item_id, data: repo.update(item_id, data), + on_delete=lambda item_id: repo.delete(item_id), ) """ @@ -95,6 +108,16 @@ class DataResource(SvBaseModel): @model_validator(mode="after") def check_callbacks(self) -> "DataResource": + """Validate required callbacks. + + on_list is always required. + on_create is required for writable resources (read_only=False). + on_import is required when importable=True. + + on_get, on_update, and on_delete are optional — their presence is + reflected in the operations dict. A writable resource may support + create-only (no update/delete). + """ if self.on_list is None: raise ValueError(f"DataResource '{self.name}' must define on_list") if not self.read_only and self.on_create is None: @@ -103,7 +126,6 @@ def check_callbacks(self) -> "DataResource": raise ValueError(f"Importable DataResource '{self.name}' must define on_import") return self - @computed_field @property def operations(self) -> dict[str, bool]: return { diff --git a/tests/test_data_resource.py b/tests/test_data_resource.py index 34626a9..7fe60b2 100644 --- a/tests/test_data_resource.py +++ b/tests/test_data_resource.py @@ -5,12 +5,12 @@ # https://mozilla.org/MPL/2.0/. import pytest -from supervaizer.data_resource import DataResource, DataResourceField, Editable +from supervaizer.data_resource import DataResource, DataResourceField, Editable, FieldType def test_field_defaults(): f = DataResourceField(name="email") - assert f.field_type == "string" + assert f.field_type == FieldType.STRING assert f.editable == Editable.ALWAYS assert f.visible_on == ["list", "detail", "create", "edit"] assert f.required is False @@ -60,10 +60,10 @@ def test_data_resource_operations_full(): name="contacts", fields=[], on_list=lambda: [], - on_get=lambda id: {"id": id}, + on_get=lambda item_id: {"id": item_id}, on_create=lambda d: {**d, "id": "new"}, - on_update=lambda id, d: {**d, "id": id}, - on_delete=lambda id: True, + on_update=lambda item_id, d: {**d, "id": item_id}, + on_delete=lambda item_id: True, ) assert dr.operations == { "list": True, @@ -81,7 +81,7 @@ def test_data_resource_registration_info(): display_name="Contacts", fields=[ DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), - DataResourceField(name="email", field_type="email", required=True), + DataResourceField(name="email", field_type=FieldType.EMAIL, required=True), ], on_list=lambda: [], on_create=lambda d: {**d, "id": "1"}, From 11f1ead3b5e21d3b18f64d27564598ac605c1b7d Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:37:56 +0300 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8=20feat(agent):=20add=20data=5Fr?= =?UTF-8?q?esources=20field=20and=20registration=5Finfo=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/agent.py | 9 +++++++++ tests/test_agent.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 0e57231..1532361 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -30,6 +30,7 @@ from supervaizer.lifecycle import EntityStatus from supervaizer.parameter import ParametersSetup from supervaizer.case import CaseNodes +from supervaizer.data_resource import DataResource if TYPE_CHECKING: from supervaizer.server import Server @@ -630,6 +631,11 @@ class AgentAbstract(SvBaseModel): description="Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]. Context includes workspace_id, workspace_slug, mission_id from the dynamic_choices request body.", exclude=True, ) + data_resources: list[DataResource] = Field( + default_factory=list, + description="Data resources this agent exposes for Studio CRUD access", + exclude=True, + ) model_config = cast( ConfigDict, {"reference_group": "Core", "arbitrary_types_allowed": True} @@ -657,6 +663,7 @@ def __init__( max_execution_time: int = 60 * 60, # 1 hour (in seconds) custom_routes: Any | None = None, dynamic_choices_callback: Any | None = None, + data_resources: list["DataResource"] | None = None, **kwargs: Any, ) -> None: """ @@ -711,6 +718,7 @@ def __init__( max_execution_time=max_execution_time, custom_routes=custom_routes, dynamic_choices_callback=dynamic_choices_callback, + data_resources=data_resources or [], **kwargs, ) @@ -750,6 +758,7 @@ def registration_info(self) -> Dict[str, Any]: "server_encrypted_parameters": self.server_encrypted_parameters, "max_execution_time": self.max_execution_time, "instructions_path": self.instructions_path, + "data_resources": [r.registration_info for r in self.data_resources], } def update_agent_from_server(self, server: "Server") -> Optional["Agent"]: diff --git a/tests/test_agent.py b/tests/test_agent.py index e05035a..28799aa 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1089,3 +1089,44 @@ def test_agent_method_fields_definitions() -> None: assert defs[1]["description"] == "Enter your age" assert defs[1]["type"] == "int" assert defs[1]["choices"] is None + + +def test_agent_data_resources_default_empty() -> None: + """Agent.data_resources defaults to empty list.""" + agent = Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + ) + assert agent.data_resources == [] + + +def test_agent_registration_info_includes_data_resources() -> None: + """Agent.registration_info includes data_resources when declared.""" + from supervaizer.data_resource import DataResource, DataResourceField, Editable + + contacts_resource = DataResource( + name="contacts", + display_name="Contacts", + fields=[ + DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), + DataResourceField(name="email", field_type="email", required=True), + ], + on_list=lambda: [], + on_create=lambda d: {**d, "id": "1"}, + ) + agent = Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + data_resources=[contacts_resource], + ) + info = agent.registration_info + assert "data_resources" in info + assert len(info["data_resources"]) == 1 + assert info["data_resources"][0]["name"] == "contacts" + assert info["data_resources"][0]["operations"]["create"] is True From b90b0bf984cdc7f1c1e70f0fd9feb2769caf5d1d Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:39:31 +0300 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=A8=20feat(routes):=20add=20DataRes?= =?UTF-8?q?ource=20CRUD=20route=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/data_routes.py | 146 +++++++++++++++++++++++++++++++++ src/supervaizer/routes.py | 3 + 2 files changed, 149 insertions(+) create mode 100644 src/supervaizer/data_routes.py diff --git a/src/supervaizer/data_routes.py b/src/supervaizer/data_routes.py new file mode 100644 index 0000000..1c0a64e --- /dev/null +++ b/src/supervaizer/data_routes.py @@ -0,0 +1,146 @@ +# Copyright (c) 2024-2025 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/. + +"""FastAPI route generation for DataResource CRUD endpoints. + +For each DataResource declared on an Agent, generates routes under: + GET /agents/{slug}/data/{resource_name}/ — list + GET /agents/{slug}/data/{resource_name}/{item_id} — detail + POST /agents/{slug}/data/{resource_name}/ — create + PUT /agents/{slug}/data/{resource_name}/{item_id} — update + DELETE /agents/{slug}/data/{resource_name}/{item_id} — delete + POST /agents/{slug}/data/{resource_name}/import/ — CSV bulk import + +All routes require the server API key (same auth as existing agent routes). +Uses factory functions per resource to avoid Python closure-in-loop capture bugs. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from fastapi import APIRouter, Body, HTTPException, Query, Security +from fastapi.responses import JSONResponse + +from supervaizer.common import log +from supervaizer.data_resource import DataResource + +if TYPE_CHECKING: + from supervaizer.agent import Agent + from supervaizer.server import Server + + +def create_agent_data_routes(server: "Server", agent: "Agent") -> APIRouter: + """Generate CRUD REST routes for all DataResources declared on an agent.""" + router = APIRouter(prefix=agent.path, tags=["Data Resources"]) + for resource in agent.data_resources: + _add_resource_routes(router, resource, server) + return router + + +def _add_resource_routes(router: APIRouter, resource: DataResource, server: "Server") -> None: + """Register all declared operation routes for one DataResource. + + Each operation is added via router.add_api_route() with a locally-scoped + handler function to avoid the classic Python loop-closure variable capture bug. + """ + prefix = f"/data/{resource.name}" + + if resource.on_list is not None: + _r = resource + + async def _list( + skip: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1000), + ) -> list[dict[str, Any]]: + log.info(f"📥 GET {prefix}/ [DataResource list: {_r.name}]") + result = _r.on_list() # type: ignore[misc] + return result[skip: skip + limit] + + router.add_api_route( + f"{prefix}/", _list, methods=["GET"], + dependencies=[Security(server.verify_api_key)], + summary=f"List {resource.display_name_resolved}", + ) + + if resource.on_get is not None: + _r = resource + + async def _get(item_id: str) -> dict[str, Any]: + log.info(f"📥 GET {prefix}/{item_id} [DataResource get: {_r.name}]") + result = _r.on_get(item_id) # type: ignore[misc] + if result is None: + raise HTTPException(status_code=404, detail=f"{_r.name} '{item_id}' not found") + return result + + router.add_api_route( + f"{prefix}/{{item_id}}", _get, methods=["GET"], + dependencies=[Security(server.verify_api_key)], + summary=f"Get {resource.display_name_resolved}", + ) + + if resource.on_create is not None and not resource.read_only: + _r = resource + + async def _create(data: dict[str, Any] = Body(...)) -> JSONResponse: + log.info(f"📥 POST {prefix}/ [DataResource create: {_r.name}]") + result = _r.on_create(data) # type: ignore[misc] + if not isinstance(result, dict) or "id" not in result: + raise HTTPException( + status_code=500, + detail=f"on_create for '{_r.name}' must return a dict with 'id'", + ) + return JSONResponse(content=result, status_code=201) + + router.add_api_route( + f"{prefix}/", _create, methods=["POST"], + dependencies=[Security(server.verify_api_key)], + summary=f"Create {resource.display_name_resolved}", + ) + + if resource.on_update is not None and not resource.read_only: + _r = resource + + async def _update(item_id: str, data: dict[str, Any] = Body(...)) -> dict[str, Any]: + log.info(f"📥 PUT {prefix}/{item_id} [DataResource update: {_r.name}]") + result = _r.on_update(item_id, data) # type: ignore[misc] + if result is None: + raise HTTPException(status_code=404, detail=f"{_r.name} '{item_id}' not found") + return result + + router.add_api_route( + f"{prefix}/{{item_id}}", _update, methods=["PUT"], + dependencies=[Security(server.verify_api_key)], + summary=f"Update {resource.display_name_resolved}", + ) + + if resource.on_delete is not None and not resource.read_only: + _r = resource + + async def _delete(item_id: str) -> JSONResponse: + log.info(f"📥 DELETE {prefix}/{item_id} [DataResource delete: {_r.name}]") + success = _r.on_delete(item_id) # type: ignore[misc] + if not success: + raise HTTPException(status_code=404, detail=f"{_r.name} '{item_id}' not found") + return JSONResponse(content={"deleted": True}, status_code=200) + + router.add_api_route( + f"{prefix}/{{item_id}}", _delete, methods=["DELETE"], + dependencies=[Security(server.verify_api_key)], + summary=f"Delete {resource.display_name_resolved}", + ) + + if resource.importable and resource.on_import is not None: + _r = resource + + async def _import(records: list[dict[str, Any]] = Body(...)) -> dict[str, Any]: + log.info(f"📥 POST {prefix}/import/ [DataResource import: {_r.name}]") + return _r.on_import(records) # type: ignore[misc] + + router.add_api_route( + f"{prefix}/import/", _import, methods=["POST"], + dependencies=[Security(server.verify_api_key)], + summary=f"Import {resource.display_name_resolved} (bulk)", + ) diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index 684d27b..a175f96 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -35,6 +35,7 @@ AgentMethodParams, AgentResponse, ) +from supervaizer.data_routes import create_agent_data_routes from supervaizer.case import CaseNodeUpdate, Cases from supervaizer.common import SvBaseModel, log from supervaizer.job import Job, JobContext, JobResponse, Jobs @@ -387,6 +388,8 @@ def create_agents_routes(server: "Server") -> APIRouter: # Add custom method routes for each agent if agent.methods and agent.methods.custom: routers.include_router(create_agent_custom_routes(server, agent)) + if agent.data_resources: + routers.include_router(create_agent_data_routes(server, agent)) return routers From d804a63a12d3e5ffc45d54d804ebb80bada0eead Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:42:40 +0300 Subject: [PATCH 06/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(data-routes?= =?UTF-8?q?):=20use=20factory=20functions=20for=20safe=20closure=20capture?= =?UTF-8?q?=20and=20unique=20operationIds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/data_routes.py | 152 +++++++++++++++++++-------------- 1 file changed, 86 insertions(+), 66 deletions(-) diff --git a/src/supervaizer/data_routes.py b/src/supervaizer/data_routes.py index 1c0a64e..8db3d18 100644 --- a/src/supervaizer/data_routes.py +++ b/src/supervaizer/data_routes.py @@ -41,106 +41,126 @@ def create_agent_data_routes(server: "Server", agent: "Agent") -> APIRouter: def _add_resource_routes(router: APIRouter, resource: DataResource, server: "Server") -> None: - """Register all declared operation routes for one DataResource. - - Each operation is added via router.add_api_route() with a locally-scoped - handler function to avoid the classic Python loop-closure variable capture bug. - """ + """Register all declared operation routes for one DataResource.""" prefix = f"/data/{resource.name}" if resource.on_list is not None: - _r = resource - - async def _list( - skip: int = Query(default=0, ge=0), - limit: int = Query(default=100, ge=1, le=1000), - ) -> list[dict[str, Any]]: - log.info(f"📥 GET {prefix}/ [DataResource list: {_r.name}]") - result = _r.on_list() # type: ignore[misc] - return result[skip: skip + limit] - router.add_api_route( - f"{prefix}/", _list, methods=["GET"], + f"{prefix}/", + _make_list_handler(resource, prefix), + methods=["GET"], dependencies=[Security(server.verify_api_key)], summary=f"List {resource.display_name_resolved}", + operation_id=f"{resource.name}_list", ) if resource.on_get is not None: - _r = resource - - async def _get(item_id: str) -> dict[str, Any]: - log.info(f"📥 GET {prefix}/{item_id} [DataResource get: {_r.name}]") - result = _r.on_get(item_id) # type: ignore[misc] - if result is None: - raise HTTPException(status_code=404, detail=f"{_r.name} '{item_id}' not found") - return result - router.add_api_route( - f"{prefix}/{{item_id}}", _get, methods=["GET"], + f"{prefix}/{{item_id}}", + _make_get_handler(resource, prefix), + methods=["GET"], dependencies=[Security(server.verify_api_key)], summary=f"Get {resource.display_name_resolved}", + operation_id=f"{resource.name}_get", ) if resource.on_create is not None and not resource.read_only: - _r = resource - - async def _create(data: dict[str, Any] = Body(...)) -> JSONResponse: - log.info(f"📥 POST {prefix}/ [DataResource create: {_r.name}]") - result = _r.on_create(data) # type: ignore[misc] - if not isinstance(result, dict) or "id" not in result: - raise HTTPException( - status_code=500, - detail=f"on_create for '{_r.name}' must return a dict with 'id'", - ) - return JSONResponse(content=result, status_code=201) - router.add_api_route( - f"{prefix}/", _create, methods=["POST"], + f"{prefix}/", + _make_create_handler(resource, prefix), + methods=["POST"], dependencies=[Security(server.verify_api_key)], summary=f"Create {resource.display_name_resolved}", + operation_id=f"{resource.name}_create", ) if resource.on_update is not None and not resource.read_only: - _r = resource - - async def _update(item_id: str, data: dict[str, Any] = Body(...)) -> dict[str, Any]: - log.info(f"📥 PUT {prefix}/{item_id} [DataResource update: {_r.name}]") - result = _r.on_update(item_id, data) # type: ignore[misc] - if result is None: - raise HTTPException(status_code=404, detail=f"{_r.name} '{item_id}' not found") - return result - router.add_api_route( - f"{prefix}/{{item_id}}", _update, methods=["PUT"], + f"{prefix}/{{item_id}}", + _make_update_handler(resource, prefix), + methods=["PUT"], dependencies=[Security(server.verify_api_key)], summary=f"Update {resource.display_name_resolved}", + operation_id=f"{resource.name}_update", ) if resource.on_delete is not None and not resource.read_only: - _r = resource - - async def _delete(item_id: str) -> JSONResponse: - log.info(f"📥 DELETE {prefix}/{item_id} [DataResource delete: {_r.name}]") - success = _r.on_delete(item_id) # type: ignore[misc] - if not success: - raise HTTPException(status_code=404, detail=f"{_r.name} '{item_id}' not found") - return JSONResponse(content={"deleted": True}, status_code=200) - router.add_api_route( - f"{prefix}/{{item_id}}", _delete, methods=["DELETE"], + f"{prefix}/{{item_id}}", + _make_delete_handler(resource, prefix), + methods=["DELETE"], dependencies=[Security(server.verify_api_key)], summary=f"Delete {resource.display_name_resolved}", + operation_id=f"{resource.name}_delete", ) if resource.importable and resource.on_import is not None: - _r = resource - - async def _import(records: list[dict[str, Any]] = Body(...)) -> dict[str, Any]: - log.info(f"📥 POST {prefix}/import/ [DataResource import: {_r.name}]") - return _r.on_import(records) # type: ignore[misc] - router.add_api_route( - f"{prefix}/import/", _import, methods=["POST"], + f"{prefix}/import/", + _make_import_handler(resource, prefix), + methods=["POST"], dependencies=[Security(server.verify_api_key)], summary=f"Import {resource.display_name_resolved} (bulk)", + operation_id=f"{resource.name}_import", ) + + +def _make_list_handler(r: DataResource, prefix: str) -> Any: + async def _handler( + skip: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1000), + ) -> list[dict[str, Any]]: + log.info(f"📥 GET {prefix}/ [DataResource list: {r.name}]") + result = r.on_list() # type: ignore[misc] + return result[skip: skip + limit] + return _handler + + +def _make_get_handler(r: DataResource, prefix: str) -> Any: + async def _handler(item_id: str) -> dict[str, Any]: + log.info(f"📥 GET {prefix}/{item_id} [DataResource get: {r.name}]") + result = r.on_get(item_id) # type: ignore[misc] + if result is None: + raise HTTPException(status_code=404, detail=f"{r.name} '{item_id}' not found") + return result + return _handler + + +def _make_create_handler(r: DataResource, prefix: str) -> Any: + async def _handler(data: dict[str, Any] = Body(...)) -> JSONResponse: + log.info(f"📥 POST {prefix}/ [DataResource create: {r.name}]") + result = r.on_create(data) # type: ignore[misc] + if not isinstance(result, dict) or "id" not in result: + raise HTTPException( + status_code=500, + detail=f"on_create for '{r.name}' must return a dict with 'id'", + ) + return JSONResponse(content=result, status_code=201) + return _handler + + +def _make_update_handler(r: DataResource, prefix: str) -> Any: + async def _handler(item_id: str, data: dict[str, Any] = Body(...)) -> dict[str, Any]: + log.info(f"📥 PUT {prefix}/{item_id} [DataResource update: {r.name}]") + result = r.on_update(item_id, data) # type: ignore[misc] + if result is None: + raise HTTPException(status_code=404, detail=f"{r.name} '{item_id}' not found") + return result + return _handler + + +def _make_delete_handler(r: DataResource, prefix: str) -> Any: + async def _handler(item_id: str) -> JSONResponse: + log.info(f"📥 DELETE {prefix}/{item_id} [DataResource delete: {r.name}]") + success = r.on_delete(item_id) # type: ignore[misc] + if not success: + raise HTTPException(status_code=404, detail=f"{r.name} '{item_id}' not found") + return JSONResponse(content={"deleted": True}, status_code=200) + return _handler + + +def _make_import_handler(r: DataResource, prefix: str) -> Any: + async def _handler(records: list[dict[str, Any]] = Body(...)) -> dict[str, Any]: + log.info(f"📥 POST {prefix}/import/ [DataResource import: {r.name}]") + return r.on_import(records) # type: ignore[misc] + return _handler From 29a36e0810bdcb9763c27c94a9007f0b769bb46e Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:53:47 +0300 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=90=9B=20fix(exports):=20remove=20d?= =?UTF-8?q?uplicate=20entries=20from=20=5F=5Fall=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index eaf2e47..10f6d2d 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -75,11 +75,8 @@ "Editable", "FieldType", "EntityEvents", - "EntityEvents", - "EntityLifecycle", "EntityLifecycle", "EntityStatus", - "EntityStatus", "ErrorResponse", "ErrorType", "Event", From 7df27a0d3b8607219ce7dd5e980bb3d04b17fb7a Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 18:54:44 +0300 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=93=9D=20chore:=20update=20CHANGELO?= =?UTF-8?q?G=20and=20bump=20version=20for=20DataResource=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a3ee4af..b418978 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -21,6 +21,22 @@ All notable changes to this project will be documented in this file. ### Added +- **`DataResource` class** — Declares agent-owned CRUD endpoints exposed to Studio with `name`, `entity_type`, `description`, `operations` (list of CRUD operations), `importable` (bulk import support), and `deletable` flags. + +- **`DataResourceField` class** — Describes field schema with `name`, `type`, `description`, `editable`, and `visible` attributes for validated rendering in Studio forms. + +- **`FieldType` enum** — Validated field types: `STRING`, `INTEGER`, `BOOLEAN`, `DATE`, `DATETIME`, `TEXT`, `EMAIL`, `URL` for consistent data handling across agents and Studio. + +- **`Editable` enum** — Controls Studio form behaviour per field: `ALWAYS` (edit in all forms), `CREATE_ONLY` (edit only on creation), `NEVER` (read-only display). + +- **`metadata: dict` on `AbstractJob` and `CaseAbstractModel`** — Arbitrary metadata flows through `registration_info` to Studio, enabling agents to attach custom context and tracking data to jobs and cases. + +- **`data_resources: list[DataResource]` on `Agent`** — Included in `registration_info` for Studio to discover and render CRUD interfaces for agent-managed data. + +- **Auto-generated FastAPI CRUD routes** — For each declared `DataResource` operation, Supervaizer auto-mounts routes (GET, POST, PATCH, DELETE) at `/agents/{slug}/data/{resource}/...`. + +- **Bulk import route** — When `importable=True` on a `DataResource`, a `POST /data/{resource}/import/` route accepts CSV or JSON for batch creation, enabling Studio to load data in bulk. + - **`CaseNodeUpdate.upsert` and `Case.patch_step`** — Optional step update path for Studio: when `upsert` is true, the existing case step at the same index is updated instead of appending. `Case.patch_step(index, update)` sets `index` and `upsert` on the update, sends `send_update_case`, and replaces the matching entry in `Case.updates`. Serialized in `CaseNodeUpdate.registration_info` for the controller payload. - **Human answer with `casestep_index`** — `POST /jobs/{job_id}/cases/{case_id}/update`: if `request.answer` includes `casestep_index`, the controller calls `case.patch_step(int(casestep_index), update)` and runs `PersistentEntityLifecycle.handle_event(..., INPUT_RECEIVED)` instead of `receive_human_input`. Omit `casestep_index` for the previous append/receive-human-input behavior. From 8632a87520b45464ffa244fb9df29f9a92bd0a75 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 22:59:18 +0300 Subject: [PATCH 09/11] docs: add unit test results and bump chang for0.133Add a " Tests Results" section to CHANGELOG.md showing thelatest test summary (492 passed,0,0 failed, ~70). Insert a new release header for0.13.3 dated202604-14 preservethe existing "Added" entries describing CaseNodeUpdate.upsert / Case.patch and human-answer casestep_index. This documents the test health and records the new patch release sousers and maintainers can see the verification status alongside thefeature notes. --- docs/CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b418978..90d67c4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -37,6 +37,21 @@ All notable changes to this project will be documented in this file. - **Bulk import route** — When `importable=True` on a `DataResource`, a `POST /data/{resource}/import/` route accepts CSV or JSON for batch creation, enabling Studio to load data in bulk. +### Unit Tests Results + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 492 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | ~70s | + +## [0.13.3] 2026-04-14 + +### Added + - **`CaseNodeUpdate.upsert` and `Case.patch_step`** — Optional step update path for Studio: when `upsert` is true, the existing case step at the same index is updated instead of appending. `Case.patch_step(index, update)` sets `index` and `upsert` on the update, sends `send_update_case`, and replaces the matching entry in `Case.updates`. Serialized in `CaseNodeUpdate.registration_info` for the controller payload. - **Human answer with `casestep_index`** — `POST /jobs/{job_id}/cases/{case_id}/update`: if `request.answer` includes `casestep_index`, the controller calls `case.patch_step(int(casestep_index), update)` and runs `PersistentEntityLifecycle.handle_event(..., INPUT_RECEIVED)` instead of `receive_human_input`. Omit `casestep_index` for the previous append/receive-human-input behavior. From a3d43a647f2a2b19bf98ba094671403ea008b56e Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 23:11:06 +0300 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=A8=20style:=20format=20multiline?= =?UTF-8?q?=20params=20dicts=20forReformat=20to=20improve=20readability=20?= =?UTF-8?q?and=20consistency-=20long=20import=20lists=20and=20DataResource?= =?UTF-8?q?Field=20instantiations=20across=20multiple=20lines=20to=20make?= =?UTF-8?q?=20diffs=20clearer=20and=20improve=20maintainability.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Break long Field(...) and model attribute declarations into multiple lines so descriptions and default factories are visually distinct. - Add and blank in data_routes separate logical blocks. - Annotate signature multiple lines for clarity. - Adjust slicing and HTTPException formatting to conform to the project's spacing and line-length style. These are purely stylist and do not alter behavior They make code easier to review and maintain, and keep lines within theproject's preferred width. --- src/supervaizer/__init__.py | 7 +++++- src/supervaizer/case.py | 5 +++- src/supervaizer/data_resource.py | 41 +++++++++++++++++++++++--------- src/supervaizer/data_routes.py | 29 +++++++++++++++++----- src/supervaizer/job.py | 5 +++- tests/test_agent.py | 4 +++- tests/test_data_resource.py | 15 +++++++++--- 7 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 10f6d2d..599fdbb 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -26,7 +26,12 @@ CaseNodes, ) from supervaizer.common import ApiError, ApiResult, ApiSuccess -from supervaizer.data_resource import DataResource, DataResourceField, Editable, FieldType +from supervaizer.data_resource import ( + DataResource, + DataResourceField, + Editable, + FieldType, +) from supervaizer.event import ( AgentRegisterEvent, CaseStartEvent, diff --git a/src/supervaizer/case.py b/src/supervaizer/case.py index 34fec04..294332d 100644 --- a/src/supervaizer/case.py +++ b/src/supervaizer/case.py @@ -215,7 +215,10 @@ class CaseAbstractModel(SvBaseModel): total_cost: float = 0.0 final_delivery: Optional[Dict[str, Any]] = None finished_at: Optional[datetime] = None - metadata: Dict[str, Any] = Field(default_factory=dict, description="Agent-provided domain metadata (e.g. contact context)") + metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Agent-provided domain metadata (e.g. contact context)", + ) class Case(CaseAbstractModel): diff --git a/src/supervaizer/data_resource.py b/src/supervaizer/data_resource.py index f119543..647b85d 100644 --- a/src/supervaizer/data_resource.py +++ b/src/supervaizer/data_resource.py @@ -10,6 +10,7 @@ auto-generates FastAPI CRUD routes for each declared resource, secured with the same API key as all other agent routes. """ + from __future__ import annotations from enum import StrEnum @@ -36,9 +37,9 @@ class FieldType(StrEnum): class Editable(StrEnum): """Controls when Studio may edit a field.""" - ALWAYS = "always" # Editable on create and update forms + ALWAYS = "always" # Editable on create and update forms CREATE_ONLY = "create_only" # Set on create; shown read-only on edit - NEVER = "never" # Agent-controlled; never shown in a form input + NEVER = "never" # Agent-controlled; never shown in a form input class DataResourceField(SvBaseModel): @@ -49,14 +50,18 @@ class DataResourceField(SvBaseModel): default=FieldType.STRING, description="One of: string, integer, boolean, date, datetime, text, email, url", ) - label: str | None = Field(default=None, description="Human-readable label; defaults to name.title()") + label: str | None = Field( + default=None, description="Human-readable label; defaults to name.title()" + ) required: bool = Field(default=False, description="Required on create form") editable: Editable = Field(default=Editable.ALWAYS) visible_on: list[str] = Field( default_factory=lambda: ["list", "detail", "create", "edit"], description="Views that render this field: list, detail, create, edit", ) - description: str | None = Field(default=None, description="Help text shown in Studio") + description: str | None = Field( + default=None, description="Help text shown in Studio" + ) related_resource: str | None = Field( default=None, description="Name of another DataResource this field FK-references", @@ -99,12 +104,22 @@ class DataResource(SvBaseModel): read_only: bool = Field(default=False) importable: bool = Field(default=False, description="Enables CSV bulk import route") # Callbacks — excluded from model serialization - on_list: Callable[[], list[dict[str, Any]]] | None = Field(default=None, exclude=True) - on_get: Callable[[str], dict[str, Any] | None] | None = Field(default=None, exclude=True) - on_create: Callable[[dict[str, Any]], dict[str, Any]] | None = Field(default=None, exclude=True) - on_update: Callable[[str, dict[str, Any]], dict[str, Any]] | None = Field(default=None, exclude=True) + on_list: Callable[[], list[dict[str, Any]]] | None = Field( + default=None, exclude=True + ) + on_get: Callable[[str], dict[str, Any] | None] | None = Field( + default=None, exclude=True + ) + on_create: Callable[[dict[str, Any]], dict[str, Any]] | None = Field( + default=None, exclude=True + ) + on_update: Callable[[str, dict[str, Any]], dict[str, Any]] | None = Field( + default=None, exclude=True + ) on_delete: Callable[[str], bool] | None = Field(default=None, exclude=True) - on_import: Callable[[list[dict[str, Any]]], dict[str, Any]] | None = Field(default=None, exclude=True) + on_import: Callable[[list[dict[str, Any]]], dict[str, Any]] | None = Field( + default=None, exclude=True + ) @model_validator(mode="after") def check_callbacks(self) -> "DataResource": @@ -121,9 +136,13 @@ def check_callbacks(self) -> "DataResource": if self.on_list is None: raise ValueError(f"DataResource '{self.name}' must define on_list") if not self.read_only and self.on_create is None: - raise ValueError(f"Writable DataResource '{self.name}' must define on_create") + raise ValueError( + f"Writable DataResource '{self.name}' must define on_create" + ) if self.importable and self.on_import is None: - raise ValueError(f"Importable DataResource '{self.name}' must define on_import") + raise ValueError( + f"Importable DataResource '{self.name}' must define on_import" + ) return self @property diff --git a/src/supervaizer/data_routes.py b/src/supervaizer/data_routes.py index 8db3d18..85ba31a 100644 --- a/src/supervaizer/data_routes.py +++ b/src/supervaizer/data_routes.py @@ -17,6 +17,7 @@ All routes require the server API key (same auth as existing agent routes). Uses factory functions per resource to avoid Python closure-in-loop capture bugs. """ + from __future__ import annotations from typing import TYPE_CHECKING, Any @@ -40,7 +41,9 @@ def create_agent_data_routes(server: "Server", agent: "Agent") -> APIRouter: return router -def _add_resource_routes(router: APIRouter, resource: DataResource, server: "Server") -> None: +def _add_resource_routes( + router: APIRouter, resource: DataResource, server: "Server" +) -> None: """Register all declared operation routes for one DataResource.""" prefix = f"/data/{resource.name}" @@ -112,7 +115,8 @@ async def _handler( ) -> list[dict[str, Any]]: log.info(f"📥 GET {prefix}/ [DataResource list: {r.name}]") result = r.on_list() # type: ignore[misc] - return result[skip: skip + limit] + return result[skip : skip + limit] + return _handler @@ -121,8 +125,11 @@ async def _handler(item_id: str) -> dict[str, Any]: log.info(f"📥 GET {prefix}/{item_id} [DataResource get: {r.name}]") result = r.on_get(item_id) # type: ignore[misc] if result is None: - raise HTTPException(status_code=404, detail=f"{r.name} '{item_id}' not found") + raise HTTPException( + status_code=404, detail=f"{r.name} '{item_id}' not found" + ) return result + return _handler @@ -136,16 +143,22 @@ async def _handler(data: dict[str, Any] = Body(...)) -> JSONResponse: detail=f"on_create for '{r.name}' must return a dict with 'id'", ) return JSONResponse(content=result, status_code=201) + return _handler def _make_update_handler(r: DataResource, prefix: str) -> Any: - async def _handler(item_id: str, data: dict[str, Any] = Body(...)) -> dict[str, Any]: + async def _handler( + item_id: str, data: dict[str, Any] = Body(...) + ) -> dict[str, Any]: log.info(f"📥 PUT {prefix}/{item_id} [DataResource update: {r.name}]") result = r.on_update(item_id, data) # type: ignore[misc] if result is None: - raise HTTPException(status_code=404, detail=f"{r.name} '{item_id}' not found") + raise HTTPException( + status_code=404, detail=f"{r.name} '{item_id}' not found" + ) return result + return _handler @@ -154,8 +167,11 @@ async def _handler(item_id: str) -> JSONResponse: log.info(f"📥 DELETE {prefix}/{item_id} [DataResource delete: {r.name}]") success = r.on_delete(item_id) # type: ignore[misc] if not success: - raise HTTPException(status_code=404, detail=f"{r.name} '{item_id}' not found") + raise HTTPException( + status_code=404, detail=f"{r.name} '{item_id}' not found" + ) return JSONResponse(content={"deleted": True}, status_code=200) + return _handler @@ -163,4 +179,5 @@ def _make_import_handler(r: DataResource, prefix: str) -> Any: async def _handler(records: list[dict[str, Any]] = Body(...)) -> dict[str, Any]: log.info(f"📥 POST {prefix}/import/ [DataResource import: {r.name}]") return r.on_import(records) # type: ignore[misc] + return _handler diff --git a/src/supervaizer/job.py b/src/supervaizer/job.py index c2cc838..cf2db4b 100644 --- a/src/supervaizer/job.py +++ b/src/supervaizer/job.py @@ -256,7 +256,10 @@ class AbstractJob(SvBaseModel): created_at: datetime | None = None agent_parameters: list[dict[str, Any]] | None = None case_ids: list[str] = [] # Foreign key relationship to cases - metadata: dict[str, Any] = Field(default_factory=dict, description="Agent-provided domain metadata (e.g. campaign context)") + metadata: dict[str, Any] = Field( + default_factory=dict, + description="Agent-provided domain metadata (e.g. campaign context)", + ) class Job(AbstractJob): diff --git a/tests/test_agent.py b/tests/test_agent.py index 28799aa..46e376d 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1111,7 +1111,9 @@ def test_agent_registration_info_includes_data_resources() -> None: name="contacts", display_name="Contacts", fields=[ - DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), + DataResourceField( + name="id", editable=Editable.NEVER, visible_on=["list", "detail"] + ), DataResourceField(name="email", field_type="email", required=True), ], on_list=lambda: [], diff --git a/tests/test_data_resource.py b/tests/test_data_resource.py index 7fe60b2..813129e 100644 --- a/tests/test_data_resource.py +++ b/tests/test_data_resource.py @@ -5,7 +5,12 @@ # https://mozilla.org/MPL/2.0/. import pytest -from supervaizer.data_resource import DataResource, DataResourceField, Editable, FieldType +from supervaizer.data_resource import ( + DataResource, + DataResourceField, + Editable, + FieldType, +) def test_field_defaults(): @@ -80,7 +85,9 @@ def test_data_resource_registration_info(): name="contacts", display_name="Contacts", fields=[ - DataResourceField(name="id", editable=Editable.NEVER, visible_on=["list", "detail"]), + DataResourceField( + name="id", editable=Editable.NEVER, visible_on=["list", "detail"] + ), DataResourceField(name="email", field_type=FieldType.EMAIL, required=True), ], on_list=lambda: [], @@ -98,7 +105,9 @@ def test_data_resource_registration_info(): def test_data_resource_display_name_derived_from_name(): - dr = DataResource(name="contact_knowledge", fields=[], on_list=lambda: [], read_only=True) + dr = DataResource( + name="contact_knowledge", fields=[], on_list=lambda: [], read_only=True + ) assert dr.display_name_resolved == "Contact Knowledge" From 1925473a8b01b84ba6faee37800b3feb2778dfab Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Tue, 14 Apr 2026 23:27:12 +0300 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=A8=20feat(data-resource):=20valida?= =?UTF-8?q?te,=20fix=20handlers,=20and=20test=20OpenAPI=20ids-=20Add=20a?= =?UTF-8?q?=20strict=20name=20pattern=20for=20DataResource=20(lowercase=20?= =?UTF-8?q?letters,=20digits,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit and hyphens; must start with a letter or digit) and apply it to the DataResource.name field. This ensures path and OpenAPI operation_id fragments remain safe and predictable. - Clarify and expand the DataResource.name Field description to document the allowed characters intent. - Adjust on_update signature to allow returning None from update handlers (Callable[[str, dict[str, Any]], dict[str, Any] | None to support handlers that may not always return an updated object. - Serialize event payloads in tests with SvBaseModel.serialize_value to match the application's payload serialization when sending events- Add a new test ensuring OpenAPI operation for data resource list endpoints unique per agent. This prevents collisions multiple agents expose resources with the same name. - Update and test scaffolding to support the new test (add DataResource and Parameters imports RSA key generation in the test server setup). These changes improve API, flexibility and test coveragefor OpenAPI uniqueness. --- AGENTS.md | 1 + src/supervaizer/account_service.py | 11 ++++- src/supervaizer/agent.py | 9 ++++ src/supervaizer/case.py | 2 +- src/supervaizer/common.py | 7 ++- src/supervaizer/data_resource.py | 14 +++++- src/supervaizer/data_routes.py | 41 ++++++++++++---- src/supervaizer/job.py | 2 +- tests/test_account_service.py | 3 +- tests/test_agent.py | 16 +++++++ tests/test_data_resource.py | 39 ++++++++++----- tests/test_event.py | 36 +++++++++++++- tests/test_routes.py | 76 +++++++++++++++++++++++++++++- 13 files changed, 227 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 99cc228..9d21f3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ Reference specific personas when requesting work: ## Learned Workspace Facts +- Compliance for this repo expects explicit type annotations, including return types, on functions in new or modified Python files (including tests), for mypy-clean CI. - `ADMIN_ALLOWED_IPS` restricts `/admin` when set (comma-separated IPs/CIDR); unset or empty allows all client IPs. - In `9agents/agent_interviewer`, empty `MANAGE_ALLOWED_IPS` still requires `MANAGE_AUTH_TOKEN` when that env is set; supervaizer’s admin IP middleware has no equivalent token fallback when the allowlist is empty. diff --git a/src/supervaizer/account_service.py b/src/supervaizer/account_service.py index fd10fbd..04bf0a7 100644 --- a/src/supervaizer/account_service.py +++ b/src/supervaizer/account_service.py @@ -14,7 +14,14 @@ import httpx -from supervaizer.common import ApiError, ApiResult, ApiSuccess, is_local_mode, log +from supervaizer.common import ( + ApiError, + ApiResult, + ApiSuccess, + SvBaseModel, + is_local_mode, + log, +) logger = logging.getLogger("httpx") # Enable httpx debug logging (optional - uncomment for transport-level debugging) @@ -65,7 +72,7 @@ def send_event( ) headers = account.api_headers - payload = event.payload + payload = SvBaseModel.serialize_value(event.payload) url_event = ( account.url_event.strip() ) # defensive: env vars often have trailing newline diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 1532361..368b810 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -722,6 +722,15 @@ def __init__( **kwargs, ) + seen_resource_names: set[str] = set() + for r in self.data_resources: + if r.name in seen_resource_names: + raise ValueError( + f"Duplicate DataResource name {r.name!r} on agent {self.name!r}; " + "each data resource must have a unique name per agent." + ) + seen_resource_names.add(r.name) + def __str__(self) -> str: return f"{self.name} ({self.id})" diff --git a/src/supervaizer/case.py b/src/supervaizer/case.py index 294332d..9d2302e 100644 --- a/src/supervaizer/case.py +++ b/src/supervaizer/case.py @@ -365,7 +365,7 @@ def registration_info(self) -> Dict[str, Any]: "updates": [update.registration_info for update in self.updates], "total_cost": self.total_cost, "final_delivery": self.final_delivery, - "metadata": self.metadata, + "metadata": SvBaseModel.serialize_value(self.metadata), } @classmethod diff --git a/src/supervaizer/common.py b/src/supervaizer/common.py index 6ea236b..de5f6dc 100644 --- a/src/supervaizer/common.py +++ b/src/supervaizer/common.py @@ -35,7 +35,12 @@ class SvBaseModel(BaseModel): @staticmethod def serialize_value(value: Any) -> Any: - """Recursively serialize values, converting type objects and datetimes to strings.""" + """Recursively serialize values for JSON-compatible output. + + Converts type objects to their name and datetime values to ISO-8601 strings. + Dicts and lists are processed recursively. Used by ``to_dict``, job/case + ``registration_info`` metadata, and ``send_event`` HTTP bodies. + """ from datetime import datetime if isinstance(value, type): diff --git a/src/supervaizer/data_resource.py b/src/supervaizer/data_resource.py index 647b85d..cb2e75e 100644 --- a/src/supervaizer/data_resource.py +++ b/src/supervaizer/data_resource.py @@ -20,6 +20,9 @@ from supervaizer.common import SvBaseModel +# Used for URL path segments (/data/{name}/) and OpenAPI operation_id fragments. +_DATA_RESOURCE_NAME_PATTERN = r"^[a-z0-9][a-z0-9_-]*$" + class FieldType(StrEnum): """Allowed field types for DataResourceField.""" @@ -97,7 +100,14 @@ class DataResource(SvBaseModel): model_config = {"arbitrary_types_allowed": True} - name: str = Field(description="URL-safe resource identifier, e.g. 'contacts'") + name: str = Field( + description=( + "URL-safe resource identifier, e.g. 'contacts'. " + "Lowercase letters, digits, underscores, and hyphens only; " + "must start with a letter or digit." + ), + pattern=_DATA_RESOURCE_NAME_PATTERN, + ) display_name: str = Field(default="") description: str = Field(default="") fields: list[DataResourceField] = Field(default_factory=list) @@ -113,7 +123,7 @@ class DataResource(SvBaseModel): on_create: Callable[[dict[str, Any]], dict[str, Any]] | None = Field( default=None, exclude=True ) - on_update: Callable[[str, dict[str, Any]], dict[str, Any]] | None = Field( + on_update: Callable[[str, dict[str, Any]], dict[str, Any] | None] | None = Field( default=None, exclude=True ) on_delete: Callable[[str], bool] | None = Field(default=None, exclude=True) diff --git a/src/supervaizer/data_routes.py b/src/supervaizer/data_routes.py index 85ba31a..40d81f2 100644 --- a/src/supervaizer/data_routes.py +++ b/src/supervaizer/data_routes.py @@ -36,75 +36,95 @@ def create_agent_data_routes(server: "Server", agent: "Agent") -> APIRouter: """Generate CRUD REST routes for all DataResources declared on an agent.""" router = APIRouter(prefix=agent.path, tags=["Data Resources"]) + agent_slug = agent.slug for resource in agent.data_resources: - _add_resource_routes(router, resource, server) + _add_resource_routes(router, resource, server, agent_slug) return router +def _data_resource_operation_id( + agent_slug: str, resource_name: str, action: str +) -> str: + """Build a globally unique OpenAPI operation_id (per app, all agents).""" + return f"{agent_slug}_{resource_name}_{action}" + + def _add_resource_routes( - router: APIRouter, resource: DataResource, server: "Server" + router: APIRouter, resource: DataResource, server: "Server", agent_slug: str ) -> None: """Register all declared operation routes for one DataResource.""" prefix = f"/data/{resource.name}" if resource.on_list is not None: + op_id = _data_resource_operation_id(agent_slug, resource.name, "list") router.add_api_route( f"{prefix}/", _make_list_handler(resource, prefix), methods=["GET"], dependencies=[Security(server.verify_api_key)], summary=f"List {resource.display_name_resolved}", - operation_id=f"{resource.name}_list", + operation_id=op_id, + name=op_id, ) if resource.on_get is not None: + op_id = _data_resource_operation_id(agent_slug, resource.name, "get") router.add_api_route( f"{prefix}/{{item_id}}", _make_get_handler(resource, prefix), methods=["GET"], dependencies=[Security(server.verify_api_key)], summary=f"Get {resource.display_name_resolved}", - operation_id=f"{resource.name}_get", + operation_id=op_id, + name=op_id, ) if resource.on_create is not None and not resource.read_only: + op_id = _data_resource_operation_id(agent_slug, resource.name, "create") router.add_api_route( f"{prefix}/", _make_create_handler(resource, prefix), methods=["POST"], dependencies=[Security(server.verify_api_key)], summary=f"Create {resource.display_name_resolved}", - operation_id=f"{resource.name}_create", + operation_id=op_id, + name=op_id, ) if resource.on_update is not None and not resource.read_only: + op_id = _data_resource_operation_id(agent_slug, resource.name, "update") router.add_api_route( f"{prefix}/{{item_id}}", _make_update_handler(resource, prefix), methods=["PUT"], dependencies=[Security(server.verify_api_key)], summary=f"Update {resource.display_name_resolved}", - operation_id=f"{resource.name}_update", + operation_id=op_id, + name=op_id, ) if resource.on_delete is not None and not resource.read_only: + op_id = _data_resource_operation_id(agent_slug, resource.name, "delete") router.add_api_route( f"{prefix}/{{item_id}}", _make_delete_handler(resource, prefix), methods=["DELETE"], dependencies=[Security(server.verify_api_key)], summary=f"Delete {resource.display_name_resolved}", - operation_id=f"{resource.name}_delete", + operation_id=op_id, + name=op_id, ) if resource.importable and resource.on_import is not None: + op_id = _data_resource_operation_id(agent_slug, resource.name, "import") router.add_api_route( f"{prefix}/import/", _make_import_handler(resource, prefix), methods=["POST"], dependencies=[Security(server.verify_api_key)], summary=f"Import {resource.display_name_resolved} (bulk)", - operation_id=f"{resource.name}_import", + operation_id=op_id, + name=op_id, ) @@ -148,11 +168,14 @@ async def _handler(data: dict[str, Any] = Body(...)) -> JSONResponse: def _make_update_handler(r: DataResource, prefix: str) -> Any: + on_update = r.on_update + assert on_update is not None # route registered only when on_update is set + async def _handler( item_id: str, data: dict[str, Any] = Body(...) ) -> dict[str, Any]: log.info(f"📥 PUT {prefix}/{item_id} [DataResource update: {r.name}]") - result = r.on_update(item_id, data) # type: ignore[misc] + result = on_update(item_id, data) if result is None: raise HTTPException( status_code=404, detail=f"{r.name} '{item_id}' not found" diff --git a/src/supervaizer/job.py b/src/supervaizer/job.py index cf2db4b..eb29c52 100644 --- a/src/supervaizer/job.py +++ b/src/supervaizer/job.py @@ -353,7 +353,7 @@ def registration_info(self) -> Dict[str, Any]: "finished_at": self.finished_at.isoformat() if self.finished_at else "", "created_at": self.created_at.isoformat() if self.created_at else "", "case_ids": self.case_ids, - "metadata": self.metadata, + "metadata": SvBaseModel.serialize_value(self.metadata), } @classmethod diff --git a/tests/test_account_service.py b/tests/test_account_service.py index 18a645b..9be68ad 100644 --- a/tests/test_account_service.py +++ b/tests/test_account_service.py @@ -11,6 +11,7 @@ from supervaizer import Account, ApiSuccess from supervaizer.account_service import send_event +from supervaizer.common import SvBaseModel from supervaizer.event import Event from supervaizer.server import Server @@ -36,7 +37,7 @@ def test_send_event_success( mock_post.assert_called_once_with( account_fixture.url_event, headers=account_fixture.api_headers, - json=event_fixture.payload, + json=SvBaseModel.serialize_value(event_fixture.payload), ) assert isinstance(result, ApiSuccess) assert result.message == f"POST Event {event_fixture.type.name} sent" diff --git a/tests/test_agent.py b/tests/test_agent.py index 46e376d..eabbfc6 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1103,6 +1103,22 @@ def test_agent_data_resources_default_empty() -> None: assert agent.data_resources == [] +def test_agent_rejects_duplicate_data_resource_names() -> None: + """Two DataResources with the same name on one agent are invalid.""" + from supervaizer.data_resource import DataResource + + dup = DataResource(name="items", fields=[], on_list=lambda: [], read_only=True) + with pytest.raises(ValueError, match="Duplicate DataResource name"): + Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + data_resources=[dup, dup], + ) + + def test_agent_registration_info_includes_data_resources() -> None: """Agent.registration_info includes data_resources when declared.""" from supervaizer.data_resource import DataResource, DataResourceField, Editable diff --git a/tests/test_data_resource.py b/tests/test_data_resource.py index 813129e..1eaadf8 100644 --- a/tests/test_data_resource.py +++ b/tests/test_data_resource.py @@ -5,6 +5,8 @@ # https://mozilla.org/MPL/2.0/. import pytest +from pydantic import ValidationError + from supervaizer.data_resource import ( DataResource, DataResourceField, @@ -13,7 +15,7 @@ ) -def test_field_defaults(): +def test_field_defaults() -> None: f = DataResourceField(name="email") assert f.field_type == FieldType.STRING assert f.editable == Editable.ALWAYS @@ -21,27 +23,42 @@ def test_field_defaults(): assert f.required is False -def test_field_display_label_defaults_to_name_title(): +def test_field_display_label_defaults_to_name_title() -> None: f = DataResourceField(name="first_name") assert f.display_label == "First Name" -def test_field_display_label_custom(): +def test_field_display_label_custom() -> None: f = DataResourceField(name="first_name", label="Given Name") assert f.display_label == "Given Name" -def test_data_resource_requires_on_list(): +@pytest.mark.parametrize( + "bad_name", + [ + "", + "contacts/admin", + "{id}", + "bad name", + "CamelCase", + ], +) +def test_data_resource_name_must_be_url_safe(bad_name: str) -> None: + with pytest.raises(ValidationError): + DataResource(name=bad_name, fields=[], on_list=lambda: []) + + +def test_data_resource_requires_on_list() -> None: with pytest.raises(ValueError, match="must define on_list"): DataResource(name="contacts", fields=[]) -def test_data_resource_writable_requires_on_create(): +def test_data_resource_writable_requires_on_create() -> None: with pytest.raises(ValueError, match="must define on_create"): DataResource(name="contacts", fields=[], on_list=lambda: [], read_only=False) -def test_data_resource_read_only_no_on_create_required(): +def test_data_resource_read_only_no_on_create_required() -> None: dr = DataResource(name="prompts", fields=[], on_list=lambda: [], read_only=True) assert dr.operations["create"] is False assert dr.operations["update"] is False @@ -49,7 +66,7 @@ def test_data_resource_read_only_no_on_create_required(): assert dr.operations["list"] is True -def test_data_resource_importable_requires_on_import(): +def test_data_resource_importable_requires_on_import() -> None: with pytest.raises(ValueError, match="must define on_import"): DataResource( name="contacts", @@ -60,7 +77,7 @@ def test_data_resource_importable_requires_on_import(): ) -def test_data_resource_operations_full(): +def test_data_resource_operations_full() -> None: dr = DataResource( name="contacts", fields=[], @@ -80,7 +97,7 @@ def test_data_resource_operations_full(): } -def test_data_resource_registration_info(): +def test_data_resource_registration_info() -> None: dr = DataResource( name="contacts", display_name="Contacts", @@ -104,14 +121,14 @@ def test_data_resource_registration_info(): assert info["operations"]["import"] is False -def test_data_resource_display_name_derived_from_name(): +def test_data_resource_display_name_derived_from_name() -> None: dr = DataResource( name="contact_knowledge", fields=[], on_list=lambda: [], read_only=True ) assert dr.display_name_resolved == "Contact Knowledge" -def test_data_resource_callbacks_excluded_from_serialization(): +def test_data_resource_callbacks_excluded_from_serialization() -> None: dr = DataResource(name="prompts", fields=[], on_list=lambda: [], read_only=True) dumped = dr.model_dump() assert "on_list" not in dumped diff --git a/tests/test_event.py b/tests/test_event.py index 1c71a6c..40334f9 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -4,6 +4,9 @@ # If a copy of the MPL was not distributed with this file, you can obtain one at # https://mozilla.org/MPL/2.0/. +import json +from datetime import datetime, timezone +from uuid import uuid4 from supervaizer import ( Account, @@ -20,7 +23,8 @@ Server, ServerRegisterEvent, ) -from supervaizer.job import Job +from supervaizer.job import Job, JobContext +from supervaizer.lifecycle import EntityStatus def test_event(event_fixture: Event) -> None: @@ -118,3 +122,33 @@ def test_job_finished_event(job_fixture: Job, account_fixture: Account) -> None: account=account_fixture, ) assert isinstance(job_finished_event, JobFinishedEvent) + + +def test_event_payload_json_encodable_with_metadata_datetime( + context_fixture: JobContext, + account_fixture: Account, +) -> None: + """Metadata with datetime/type must JSON-encode for httpx (event.details → payload).""" + dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc) + job = Job.new( + job_context=context_fixture, + agent_name="test-agent", + metadata={"scheduled_at": dt, "kind": str}, + ) + job_event = JobStartConfirmationEvent(job=job, account=account_fixture) + json.dumps(job_event.payload) + assert job_event.details["metadata"]["scheduled_at"] == dt.isoformat() + assert job_event.details["metadata"]["kind"] == "str" + + case = Case( + id=str(uuid4()), + job_id=context_fixture.job_id, + account=account_fixture, + status=EntityStatus.IN_PROGRESS, + name="n", + description="d", + metadata={"at": dt}, + ) + case_event = CaseStartEvent(case=case, account=account_fixture) + json.dumps(case_event.payload) + assert case_event.details["metadata"]["at"] == dt.isoformat() diff --git a/tests/test_routes.py b/tests/test_routes.py index 305a0f5..2c7d7cd 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -6,9 +6,19 @@ from typing import Any +from cryptography.hazmat.primitives.asymmetric import rsa from fastapi.testclient import TestClient -from supervaizer import Agent, Job, Server +from supervaizer import ( + Account, + Agent, + AgentMethod, + AgentMethods, + Job, + Server, +) +from supervaizer.data_resource import DataResource +from supervaizer.parameter import ParametersSetup from supervaizer.routes import ( create_agents_routes, create_default_routes, @@ -283,3 +293,67 @@ def test_dynamic_choices_endpoint_no_callback( }, ) assert resp.status_code == 404 + + +def test_data_resource_openapi_operation_ids_unique_per_agent( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + """Same resource name on different agents must not share operationId (OpenAPI).""" + methods = AgentMethods( + job_start=agent_method_fixture, + job_stop=agent_method_fixture, + job_status=agent_method_fixture, + chat=None, + custom={"method1": agent_method_fixture}, + ) + dr_a = DataResource(name="items", fields=[], on_list=lambda: [], read_only=True) + dr_b = DataResource(name="items", fields=[], on_list=lambda: [], read_only=True) + agent_a = Agent( + name="First Agent", + author="a", + developer="d", + version="1.0.0", + description="d", + methods=methods, + parameters_setup=parameters_setup_fixture, + data_resources=[dr_a], + ) + agent_b = Agent( + name="Second Agent", + author="a", + developer="d", + version="1.0.0", + description="d", + methods=methods, + parameters_setup=parameters_setup_fixture, + data_resources=[dr_b], + ) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + server = Server( + scheme="http", + host="localhost", + port=8001, + environment="test", + mac_addr="E2-AC-ED-22-BF-B2", + debug=True, + agent_timeout=10, + private_key=private_key, + a2a_endpoints=False, + supervisor_account=account_fixture, + agents=[agent_a, agent_b], + api_key="test-api-key", + ) + client = TestClient(server.app) + schema = client.get("/openapi.json").json() + op_ids: list[str] = [] + for path_item in schema.get("paths", {}).values(): + for op in path_item.values(): + if isinstance(op, dict) and "operationId" in op: + op_ids.append(op["operationId"]) + list_ids = [oid for oid in op_ids if oid.endswith("_items_list")] + assert len(list_ids) == 2 + assert len(set(list_ids)) == 2 + assert f"{agent_a.slug}_items_list" in list_ids + assert f"{agent_b.slug}_items_list" in list_ids