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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions automation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from datetime import datetime

from sqlalchemy import (
JSON,
Comment thread
malhotra5 marked this conversation as resolved.
BigInteger,
DateTime,
Enum,
Expand Down Expand Up @@ -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)
Comment thread
malhotra5 marked this conversation as resolved.

# Trigger config — for MVP, only cron is supported.
trigger: Mapped[dict] = mapped_column(JSONB, nullable=False)

Expand Down
19 changes: 19 additions & 0 deletions automation/preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions migrations/versions/005_add_preset_metadata_column.py
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
malhotra5 marked this conversation as resolved.
sa.Column("preset_metadata", sa.JSON(), nullable=True),
)


def downgrade() -> None:
op.drop_column("automations", "preset_metadata")
196 changes: 196 additions & 0 deletions tests/test_preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading