diff --git a/migrations/versions/022_add_automation_description.py b/migrations/versions/022_add_automation_description.py new file mode 100644 index 00000000..edee78f4 --- /dev/null +++ b/migrations/versions/022_add_automation_description.py @@ -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") diff --git a/openhands/automation/capabilities_router.py b/openhands/automation/capabilities_router.py index 3ac82e98..2dd429d7 100644 --- a/openhands/automation/capabilities_router.py +++ b/openhands/automation/capabilities_router.py @@ -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. diff --git a/openhands/automation/git_sync/loop.py b/openhands/automation/git_sync/loop.py index d20e5884..86494ad6 100644 --- a/openhands/automation/git_sync/loop.py +++ b/openhands/automation/git_sync/loop.py @@ -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, } diff --git a/openhands/automation/git_sync/serializer.py b/openhands/automation/git_sync/serializer.py index 6e035202..70d963cf 100644 --- a/openhands/automation/git_sync/serializer.py +++ b/openhands/automation/git_sync/serializer.py @@ -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 diff --git a/openhands/automation/models.py b/openhands/automation/models.py index af1e7d83..46dd5ff4 100644 --- a/openhands/automation/models.py +++ b/openhands/automation/models.py @@ -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) diff --git a/openhands/automation/preset_router.py b/openhands/automation/preset_router.py index 12d6267f..7f65562b 100644 --- a/openhands/automation/preset_router.py +++ b/openhands/automation/preset_router.py @@ -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, @@ -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, @@ -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'.", @@ -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, diff --git a/openhands/automation/router.py b/openhands/automation/router.py index d6265562..49b7eea5 100644 --- a/openhands/automation/router.py +++ b/openhands/automation/router.py @@ -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(), diff --git a/openhands/automation/schemas.py b/openhands/automation/schemas.py index 65e54da1..1035b2a3 100644 --- a/openhands/automation/schemas.py +++ b/openhands/automation/schemas.py @@ -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, @@ -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, @@ -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 diff --git a/tests/test_capabilities_router.py b/tests/test_capabilities_router.py index 605a3202..3bff9775 100644 --- a/tests/test_capabilities_router.py +++ b/tests/test_capabilities_router.py @@ -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 diff --git a/tests/test_git_sync.py b/tests/test_git_sync.py index 8c6f6fe9..4ca025a8 100644 --- a/tests/test_git_sync.py +++ b/tests/test_git_sync.py @@ -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 ): diff --git a/tests/test_git_sync_serializer.py b/tests/test_git_sync_serializer.py index 9bcaab35..94499ade 100644 --- a/tests/test_git_sync_serializer.py +++ b/tests/test_git_sync_serializer.py @@ -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) diff --git a/tests/test_router.py b/tests/test_router.py index c9178933..f822a22e 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -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 = { @@ -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( diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 80d3f079..426c500e 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -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",