refactor(automations): park manual trigger pending Run-now redesign

Manual-as-a-standalone-trigger conflates "user clicks Run now" with the
trigger model and forces ad-hoc input plumbing on the caller. Remove the
unreachable surface so the tree reflects reality (schedule is the only
v1 trigger).

- Unregister `manual`: drop import from triggers/__init__.py
- Delete `app/automations/triggers/manual/`
- Drop `RunService.dispatch_manual` (RunService is now read-only)
- Drop `POST /automations/{id}/run` and `RunDispatched` schema
- Keep `TriggerType.MANUAL` Python + PG enum value (reserved, documented)
  to avoid an Alembic round-trip when Run-now is redesigned
This commit is contained in:
CREDO23 2026-05-27 22:29:51 +02:00
parent 8fb65d7188
commit c0232fdcfe
13 changed files with 18 additions and 176 deletions

View file

@ -1,42 +1,15 @@
"""HTTP routes for automation runs (dispatch + history)."""
"""HTTP routes for automation run history."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Body, Depends, Query, status
from app.automations.schemas.api import (
RunDetail,
RunDispatched,
RunList,
RunSummary,
)
from app.automations.schemas.api import RunDetail, RunList, RunSummary
from app.automations.services import RunService, get_run_service
router = APIRouter()
@router.post(
"/automations/{automation_id}/run",
response_model=RunDispatched,
status_code=status.HTTP_202_ACCEPTED,
)
async def run_automation_now(
automation_id: int,
inputs: dict[str, Any] | None = Body(default=None),
service: RunService = Depends(get_run_service),
) -> RunDispatched:
"""Fire a manual run.
``inputs`` is the runtime payload supplied by the caller; it is merged with
the manual trigger's ``static_inputs`` (static wins) and validated against
the automation's input schema.
"""
run = await service.dispatch_manual(automation_id=automation_id, runtime_inputs=inputs)
return RunDispatched(run_id=run.id, status=run.status)
@router.get(
"/automations/{automation_id}/runs",
response_model=RunList,

View file

@ -1,4 +1,9 @@
"""Trigger-kind discriminator. v1: schedule | manual; webhook/event in Phase 2/3."""
"""Trigger-kind discriminator.
v1 only registers ``schedule``. ``manual`` is reserved in the enum (mirrors the
postgres enum) but is intentionally unregistered pending a redesign of the
"Run now" UX.
"""
from __future__ import annotations

View file

@ -56,7 +56,7 @@ class AutomationTrigger(BaseModel, TimestampMixin):
# Precomputed next fire moment in UTC; advanced after each fire by the
# schedule tick. NULL means the trigger has never been scheduled (the
# tick self-heals on first sight). Manual triggers leave this NULL.
# tick self-heals on first sight).
next_fire_at = Column(TIMESTAMP(timezone=True), nullable=True)
automation = relationship("Automation", back_populates="triggers")

View file

@ -9,7 +9,7 @@ from .automation import (
AutomationSummary,
AutomationUpdate,
)
from .run import RunDetail, RunDispatched, RunList, RunSummary
from .run import RunDetail, RunList, RunSummary
from .trigger import TriggerCreate, TriggerDetail, TriggerUpdate
__all__ = [
@ -19,7 +19,6 @@ __all__ = [
"AutomationSummary",
"AutomationUpdate",
"RunDetail",
"RunDispatched",
"RunList",
"RunSummary",
"TriggerCreate",

View file

@ -1,4 +1,4 @@
"""Response schemas for run sub-resources and run dispatch."""
"""Response schemas for run sub-resources."""
from __future__ import annotations
@ -40,10 +40,3 @@ class RunList(BaseModel):
items: list[RunSummary]
total: int
class RunDispatched(BaseModel):
"""Response of a successful run dispatch."""
run_id: int
status: RunStatus

View file

@ -1,46 +1,25 @@
"""``RunService`` — dispatch and history of automation runs."""
"""``RunService`` — read-only access to automation run history."""
from __future__ import annotations
from typing import Any
from fastapi import Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.dispatch import DispatchError
from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.run import AutomationRun
from app.automations.triggers.manual import dispatch_manual_run
from app.db import Permission, User, get_async_session
from app.users import current_active_user
from app.utils.rbac import check_permission
class RunService:
"""Lifecycle of the ``AutomationRun`` resource."""
"""Read-only access to ``AutomationRun`` history."""
def __init__(self, *, session: AsyncSession, user: User) -> None:
self.session = session
self.user = user
async def dispatch_manual(
self,
*,
automation_id: int,
runtime_inputs: dict[str, Any] | None,
) -> AutomationRun:
"""Fire a manual run via the registered manual trigger."""
await self._authorize(automation_id, Permission.AUTOMATIONS_EXECUTE.value)
try:
return await dispatch_manual_run(
session=self.session,
automation_id=automation_id,
runtime_inputs=runtime_inputs,
)
except DispatchError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def list(
self,
*,

View file

@ -69,7 +69,7 @@ class TriggerService:
trigger.enabled = data["enabled"]
# Recompute next_fire_at when schedule timing changed or the trigger was
# toggled back on. Manual triggers always have NULL next_fire_at.
# toggled back on.
if trigger.type == TriggerType.SCHEDULE:
trigger.next_fire_at = _initial_next_fire(
trigger.type, trigger.params, trigger.enabled

View file

@ -1,6 +1,6 @@
"""Triggers domain: registry surface + built-in trigger packages.
Each trigger lives in its own subpackage (``manual/``, ``schedule/``, ...) and
Each trigger lives in its own subpackage (``schedule/``, ...) and
self-registers at import time via its ``definition`` module.
"""
@ -17,4 +17,4 @@ __all__ = [
]
# Built-in triggers self-register at import time.
from . import manual, schedule # noqa: E402, F401
from . import schedule # noqa: E402, F401

View file

@ -1,11 +0,0 @@
"""``manual`` trigger: fired by a user clicking ``Run now``."""
from __future__ import annotations
from .dispatch import dispatch_manual_run
from .params import ManualTriggerParams
__all__ = ["ManualTriggerParams", "dispatch_manual_run"]
# Side-effect: register on the triggers store.
from . import definition # noqa: E402, F401

View file

@ -1,15 +0,0 @@
"""``manual`` ``TriggerDefinition`` registration."""
from __future__ import annotations
from ..store import register_trigger
from ..types import TriggerDefinition
from .params import ManualTriggerParams
MANUAL_TRIGGER = TriggerDefinition(
type="manual",
description="Fire on a user-initiated 'Run now' invocation.",
params_model=ManualTriggerParams,
)
register_trigger(MANUAL_TRIGGER)

View file

@ -1,72 +0,0 @@
"""Manual ``Run now`` dispatch adapter: load + guard, then call generic dispatch."""
from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.dispatch import DispatchError, dispatch_run
from app.automations.persistence.enums.automation_status import AutomationStatus
from app.automations.persistence.enums.trigger_type import TriggerType
from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.run import AutomationRun
from app.automations.persistence.models.trigger import AutomationTrigger
async def dispatch_manual_run(
*,
session: AsyncSession,
automation_id: int,
runtime_inputs: dict[str, Any] | None,
) -> AutomationRun:
"""Find the automation + its enabled manual trigger, then run the generic dispatch.
``runtime_inputs`` is the caller-supplied payload (e.g. an HTTP body for a
"Run now" API call); it is merged with the trigger's ``static_inputs`` by
the generic dispatcher, with static winning on key collision.
"""
automation = await _load_automation(session, automation_id)
if automation is None:
raise DispatchError(f"automation {automation_id} not found")
if automation.status != AutomationStatus.ACTIVE:
raise DispatchError(
f"automation {automation_id} is {automation.status.value}, not active"
)
trigger = await _find_manual_trigger(session, automation_id)
if trigger is None:
raise DispatchError(
f"automation {automation_id} has no enabled manual trigger"
)
return await dispatch_run(
session=session,
automation=automation,
trigger=trigger,
runtime_inputs=runtime_inputs,
)
async def _load_automation(
session: AsyncSession, automation_id: int
) -> Automation | None:
stmt = select(Automation).where(Automation.id == automation_id)
return (await session.execute(stmt)).scalar_one_or_none()
async def _find_manual_trigger(
session: AsyncSession, automation_id: int
) -> AutomationTrigger | None:
stmt = (
select(AutomationTrigger)
.where(
AutomationTrigger.automation_id == automation_id,
AutomationTrigger.type == TriggerType.MANUAL,
AutomationTrigger.enabled.is_(True),
)
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()

View file

@ -1,9 +0,0 @@
"""``ManualTriggerParams`` — params for the ``manual`` trigger (empty in v1)."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class ManualTriggerParams(BaseModel):
model_config = ConfigDict(extra="forbid")

View file

@ -120,4 +120,4 @@ router.include_router(youtube_router) # YouTube playlist resolution
router.include_router(prompts_router)
router.include_router(memory_router) # User personal memory (memory.md style)
router.include_router(team_memory_router) # Search-space team memory
router.include_router(automations_router) # Automations (manual run-now)
router.include_router(automations_router) # Automations CRUD + run history