diff --git a/automation/models.py b/automation/models.py index 79ed934..aa4373e 100644 --- a/automation/models.py +++ b/automation/models.py @@ -5,6 +5,7 @@ from datetime import datetime from sqlalchemy import ( + JSON, BigInteger, DateTime, Enum, @@ -55,6 +56,10 @@ class Automation(Base): # Optional prompt (set when created via preset endpoints) prompt: Mapped[str | None] = mapped_column(Text, nullable=True) + # Preset-specific metadata (populated by preset endpoints, NULL for custom + # SDK automations). Schema: {"preset_type": "prompt"|"plugin", ...} + preset_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # Trigger config — for MVP, only cron is supported. trigger: Mapped[dict] = mapped_column(JSONB, nullable=False) diff --git a/automation/preset_router.py b/automation/preset_router.py index 0c7f746..979f2f3 100644 --- a/automation/preset_router.py +++ b/automation/preset_router.py @@ -254,12 +254,21 @@ async def create_automation_from_prompt( # 3. Create the automation referencing the internal upload tarball_path = build_internal_url(upload_id) + # Build preset metadata for UI consumption + preset_metadata: dict[str, Any] = { + "preset_type": "prompt", + "prompt": body.prompt, + } + if body.repos: + preset_metadata["repos"] = [r.model_dump(exclude_none=True) for r in body.repos] + try: automation = Automation( user_id=user.user_id, org_id=user.org_id, name=body.name, prompt=body.prompt, + preset_metadata=preset_metadata, trigger=body.trigger.model_dump(), tarball_path=tarball_path, setup_script_path="setup.sh", @@ -486,12 +495,22 @@ async def create_automation_from_plugin( # 3. Create the automation referencing the internal upload tarball_path = build_internal_url(upload_id) + # Build preset metadata for UI consumption + preset_metadata: dict[str, Any] = { + "preset_type": "plugin", + "prompt": body.prompt, + "plugins": [p.model_dump(exclude_none=True) for p in body.plugins], + } + if body.repos: + preset_metadata["repos"] = [r.model_dump(exclude_none=True) for r in body.repos] + try: automation = Automation( user_id=user.user_id, org_id=user.org_id, name=body.name, prompt=body.prompt, + preset_metadata=preset_metadata, trigger=body.trigger.model_dump(), tarball_path=tarball_path, setup_script_path="setup.sh", diff --git a/automation/schemas.py b/automation/schemas.py index e108ca4..0734370 100644 --- a/automation/schemas.py +++ b/automation/schemas.py @@ -556,6 +556,7 @@ class AutomationResponse(BaseModel): org_id: uuid.UUID name: str prompt: str | None + preset_metadata: dict | None trigger: dict tarball_path: str setup_script_path: str | None diff --git a/migrations/versions/005_add_preset_metadata_column.py b/migrations/versions/005_add_preset_metadata_column.py new file mode 100644 index 0000000..c6668b0 --- /dev/null +++ b/migrations/versions/005_add_preset_metadata_column.py @@ -0,0 +1,35 @@ +"""Add preset_metadata column to automations table. + +This migration adds a nullable JSON preset_metadata column to the automations +table for storing preset-specific configuration that the UI can consume. + +The field stores metadata like preset_type, prompt, plugins, and repos +for automations created via preset endpoints (/v1/preset/prompt, /v1/preset/plugin). +Custom SDK automations will have NULL preset_metadata. + +Revision ID: 005 +Revises: 004 +Create Date: 2026-04-30 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "005" +down_revision: str = "004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "automations", + sa.Column("preset_metadata", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("automations", "preset_metadata") diff --git a/tests/test_preset_router.py b/tests/test_preset_router.py index 89a1fd0..755c6a2 100644 --- a/tests/test_preset_router.py +++ b/tests/test_preset_router.py @@ -1121,3 +1121,199 @@ async def test_create_from_plugin_single_plugin_minimal( assert config[0]["source"] == "github:owner/minimal-plugin" # No ref or repo_path since they were None assert "ref" not in config[0] + + +@requires_docker +class TestPresetMetadata: + """Tests for preset_metadata field in automation responses.""" + + @pytest.fixture + def mock_file_store(self): + """Create a mock file store.""" + from collections.abc import AsyncIterator + from unittest.mock import AsyncMock + + store = MagicMock() + store._captured_content = None + + async def mock_write_stream( + path: str, + stream: AsyncIterator[bytes], + max_size: int | None = None, + content_type: str = "application/octet-stream", + ) -> int: + content = b"" + async for chunk in stream: + content += chunk + store._captured_content = content + return len(content) + + store.write_stream = AsyncMock(side_effect=mock_write_stream) + store.delete = MagicMock() + return store + + @pytest.fixture(autouse=True) + def setup_file_store_override(self, mock_file_store): + """Override file_store for all tests in this class.""" + from automation.app import app + from automation.storage import get_file_store + + app.dependency_overrides[get_file_store] = lambda: mock_file_store + yield + app.dependency_overrides.pop(get_file_store, None) + + async def test_prompt_preset_sets_preset_metadata( + self, async_client, async_session, mock_file_store + ): + """Prompt preset populates preset_metadata with type and prompt.""" + payload = { + "name": "Prompt Metadata Test", + "prompt": "Test prompt for metadata", + "trigger": {"type": "cron", "schedule": "0 9 * * 1"}, + } + + response = await async_client.post( + "/api/automation/v1/preset/prompt", json=payload + ) + + assert response.status_code == 201 + data = response.json() + + # Verify preset_metadata is present and correct + assert data["preset_metadata"] is not None + assert data["preset_metadata"]["preset_type"] == "prompt" + assert data["preset_metadata"]["prompt"] == "Test prompt for metadata" + # No repos specified + assert "repos" not in data["preset_metadata"] + + async def test_prompt_preset_with_repos_includes_repos_in_metadata( + self, async_client, async_session, mock_file_store + ): + """Prompt preset with repos includes repos in preset_metadata.""" + payload = { + "name": "Prompt With Repos Metadata", + "prompt": "Test prompt", + "trigger": {"type": "cron", "schedule": "0 9 * * 1"}, + "repos": [ + {"url": "https://github.com/owner/repo1"}, + {"url": "https://github.com/owner/repo2", "ref": "main"}, + ], + } + + response = await async_client.post( + "/api/automation/v1/preset/prompt", json=payload + ) + + assert response.status_code == 201 + data = response.json() + + # Verify preset_metadata contains repos + assert data["preset_metadata"] is not None + assert data["preset_metadata"]["preset_type"] == "prompt" + assert data["preset_metadata"]["prompt"] == "Test prompt" + assert "repos" in data["preset_metadata"] + assert len(data["preset_metadata"]["repos"]) == 2 + assert ( + data["preset_metadata"]["repos"][0]["url"] + == "https://github.com/owner/repo1" + ) + assert ( + data["preset_metadata"]["repos"][1]["url"] + == "https://github.com/owner/repo2" + ) + assert data["preset_metadata"]["repos"][1]["ref"] == "main" + + async def test_plugin_preset_sets_preset_metadata( + self, async_client, async_session, mock_file_store + ): + """Plugin preset populates preset_metadata with type, prompt, and plugins.""" + payload = { + "name": "Plugin Metadata Test", + "plugins": [ + {"source": "github:owner/plugin1", "ref": "v1.0.0"}, + {"source": "github:owner/plugin2"}, + ], + "prompt": "Test prompt for plugin", + "trigger": {"type": "cron", "schedule": "0 9 * * 1"}, + } + + response = await async_client.post( + "/api/automation/v1/preset/plugin", json=payload + ) + + assert response.status_code == 201 + data = response.json() + + # Verify preset_metadata is present and correct + assert data["preset_metadata"] is not None + assert data["preset_metadata"]["preset_type"] == "plugin" + assert data["preset_metadata"]["prompt"] == "Test prompt for plugin" + assert "plugins" in data["preset_metadata"] + assert len(data["preset_metadata"]["plugins"]) == 2 + assert data["preset_metadata"]["plugins"][0]["source"] == "github:owner/plugin1" + assert data["preset_metadata"]["plugins"][0]["ref"] == "v1.0.0" + assert data["preset_metadata"]["plugins"][1]["source"] == "github:owner/plugin2" + assert "ref" not in data["preset_metadata"]["plugins"][1] + # No repos specified + assert "repos" not in data["preset_metadata"] + + async def test_plugin_preset_with_repos_includes_repos_in_metadata( + self, async_client, async_session, mock_file_store + ): + """Plugin preset with repos includes repos in preset_metadata.""" + payload = { + "name": "Plugin With Repos Metadata", + "plugins": [{"source": "github:owner/plugin"}], + "prompt": "Test prompt", + "trigger": {"type": "cron", "schedule": "0 9 * * 1"}, + "repos": [{"url": "https://github.com/owner/repo", "ref": "develop"}], + } + + response = await async_client.post( + "/api/automation/v1/preset/plugin", json=payload + ) + + assert response.status_code == 201 + data = response.json() + + # Verify preset_metadata contains repos + assert data["preset_metadata"] is not None + assert data["preset_metadata"]["preset_type"] == "plugin" + assert "plugins" in data["preset_metadata"] + assert "repos" in data["preset_metadata"] + assert len(data["preset_metadata"]["repos"]) == 1 + assert ( + data["preset_metadata"]["repos"][0]["url"] + == "https://github.com/owner/repo" + ) + assert data["preset_metadata"]["repos"][0]["ref"] == "develop" + + async def test_preset_metadata_stored_in_database( + self, async_client, async_session, mock_file_store + ): + """Preset metadata is correctly stored in the database.""" + payload = { + "name": "DB Storage Test", + "prompt": "Test prompt for DB", + "trigger": {"type": "cron", "schedule": "0 9 * * 1"}, + } + + response = await async_client.post( + "/api/automation/v1/preset/prompt", json=payload + ) + + assert response.status_code == 201 + data = response.json() + automation_id = uuid.UUID(data["id"]) + + # Verify the preset_metadata is stored in the database + from sqlalchemy import select + + result = await async_session.execute( + select(Automation).where(Automation.id == automation_id) + ) + automation = result.scalars().first() + assert automation is not None + assert automation.preset_metadata is not None + assert automation.preset_metadata["preset_type"] == "prompt" + assert automation.preset_metadata["prompt"] == "Test prompt for DB"