mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
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:
parent
8fb65d7188
commit
c0232fdcfe
13 changed files with 18 additions and 176 deletions
|
|
@ -1,42 +1,15 @@
|
||||||
"""HTTP routes for automation runs (dispatch + history)."""
|
"""HTTP routes for automation run history."""
|
||||||
|
|
||||||
from __future__ import annotations
|
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, RunList, RunSummary
|
||||||
|
|
||||||
from app.automations.schemas.api import (
|
|
||||||
RunDetail,
|
|
||||||
RunDispatched,
|
|
||||||
RunList,
|
|
||||||
RunSummary,
|
|
||||||
)
|
|
||||||
from app.automations.services import RunService, get_run_service
|
from app.automations.services import RunService, get_run_service
|
||||||
|
|
||||||
router = APIRouter()
|
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(
|
@router.get(
|
||||||
"/automations/{automation_id}/runs",
|
"/automations/{automation_id}/runs",
|
||||||
response_model=RunList,
|
response_model=RunList,
|
||||||
|
|
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ class AutomationTrigger(BaseModel, TimestampMixin):
|
||||||
|
|
||||||
# Precomputed next fire moment in UTC; advanced after each fire by the
|
# Precomputed next fire moment in UTC; advanced after each fire by the
|
||||||
# schedule tick. NULL means the trigger has never been scheduled (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)
|
next_fire_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
|
||||||
automation = relationship("Automation", back_populates="triggers")
|
automation = relationship("Automation", back_populates="triggers")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from .automation import (
|
||||||
AutomationSummary,
|
AutomationSummary,
|
||||||
AutomationUpdate,
|
AutomationUpdate,
|
||||||
)
|
)
|
||||||
from .run import RunDetail, RunDispatched, RunList, RunSummary
|
from .run import RunDetail, RunList, RunSummary
|
||||||
from .trigger import TriggerCreate, TriggerDetail, TriggerUpdate
|
from .trigger import TriggerCreate, TriggerDetail, TriggerUpdate
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -19,7 +19,6 @@ __all__ = [
|
||||||
"AutomationSummary",
|
"AutomationSummary",
|
||||||
"AutomationUpdate",
|
"AutomationUpdate",
|
||||||
"RunDetail",
|
"RunDetail",
|
||||||
"RunDispatched",
|
|
||||||
"RunList",
|
"RunList",
|
||||||
"RunSummary",
|
"RunSummary",
|
||||||
"TriggerCreate",
|
"TriggerCreate",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Response schemas for run sub-resources and run dispatch."""
|
"""Response schemas for run sub-resources."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -40,10 +40,3 @@ class RunList(BaseModel):
|
||||||
|
|
||||||
items: list[RunSummary]
|
items: list[RunSummary]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class RunDispatched(BaseModel):
|
|
||||||
"""Response of a successful run dispatch."""
|
|
||||||
|
|
||||||
run_id: int
|
|
||||||
status: RunStatus
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,25 @@
|
||||||
"""``RunService`` — dispatch and history of automation runs."""
|
"""``RunService`` — read-only access to automation run history."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.automation import Automation
|
||||||
from app.automations.persistence.models.run import AutomationRun
|
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.db import Permission, User, get_async_session
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.rbac import check_permission
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
|
|
||||||
class RunService:
|
class RunService:
|
||||||
"""Lifecycle of the ``AutomationRun`` resource."""
|
"""Read-only access to ``AutomationRun`` history."""
|
||||||
|
|
||||||
def __init__(self, *, session: AsyncSession, user: User) -> None:
|
def __init__(self, *, session: AsyncSession, user: User) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
self.user = user
|
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(
|
async def list(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class TriggerService:
|
||||||
trigger.enabled = data["enabled"]
|
trigger.enabled = data["enabled"]
|
||||||
|
|
||||||
# Recompute next_fire_at when schedule timing changed or the trigger was
|
# 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:
|
if trigger.type == TriggerType.SCHEDULE:
|
||||||
trigger.next_fire_at = _initial_next_fire(
|
trigger.next_fire_at = _initial_next_fire(
|
||||||
trigger.type, trigger.params, trigger.enabled
|
trigger.type, trigger.params, trigger.enabled
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Triggers domain: registry surface + built-in trigger packages.
|
"""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.
|
self-registers at import time via its ``definition`` module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -17,4 +17,4 @@ __all__ = [
|
||||||
]
|
]
|
||||||
|
|
||||||
# Built-in triggers self-register at import time.
|
# Built-in triggers self-register at import time.
|
||||||
from . import manual, schedule # noqa: E402, F401
|
from . import schedule # noqa: E402, F401
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -120,4 +120,4 @@ router.include_router(youtube_router) # YouTube playlist resolution
|
||||||
router.include_router(prompts_router)
|
router.include_router(prompts_router)
|
||||||
router.include_router(memory_router) # User personal memory (memory.md style)
|
router.include_router(memory_router) # User personal memory (memory.md style)
|
||||||
router.include_router(team_memory_router) # Search-space team memory
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue