Skip to content
Open
60 changes: 60 additions & 0 deletions migrations/versions/023_add_state_and_trigger_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Add automation state and run trigger_source.

Revision ID: 023
Revises: 022
Create Date: 2026-09-10
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op


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


def upgrade() -> None:
op.add_column(
"automations",
sa.Column(
"state",
sa.String(length=20),
nullable=False,
server_default="ACTIVE",
),
)
op.create_index("ix_automations_state", "automations", ["state"])
# Backfill from the legacy enabled flag so existing rows match the new
# state model on day one.
op.execute(
"UPDATE automations SET state = CASE "
"WHEN enabled THEN 'ACTIVE' ELSE 'INACTIVE' END"
)

op.add_column(
"automation_runs",
sa.Column("trigger_source", sa.String(length=32), nullable=True),
)
op.create_index(
"ix_automation_runs_trigger_source", "automation_runs", ["trigger_source"]
)
op.create_index(
"ix_automation_runs_status_trigger_source",
"automation_runs",
["status", "trigger_source"],
)


def downgrade() -> None:
op.drop_index(
"ix_automation_runs_status_trigger_source", table_name="automation_runs"
)
op.drop_index("ix_automation_runs_trigger_source", table_name="automation_runs")
op.drop_column("automation_runs", "trigger_source")

op.drop_index("ix_automations_state", table_name="automations")
op.drop_column("automations", "state")
11 changes: 9 additions & 2 deletions openhands/automation/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from typing import Any

import httpx
from sqlalchemy import select
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload

