Skip to content
Closed
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
28 changes: 28 additions & 0 deletions migrations/versions/022_add_automation_description.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Add automations.description: optional human-facing summary.

Revision ID: 022
Revises: 021
Create Date: 2026-08-26
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op


revision: str = "022"
down_revision: str = "021"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"automations",
sa.Column("description", sa.Text(), nullable=True),
)


def downgrade() -> None:
op.drop_column("automations", "description")
4 changes: 4 additions & 0 deletions openhands/automation/capabilities_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
"presetPlugin",
"presetPrompt",
"repoClone",
# Automations carry an editable human-facing description. The UI gates
# the field on this rather than the manifest alone: a PATCH carrying
# `description` to an older service 422s the whole request.
"automationDescription",
)

# Tags Pydantic inserts into an error location for the trigger union.
Expand Down
1 change: 1 addition & 0 deletions openhands/automation/git_sync/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ async def _validate_and_resolve_fields(
"enabled": True if fields.get("enabled") is None else bool(fields["enabled"]),
"prompt": fields.get("prompt"),
"preset_metadata": fields.get("preset_metadata"),
"description": fields.get("description"),
"tarball_path": tarball_path,
}

Expand Down
5 changes: 5 additions & 0 deletions openhands/automation/git_sync/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ def _automation_yaml_fields(
# already-synced automation.yaml just to add an empty list.
if tarball_executables:
fields["tarball_executables"] = tarball_executables
# Same reasoning: emitting `description: null` for automations that have
# none would rewrite every automation.yaml (and its content hash) on the
# first cycle, pushing one no-op commit per already-synced automation.
if automation.description is not None:
fields["description"] = automation.description
return fields


Expand Down
4 changes: 4 additions & 0 deletions openhands/automation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ class Automation(Base):
String(256), nullable=True
)

# Optional human-facing description, shown wherever the automation is
# listed. Falls back to the prompt in the UI when absent.
description: Mapped[str | None] = mapped_column(Text, nullable=True)

# Optional prompt (set when created via preset endpoints)
prompt: Mapped[str | None] = mapped_column(Text, nullable=True)

