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
25 changes: 25 additions & 0 deletions migrations/versions/033_add_automation_description.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Add an optional human-readable automation description.

Revision ID: 033
Revises: 032
Create Date: 2026-09-28
"""

from collections.abc import Sequence

from alembic import op
from sqlalchemy import Column, Text


revision: str = "033"
down_revision: str = "032"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


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


def downgrade() -> None:
op.drop_column("automations", "description")
1 change: 1 addition & 0 deletions openhands/automation/capabilities_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
# Features every deployment has: they come from the SDK code the service
# packages into a run, not from configuration.
_STATIC_FEATURES = (
"automationDescription",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The automationDescription capability advertises support for a field the service only accepts on the create/update/response schemas. The draft schemas in openhands/automation/draft_schemas.py (_BaseDraftBody, shared by POST /v1/validate and the /v1/drafts CRUD) still use extra="forbid" without a description field, even though that schema family is documented as mirroring schemas.py. A preflight body built from the same buildCreatePayload that now carries description therefore comes back valid: false with an extra_forbidden error on description, and manifest-setup-dialog.tsx blocks Continue on any preflight error — so a template-launched automation cannot carry the catalog description this feature is meant to deliver. Add description: str | None = Field(default=None, max_length=2000) to _BaseDraftBody so preflight and the drafts CRUD accept what the create endpoints accept.

"automationDrafts",
"conversationDispatch",
# Can run a client-supplied tarball, so an entry may ship a script bundle.
Expand Down
3 changes: 3 additions & 0 deletions openhands/automation/draft_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ async def _materialize_raw_draft(
preset_metadata = {"template": body.template.model_dump(exclude_none=True)}
return {
"name": body.name,
"description": body.description,
"prompt": None,
"preset_metadata": preset_metadata,
"model": resolve_model_profile_for_user(body.model, user),
Expand Down Expand Up @@ -375,6 +376,7 @@ async def _materialize_prompt_draft(
preset_metadata["template"] = body.template.model_dump(exclude_none=True)
return {
"name": body.name,
"description": body.description,
"prompt": body.prompt,
"preset_metadata": preset_metadata,
"model": resolve_model_profile_for_user(body.model, user),
Expand Down Expand Up @@ -425,6 +427,7 @@ async def _materialize_plugin_draft(
preset_metadata["template"] = body.template.model_dump(exclude_none=True)
return {
"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/draft_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class _BaseDraftBody(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)
model: str | None = Field(
default=None,
min_length=1,
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 @@ -551,6 +551,7 @@ async def _validate_and_resolve_fields(

return {
"name": name,
"description": fields.get("description"),
"model": fields.get("model"),
"agent_profile_id": agent_profile_id,
"trigger": trigger.model_dump(),
Expand Down
4 changes: 4 additions & 0 deletions openhands/automation/git_sync/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ def _automation_yaml_fields(
"url": None if tarball_is_internal else automation.tarball_path,
},
}
# Omit null descriptions so enabling this field does not rewrite every
# existing automation on the first sync cycle.
if automation.description is not None:
fields["description"] = automation.description
# Omitted when nothing is executable, so upgrading doesn't rewrite every
# already-synced automation.yaml just to add an empty list.
if tarball_executables:
Expand Down
1 change: 1 addition & 0 deletions openhands/automation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class Automation(Base):
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
org_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
name: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
telemetry_distinct_id: Mapped[str | None] = mapped_column(
String(256), nullable=True
)
Expand Down
4 changes: 4 additions & 0 deletions openhands/automation/preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ 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)
prompt: str = Field(
...,
min_length=1,
Expand Down Expand Up @@ -585,6 +586,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 @@ -674,6 +676,7 @@ 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)
plugins: list[PluginSource] | None = Field(
default=None,
description="Plugin(s) to load. Mutually exclusive with 'variants'.",
Expand Down Expand Up @@ -1049,6 +1052,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 @@ -263,6 +263,7 @@ async def create_automation(
user_id=user.user_id,
org_id=user.org_id,
name=body.name,
description=body.description,
model=model,
agent_profile_id=body.agent_profile_id,
preset_metadata=preset_metadata,
Expand Down
3 changes: 3 additions & 0 deletions openhands/automation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ class CreateAutomationRequest(BaseModel):
)

name: str = Field(..., min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=2000)
model: str | None = Field(
default=None,
min_length=1,
Expand Down Expand Up @@ -631,6 +632,7 @@ class UpdateAutomationRequest(BaseModel):
)

name: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = Field(default=None, max_length=2000)
model: str | None = Field(
default=None,
min_length=1,
Expand Down Expand Up @@ -1040,6 +1042,7 @@ class AutomationResponse(BaseModel):
model: str | None

name: str
description: str | None = None
prompt: str | None
preset_metadata: dict | None = None
trigger: dict
Expand Down
13 changes: 13 additions & 0 deletions tests/test_capabilities_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,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 Expand Up @@ -252,6 +253,18 @@ async def test_valid_draft_reports_no_errors(self, async_client):
"sampleEventMatched": None,
}

async def test_description_is_valid_in_preflight(self, async_client):
response = await async_client.post(
VALIDATE_URL,
json=preflight(
{**CRON_DRAFT, "description": "Summarizes weekly support trends."}
),
)

assert response.status_code == 200
assert response.json()["valid"] is True
assert response.json()["errors"] == []

@pytest.mark.parametrize(
("draft", "expected_error"),
[
Expand Down
5 changes: 5 additions & 0 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,11 @@ def test_migrations_run_on_sqlite(self, monkeypatch):
assert "custom_webhooks" in tables
assert "alembic_version" in tables

automation_columns = {
column["name"] for column in inspector.get_columns("automations")
}
assert "description" in automation_columns

run_indexes = {
index["name"] for index in inspector.get_indexes("automation_runs")
}
Expand Down
20 changes: 20 additions & 0 deletions tests/test_draft_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ async def test_create_incomplete_draft_saves_partial_body(async_client, async_se
assert draft.materialized_automation_id is None


async def test_prompt_draft_accepts_description(async_client):
response = await async_client.post(
"/api/automation/v1/drafts",
json={
"endpoint": "/v1/preset/prompt",
"draft": {
"name": "Described draft",
"description": "Summarizes weekly support trends.",
},
},
)

assert response.status_code == 201
assert response.json()["draft"]["description"] == (
"Summarizes weekly support trends."
)


async def test_create_draft_rejects_unknown_endpoint_fields(async_client):
response = await async_client.post(
"/api/automation/v1/drafts",
Expand Down Expand Up @@ -168,6 +186,7 @@ async def test_dispatchable_prompt_draft_materializes_disabled_draft_and_manual_
"endpoint": "/v1/preset/prompt",
"draft": {
"name": "Runnable draft",
"description": "Summarizes weekly support trends.",
"prompt": "Write a short greeting.",
"trigger": {"type": "cron", "schedule": "0 9 * * *"},
},
Expand Down Expand Up @@ -195,6 +214,7 @@ async def test_dispatchable_prompt_draft_materializes_disabled_draft_and_manual_
assert automation is not None
assert automation.enabled is False
assert automation.state == AutomationState.DRAFT
assert automation.description == "Summarizes weekly support trends."
assert automation.prompt == "Write a short greeting."
assert automation.tarball_path.startswith("oh-internal://uploads/")

Expand Down
12 changes: 12 additions & 0 deletions tests/test_git_sync_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def test_uncompressed_tarball_is_extracted_too(self):

def test_automation_yaml_fields(self):
automation = _make_automation(
description="Summarizes weekly support trends.",
prompt="do the thing",
observability_associations={
"scm.repository.full_name": "repository.full_name",
Expand All @@ -137,13 +138,22 @@ def test_automation_yaml_fields(self):

fields = yaml.safe_load(files["automation.yaml"])
assert fields["name"] == "My Automation"
assert fields["description"] == "Summarizes weekly support trends."
assert fields["prompt"] == "do the thing"
assert fields["observability_associations"] == {
"scm.repository.full_name": "repository.full_name",
"scm.pull_request.number": "pull_request.number",
}
assert fields["tarball_source"] == {"type": "internal", "url": None}

def test_null_description_is_omitted(self):
automation = _make_automation(description=None)

files = serialize_automation(automation, _make_tarball({"main.py": b"x"}))

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

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 Expand Up @@ -184,6 +194,7 @@ def test_absolute_path_member_is_rewritten_relative(self):
class TestDeserializeAutomation:
def test_roundtrip(self):
automation = _make_automation(
description="Runs the weekly support report.",
observability_associations={
"scm.repository.full_name": "repository.full_name",
}
Expand All @@ -195,6 +206,7 @@ def test_roundtrip(self):

assert result is not None
assert result.fields["name"] == "My Automation"
assert result.fields["description"] == "Runs the weekly support report."
assert result.fields["entrypoint"] == "python main.py"
assert result.fields["observability_associations"] == {
"scm.repository.full_name": "repository.full_name",
Expand Down
57 changes: 57 additions & 0 deletions tests/test_preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,63 @@ async def test_create_from_prompt_storage_failure(
# --- Plugin Preset Tests ---


class TestPresetAutomationDescriptionValidation:
@pytest.mark.parametrize(
("request_cls_name", "extra_fields"),
[
pytest.param("CreatePromptAutomationRequest", {}, id="prompt"),
pytest.param(
"CreatePluginAutomationRequest",
{"plugins": [{"source": "github:owner/repo"}]},
id="plugin",
),
],
)
def test_accepts_description(self, request_cls_name, extra_fields):
from openhands.automation import preset_router

request_cls = getattr(preset_router, request_cls_name)
request = request_cls.model_validate(
{
"name": "Test",
"description": "Summarizes weekly support trends.",
"prompt": "Test prompt",
"trigger": {"type": "cron", "schedule": "0 0 * * *"},
**extra_fields,
}
)

assert request.description == "Summarizes weekly support trends."

@pytest.mark.parametrize(
("request_cls_name", "extra_fields"),
[
pytest.param("CreatePromptAutomationRequest", {}, id="prompt"),
pytest.param(
"CreatePluginAutomationRequest",
{"plugins": [{"source": "github:owner/repo"}]},
id="plugin",
),
],
)
def test_rejects_description_over_2000_characters(
self, request_cls_name, extra_fields
):
from openhands.automation import preset_router

request_cls = getattr(preset_router, request_cls_name)
with pytest.raises(ValueError, match="at most 2000 characters"):
request_cls.model_validate(
{
"name": "Test",
"description": "x" * 2001,
"prompt": "Test prompt",
"trigger": {"type": "cron", "schedule": "0 0 * * *"},
**extra_fields,
}
)


class TestCreatePluginAutomationRequestValidation:
"""Tests for CreatePluginAutomationRequest validation."""

Expand Down
34 changes: 34 additions & 0 deletions tests/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,40 @@ def test_rejects_invalid_association_expression(self, request_cls, base_payload)
)


class TestAutomationDescriptionValidation:
_CREATE_PAYLOAD: ClassVar[dict[str, Any]] = {
"name": "Description validation",
"trigger": {"type": "cron", "schedule": "0 9 * * 1", "timezone": "UTC"},
"tarball_path": "https://example.com/automation.tar.gz",
"entrypoint": "python main.py",
}

@pytest.mark.parametrize(
("request_cls", "base_payload"),
[
pytest.param(CreateAutomationRequest, _CREATE_PAYLOAD, id="create"),
pytest.param(UpdateAutomationRequest, {}, id="update"),
],
)
def test_accepts_description(self, request_cls, base_payload):
request = request_cls.model_validate(
{**base_payload, "description": "Summarizes weekly support trends."}
)

assert request.description == "Summarizes weekly support trends."

@pytest.mark.parametrize(
("request_cls", "base_payload"),
[
pytest.param(CreateAutomationRequest, _CREATE_PAYLOAD, id="create"),
pytest.param(UpdateAutomationRequest, {}, id="update"),
],
)
def test_rejects_description_over_2000_characters(self, request_cls, base_payload):
with pytest.raises(ValidationError, match="at most 2000 characters"):
request_cls.model_validate({**base_payload, "description": "x" * 2001})


class TestRunCompleteRequest:
def test_accepts_legacy_string_error(self):
request = RunCompleteRequest(status="FAILED", error="script crashed")
Expand Down
Loading