Expand All @@ -40,6 +40,7 @@
Automation,
AutomationRun,
AutomationRunStatus,
AutomationState,
TarballUpload,
)
from openhands.automation.subjects import conversation_id_for
Expand Down Expand Up @@ -137,8 +138,14 @@ async def _poll_pending_runs(
.options(selectinload(AutomationRun.automation))
.where(
AutomationRun.status == AutomationRunStatus.PENDING,
Automation.enabled.is_(True),
Automation.deleted_at.is_(None),
or_(
AutomationRun.trigger_source == "manual",
and_(
Automation.enabled.is_(True),
Automation.state == AutomationState.ACTIVE,
),
),
)
.order_by(AutomationRun.created_at.asc())
.limit(batch_size)
Expand Down
18 changes: 14 additions & 4 deletions openhands/automation/git_sync/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from openhands.automation.models import (
Automation,
AutomationGitSyncState,
AutomationState,
TarballUpload,
UploadStatus,
)
Expand Down Expand Up @@ -474,6 +475,16 @@ async def _validate_and_resolve_fields(
session, fields, deserialized, slug, existing, pending_storage_deletes
)

enabled = True if fields.get("enabled") is None else bool(fields["enabled"])
state = fields.get("state")
if state == AutomationState.DRAFT.value:
automation_state = AutomationState.DRAFT
enabled = False
else:
automation_state = (
AutomationState.ACTIVE if enabled else AutomationState.INACTIVE
)

return {
"name": name,
"model": fields.get("model"),
Expand All @@ -482,10 +493,8 @@ async def _validate_and_resolve_fields(
"setup_script_path": setup_script_path,
"timeout": timeout,
"keep_alive": fields.get("keep_alive"),
# `dict.get`'s default only applies when the key is absent. A hand edit
# leaving "enabled:" empty is valid YAML parsing to None, and
# bool(None) would silently disable a live automation on import.
"enabled": True if fields.get("enabled") is None else bool(fields["enabled"]),
"enabled": enabled,
"state": automation_state,
"prompt": fields.get("prompt"),
"preset_metadata": fields.get("preset_metadata"),
"tarball_path": tarball_path,
Expand Down Expand Up @@ -718,6 +727,7 @@ async def _import_from_git(
automation = await session.get(Automation, state.automation_id)
if automation is not None and automation.deleted_at is None:
automation.enabled = False
automation.state = AutomationState.INACTIVE
automation.deleted_at = utcnow()
result.deleted_in_db += 1
logger.info("Soft-deleted automation %s (removed from git)", automation.id)
Expand Down
1 change: 1 addition & 0 deletions openhands/automation/git_sync/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ def _automation_yaml_fields(
"timeout": automation.timeout,
"keep_alive": automation.keep_alive,
"enabled": automation.enabled,
"state": getattr(automation.state, "value", automation.state),
"prompt": automation.prompt,
"preset_metadata": automation.preset_metadata,
"tarball_source": {
Expand Down
26 changes: 25 additions & 1 deletion openhands/automation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ class AutomationRunStatus(enum.Enum):
SKIPPED = "SKIPPED"


class AutomationState(enum.Enum):
"""State of an automation definition."""

ACTIVE = "ACTIVE"
INACTIVE = "INACTIVE"
DRAFT = "DRAFT"


class Automation(Base):
"""An automation definition: what to run and when to trigger it."""

Expand Down Expand Up @@ -94,9 +102,19 @@ class Automation(Base):
# means the automation service owns explicit cleanup.
keep_alive: Mapped[bool | None] = mapped_column(default=None, nullable=True)

# Whether the automation is enabled (can be triggered)
# Deprecated: use state instead. Kept for backwards
# compatibility; only ACTIVE rows have enabled=True. Will be removed in a
# future release.
enabled: Mapped[bool] = mapped_column(default=True, nullable=False, index=True)

state: Mapped[AutomationState] = mapped_column(
Enum(AutomationState, native_enum=False, length=20),
nullable=False,
default=AutomationState.ACTIVE,
server_default=AutomationState.ACTIVE.value,
index=True,
)

# Current disabled-state metadata. AutomationDisableEvent keeps history.
disabled_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
disabled_detail: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
Expand Down Expand Up @@ -218,6 +236,11 @@ class AutomationRun(Base):
# local mode). Set immediately after `_start_bash` returns.
bash_command_id: Mapped[str | None] = mapped_column(String(64), nullable=True)

# How this run was created: manual, cron, event, or null for legacy rows.
trigger_source: Mapped[str | None] = mapped_column(
String(32), nullable=True, index=True
)

# Event payload for event-triggered runs (JSON)
# Contains the webhook payload that triggered this run.
# For GitHub events: model_dump() of the parsed Pydantic event
Expand Down Expand Up @@ -258,6 +281,7 @@ class AutomationRun(Base):
Index("ix_automation_runs_status", "status"),
Index("ix_automation_runs_status_created_at", "status", "created_at"),
Index("ix_automation_runs_status_timeout_at", "status", "timeout_at"),
Index("ix_automation_runs_status_trigger_source", "status", "trigger_source"),
# Partial: only live subjects are ever looked up, and only
# `continue_conversation` runs set one.
Index(
Expand Down
37 changes: 34 additions & 3 deletions openhands/automation/preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,17 @@
from openhands.automation.constants import MODEL_PROFILE_PATTERN
from openhands.automation.db import get_session
from openhands.automation.git_sync import mark_git_sync_dirty
from openhands.automation.models import Automation, TarballUpload, UploadStatus
from openhands.automation.models import (
Automation,
TarballUpload,
UploadStatus,
)
from openhands.automation.schemas import (
AutomationResponse,
AutomationState,
TemplateProvenance,
Trigger,
normalize_automation_state_enabled,
)
from openhands.automation.storage import FileStore, ObjectNotFoundError, get_file_store
from openhands.automation.telemetry import (
Expand All @@ -52,6 +58,10 @@
)
from openhands.automation.utils import utcnow
from openhands.automation.utils.model_profiles import resolve_model_profile_for_user
from openhands.automation.utils.state import (
automation_state_enabled,
model_automation_state,
)
from openhands.automation.utils.tarball_validation import (
build_internal_url,
build_upload_storage_path,
Expand All @@ -74,6 +84,7 @@

router = APIRouter(prefix="/v1/preset", tags=["Presets"])


_require_manage_automations = require_permission("manage_automations")

# Preset files directories
Expand Down Expand Up @@ -202,6 +213,13 @@ class CreatePromptAutomationRequest(BaseModel):
default=True,
description="Whether the automation starts enabled.",
)
state: AutomationState | None = Field(
default=None,
description=(
"First-class automation state. DRAFT/INACTIVE rows are not "
"triggered automatically."
),
)

@field_validator("timeout")
@classmethod
Expand All @@ -212,6 +230,7 @@ def validate_timeout(cls, v: int | None) -> int | None:
@classmethod
def normalize_repos(cls, data: Any) -> Any:
"""Normalize repos to always be a list if provided."""
data = normalize_automation_state_enabled(data)
if isinstance(data, dict) and "repos" in data and data["repos"] is not None:
repos = data["repos"]
if isinstance(repos, (str, dict)):
Expand Down Expand Up @@ -482,6 +501,7 @@ async def create_automation_from_prompt(
return AutomationResponse.model_validate(existing)

model = resolve_model_profile_for_user(body.model, user)
state = model_automation_state(body.state, body.enabled)

# 1. Generate tarball with SDK code, prompt, and optional repos config
tarball_content = _generate_tarball(body.prompt, repos=body.repos)
Expand Down Expand Up @@ -551,7 +571,8 @@ async def create_automation_from_prompt(
entrypoint=_get_preset_entrypoint(),
timeout=default_automation_timeout(body.timeout),
keep_alive=body.keep_alive,
enabled=body.enabled,
enabled=automation_state_enabled(state),
state=state,
telemetry_distinct_id=get_request_telemetry_context(
request
).frontend_distinct_id,
Expand Down Expand Up @@ -706,6 +727,13 @@ class CreatePluginAutomationRequest(BaseModel):
default=True,
description="Whether the automation starts enabled.",
)
state: AutomationState | None = Field(
default=None,
description=(
"First-class automation state. DRAFT/INACTIVE rows are not "
"triggered automatically."
),
)

@field_validator("timeout")
@classmethod
Expand All @@ -716,6 +744,7 @@ def validate_timeout(cls, v: int | None) -> int | None:
@classmethod
def normalize_plugins_and_repos(cls, data: dict) -> dict: # type: ignore[type-arg]
"""Normalize plugins and repos to always be lists."""
data = normalize_automation_state_enabled(data)
if isinstance(data, dict):
# Normalize plugins
if "plugins" in data and data["plugins"] is not None:
Expand Down Expand Up @@ -892,6 +921,7 @@ async def create_automation_from_plugin(
return AutomationResponse.model_validate(existing)

model = resolve_model_profile_for_user(body.model, user)
state = model_automation_state(body.state, body.enabled)
variants = _resolve_experiment_variant_models(
body.variants, user, default_model=model
)
Expand Down Expand Up @@ -984,7 +1014,8 @@ async def create_automation_from_plugin(
entrypoint=_get_preset_entrypoint(),
timeout=default_automation_timeout(body.timeout),
keep_alive=body.keep_alive,
enabled=body.enabled,
enabled=automation_state_enabled(state),
state=state,
telemetry_distinct_id=get_request_telemetry_context(
request
).frontend_distinct_id,
Expand Down
Loading
Loading