Expand Down
18 changes: 18 additions & 0 deletions openhands/automation/preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ class CreatePromptAutomationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str = Field(..., min_length=1, max_length=500)
description: str | None = Field(
default=None,
max_length=2000,
description=(
"Optional human-facing description, shown wherever the automation "
"is listed. Falls back to the prompt in the UI when absent."
),
)
prompt: str = Field(
...,
min_length=1,
Expand Down Expand Up @@ -531,6 +539,7 @@ async def create_automation_from_prompt(
user_id=user.user_id,
org_id=user.org_id,
name=body.name,
description=body.description,
prompt=body.prompt,
preset_metadata=preset_metadata,
model=model,
Expand Down Expand Up @@ -618,6 +627,14 @@ class CreatePluginAutomationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str = Field(..., min_length=1, max_length=500)
description: str | None = Field(
default=None,
max_length=2000,
description=(
"Optional human-facing description, shown wherever the automation "
"is listed. Falls back to the prompt in the UI when absent."
),
)
plugins: list[PluginSource] | None = Field(
default=None,
description="Plugin(s) to load. Mutually exclusive with 'variants'.",
Expand Down Expand Up @@ -961,6 +978,7 @@ async def create_automation_from_plugin(
user_id=user.user_id,
org_id=user.org_id,
name=body.name,
description=body.description,
prompt=body.prompt,
preset_metadata=preset_metadata,
model=model,
Expand Down
1 change: 1 addition & 0 deletions openhands/automation/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ async def create_automation(
user_id=user.user_id,
org_id=user.org_id,
name=body.name,
description=body.description,
model=model,
preset_metadata=preset_metadata,
trigger=body.trigger.model_dump(),
Expand Down
17 changes: 17 additions & 0 deletions openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,14 @@ class CreateAutomationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str = Field(..., min_length=1, max_length=500)
description: str | None = Field(
default=None,
max_length=2000,
description=(
"Optional human-facing description, shown wherever the automation "
"is listed. Falls back to the prompt in the UI when absent."
),
)
model: str | None = Field(
default=None,
min_length=1,
Expand Down Expand Up @@ -387,6 +395,14 @@ class UpdateAutomationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

name: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = Field(
default=None,
max_length=2000,
description=(
"Optional human-facing description, shown wherever the automation "
"is listed. Falls back to the prompt in the UI when absent."
),
)
model: str | None = Field(
default=None,
min_length=1,
Expand Down Expand Up @@ -729,6 +745,7 @@ class AutomationResponse(BaseModel):
model: str | None

name: str
description: str | None
prompt: str | None
preset_metadata: dict | None = None
trigger: dict
Expand Down
1 change: 1 addition & 0 deletions tests/test_capabilities_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async def test_configured_deployment_advertises_event_support(
assert "webhookDelivery" in body["features"]
assert "kvStore" in body["features"]
assert "customTarball" in body["features"]
assert "automationDescription" in body["features"]

async def test_advertises_the_configured_timeout_ceiling(
self, async_client, ready_deployment, monkeypatch
Expand Down
54 changes: 54 additions & 0 deletions tests/test_git_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,60 @@ def instrumented_read(directory):

assert read_slugs == ["automation-b"]

async def test_description_round_trips_through_git(
self,
sqlite_session_factory,
file_store,
git_settings,
service_settings,
origin,
):
"""A description set via the API lands in automation.yaml (with no key
when absent), and a git-side edit to it imports back on the next cycle.
"""
automation_id = await _create_internal_automation(
sqlite_session_factory, file_store
)
async with sqlite_session_factory() as session:
automation = await session.get(Automation, automation_id)
automation.description = "Weekly dependency report"
await mark_git_sync_dirty(session, automation)
await session.commit()

await run_sync_cycle(sqlite_session_factory, git_settings, service_settings)

editor_dir = origin.parent / "editor"
await ensure_repo(editor_dir, f"file://{origin}", "main", "", 30)
await pull(editor_dir, "main", "", 30)
yaml_path = (
editor_dir / "automations" / "my-first-automation" / "automation.yaml"
)
text = yaml_path.read_text()
assert "description: Weekly dependency report" in text
yaml_path.write_text(
text.replace(
"description: Weekly dependency report",
"description: Edited in git",
)
)
await commit_and_push(
editor_dir,
"automations",
"edit description",
"Human",
"human@example.com",
"main",
"",
30,
)

await run_sync_cycle(sqlite_session_factory, git_settings, service_settings)

async with sqlite_session_factory() as session:
automation = await session.get(Automation, automation_id)
assert automation is not None
assert automation.description == "Edited in git"

async def test_dirty_automation_wins_over_conflicting_git_edit(
self, sqlite_session_factory, file_store, git_settings, service_settings, origin
):
Expand Down
28 changes: 28 additions & 0 deletions tests/test_git_sync_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,34 @@ def test_automation_yaml_fields(self):
assert fields["prompt"] == "do the thing"
assert fields["tarball_source"] == {"type": "internal", "url": None}

def test_description_omitted_when_null(self):
"""Regression: emitting `description: null` for automations that have
none would rewrite every already-synced automation.yaml (and its
content hash) on the first cycle, pushing one no-op commit per
automation -- the same trap as the empty `tarball_executables` list.
"""
automation = _make_automation()
files = serialize_automation(automation, _make_tarball({"main.py": b"x"}))

import yaml

fields = yaml.safe_load(files["automation.yaml"])
assert "description" not in fields

def test_description_serialized_when_set(self):
automation = _make_automation(description="Weekly dependency report")
files = serialize_automation(automation, _make_tarball({"main.py": b"x"}))

import yaml

fields = yaml.safe_load(files["automation.yaml"])
assert fields["description"] == "Weekly dependency report"

# Round-trips through the importer's parsed field dict.
deserialized = deserialize_automation(files)
assert deserialized is not None
assert deserialized.fields["description"] == "Weekly dependency report"

def test_external_url_skips_tarball_dir(self):
automation = _make_automation(tarball_path="https://example.com/x.tar.gz")
files = serialize_automation(automation, None)
Expand Down
74 changes: 74 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,48 @@ async def test_create_automation_success(
assert automation is not None
assert automation.telemetry_distinct_id == "ph-fe-creator"

async def test_create_automation_with_description(
self, async_client, async_session
):
"""A supplied description is stored and echoed; omitted stays null."""
payload = {
"name": "My Test Automation",
"description": "Weekly dependency report",
"trigger": {"type": "cron", "schedule": "0 9 * * 5", "timezone": "UTC"},
"tarball_path": "s3://bucket/path/to/code.tar.gz",
"entrypoint": "uv run script.py",
}

response = await async_client.post("/api/automation/v1", json=payload)

assert response.status_code == 201
data = response.json()
assert data["description"] == "Weekly dependency report"
automation = await async_session.get(Automation, uuid.UUID(data["id"]))
assert automation is not None
assert automation.description == "Weekly dependency report"

# Omitted -> null, so undescribed automations look exactly like today's.
payload.pop("description")
response = await async_client.post("/api/automation/v1", json=payload)

assert response.status_code == 201
assert response.json()["description"] is None

async def test_create_automation_rejects_oversized_description(self, async_client):
"""Description is capped at 2000 characters."""
payload = {
"name": "My Test Automation",
"description": "x" * 2001,
"trigger": {"type": "cron", "schedule": "0 9 * * 5", "timezone": "UTC"},
"tarball_path": "s3://bucket/path/to/code.tar.gz",
"entrypoint": "uv run script.py",
}

response = await async_client.post("/api/automation/v1", json=payload)

assert response.status_code == 422

async def test_create_automation_preset_metadata_is_null(self, async_client):
"""Custom SDK automations are created without preset metadata."""
payload = {
Expand Down Expand Up @@ -1053,6 +1095,38 @@ async def test_update_automation_name(self, async_client, async_session):
assert data["name"] == "Updated Name"
assert data["entrypoint"] == "uv run script.py"

async def test_update_automation_description(self, async_client, async_session):
"""PATCH sets and clears the description like any other field."""
automation = Automation(
user_id=TEST_USER_ID,
org_id=TEST_ORG_ID,
name="Original Name",
trigger={"type": "cron", "schedule": "0 9 * * *", "timezone": "UTC"},
tarball_path="s3://bucket/path/to/code.tar.gz",
entrypoint="uv run script.py",
)
async_session.add(automation)
await async_session.commit()

response = await async_client.patch(
f"/api/automation/v1/{automation.id}",
json={"description": "Runs weekly"},
)

assert response.status_code == 200
assert response.json()["description"] == "Runs weekly"

# Explicit null clears it -- not a no-op.
response = await async_client.patch(
f"/api/automation/v1/{automation.id}",
json={"description": None},
)

assert response.status_code == 200
assert response.json()["description"] is None
await async_session.refresh(automation)
assert automation.description is None

async def test_update_automation_schedule(self, async_client, async_session):
"""PATCH updates the trigger schedule."""
automation = Automation(
Expand Down
1 change: 1 addition & 0 deletions tests/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ def _make_automation(self, **overrides: Any) -> AutomationResponse:
org_id=uuid.uuid4(),
model=None,
name="Test",
description=None,
prompt=None,
trigger={"type": "cron", "schedule": "0 9 * * 1", "timezone": "UTC"},
tarball_path="s3://bucket/key.tar.gz",
Expand Down
Loading