Merge commit '7972901f15' into dev_mod

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-29 20:28:12 -07:00
commit 80daf46fbf
74 changed files with 1681 additions and 234 deletions

View file

@ -21,4 +21,4 @@ __all__ = [
]
# Built-in actions self-register at import time.
from . import agent_task # noqa: F401
from . import builtin # noqa: F401

View file

@ -0,0 +1,5 @@
"""Built-in action types — each in its own subpackage, self-registering at import."""
from __future__ import annotations
from . import agent_task # noqa: F401

View file

@ -2,8 +2,8 @@
from __future__ import annotations
from ..store import register_action
from ..types import ActionDefinition
from ...store import register_action
from ...types import ActionDefinition
from .factory import build_handler
from .params import AgentTaskActionParams

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from ..types import ActionContext, ActionHandler
from ...types import ActionContext, ActionHandler
from .invoke import run_agent_task
from .params import AgentTaskActionParams

View file

@ -16,7 +16,7 @@ from app.agents.new_chat.mention_resolver import resolve_mentions, substitute_in
from app.db import ChatVisibility, async_session_maker
from app.schemas.new_chat import MentionedDocumentInfo
from ..types import ActionContext
from ...types import ActionContext
from .auto_decide import build_auto_decisions
from .dependencies import build_dependencies
from .finalize import extract_final_assistant_message

View file

@ -3,6 +3,6 @@
from __future__ import annotations
from .errors import DispatchError
from .run import dispatch_run
from .launch import launch_run
__all__ = ["DispatchError", "dispatch_run"]
__all__ = ["DispatchError", "launch_run"]

View file

@ -0,0 +1,43 @@
"""Merge and validate the inputs a run starts with."""
from __future__ import annotations
from typing import Any
import jsonschema
from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.schemas.definition.envelope import AutomationDefinition
from .errors import DispatchError
def prepare_inputs(
definition: AutomationDefinition,
trigger: AutomationTrigger,
runtime_inputs: dict[str, Any] | None,
) -> dict[str, Any]:
"""Merge ``trigger.static_inputs`` over ``runtime_inputs``, then validate.
Static inputs win on key collision.
"""
merged = {**(runtime_inputs or {}), **(trigger.static_inputs or {})}
return validate_inputs(definition, merged)
def validate_inputs(
definition: AutomationDefinition, inputs: dict[str, Any]
) -> dict[str, Any]:
"""Validate ``inputs`` against the definition's optional declared schema.
No declared schema pass through unchanged so runtime keys (``fired_at``,
``last_fired_at``, ...) still reach the template context. A declared schema
that the inputs violate is surfaced as ``DispatchError``.
"""
if definition.inputs is None or not definition.inputs.schema_:
return inputs
try:
jsonschema.validate(instance=inputs, schema=definition.inputs.schema_)
except jsonschema.ValidationError as exc:
raise DispatchError(f"inputs: {exc.message}") from exc
return inputs

View file

@ -0,0 +1,60 @@
"""Launch a run for a trigger that fired: resolve, validate, persist, enqueue.
The trigger-facing entry every selector calls. A selector builds the runtime
inputs and hands one trigger row here; this resolves and guards its automation,
snapshots the definition onto a PENDING run, and enqueues execution. The
snapshot makes the run immune to later edits of the automation.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.persistence.enums.run_status import RunStatus
from app.automations.persistence.models.run import AutomationRun
from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.schemas.definition.envelope import AutomationDefinition
from app.automations.tasks.execute_run import automation_run_execute
from .errors import DispatchError
from .inputs import prepare_inputs
from .resolve import resolve_active_automation
async def launch_run(
*,
session: AsyncSession,
trigger: AutomationTrigger,
runtime_inputs: dict[str, Any] | None = None,
) -> AutomationRun:
"""Resolve ``trigger``'s active automation and enqueue a PENDING run for it."""
automation = await resolve_active_automation(session, trigger)
try:
definition = AutomationDefinition.model_validate(automation.definition)
except Exception as exc:
raise DispatchError(f"invalid automation definition: {exc}") from exc
inputs = prepare_inputs(definition, trigger, runtime_inputs)
snapshot = definition.model_dump(mode="json", by_alias=True)
run = AutomationRun(
automation_id=automation.id,
trigger_id=trigger.id,
status=RunStatus.PENDING,
definition_snapshot=snapshot,
inputs=inputs,
step_results=[],
artifacts=[],
)
session.add(run)
await session.commit()
await session.refresh(run)
automation_run_execute.apply_async(
args=[run.id],
time_limit=definition.execution.timeout_seconds,
)
return run

View file

@ -0,0 +1,40 @@
"""Resolve the automation behind a trigger and guard that it may run."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.persistence.enums.automation_status import AutomationStatus
from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.trigger import AutomationTrigger
from .errors import DispatchError
async def resolve_active_automation(
session: AsyncSession, trigger: AutomationTrigger
) -> Automation:
"""Load ``trigger``'s automation and require it ``ACTIVE``.
Raises ``DispatchError`` if the automation is missing or not active.
"""
automation = await _load_automation(session, trigger.automation_id)
if automation is None:
raise DispatchError(
f"automation {trigger.automation_id} not found for trigger {trigger.id}"
)
if automation.status != AutomationStatus.ACTIVE:
raise DispatchError(
f"automation {trigger.automation_id} is {automation.status.value}, not active"
)
return automation
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()

View file

@ -1,83 +0,0 @@
"""Generic run dispatch: validate, snapshot, persist, enqueue. Shared by every trigger."""
from __future__ import annotations
from typing import Any
import jsonschema
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.persistence.enums.run_status import RunStatus
from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.run import AutomationRun
from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.schemas.definition.envelope import AutomationDefinition
from app.automations.tasks.execute_run import automation_run_execute
from .errors import DispatchError
async def dispatch_run(
*,
session: AsyncSession,
automation: Automation,
trigger: AutomationTrigger,
runtime_inputs: dict[str, Any] | None = None,
) -> AutomationRun:
"""Validate, snapshot the definition, persist an ``AutomationRun``, enqueue execution.
Final inputs = ``trigger.static_inputs`` merged with ``runtime_inputs``,
static winning on key collision. The merged dict is validated against
``automation.definition.inputs.schema_`` and stored on the run.
Callers (trigger-specific adapters) are responsible for resolving
``automation`` and ``trigger`` and for the trigger-side ``ACTIVE`` /
``enabled`` guards. This function only handles what's identical across
every trigger type.
"""
try:
definition = AutomationDefinition.model_validate(automation.definition)
except Exception as exc:
raise DispatchError(f"invalid automation definition: {exc}") from exc
merged_inputs = {**(runtime_inputs or {}), **(trigger.static_inputs or {})}
validated_inputs = _validate_inputs(definition, merged_inputs)
snapshot = definition.model_dump(mode="json", by_alias=True)
run = AutomationRun(
automation_id=automation.id,
trigger_id=trigger.id,
status=RunStatus.PENDING,
definition_snapshot=snapshot,
inputs=validated_inputs,
step_results=[],
artifacts=[],
)
session.add(run)
await session.commit()
await session.refresh(run)
automation_run_execute.apply_async(
args=[run.id],
time_limit=definition.execution.timeout_seconds,
)
return run
def _validate_inputs(
definition: AutomationDefinition, inputs: dict[str, Any]
) -> dict[str, Any]:
"""Validate merged inputs against the optional declared schema.
No declared schema pass through (runtime inputs like ``fired_at`` /
``last_fired_at`` and trigger ``static_inputs`` must still reach the
template context). Returning ``{}`` here strips them and makes Jinja
blow up on any ``{{ inputs.* }}`` reference.
"""
if definition.inputs is None or not definition.inputs.schema_:
return inputs
try:
jsonschema.validate(instance=inputs, schema=definition.inputs.schema_)
except jsonschema.ValidationError as exc:
raise DispatchError(f"inputs: {exc.message}") from exc
return inputs

View file

@ -1,8 +1,8 @@
"""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.
``schedule`` and ``event`` are registered. ``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
@ -12,4 +12,5 @@ from enum import StrEnum
class TriggerType(StrEnum):
SCHEDULE = "schedule"
EVENT = "event"
MANUAL = "manual"

View file

@ -26,7 +26,7 @@ from app.automations.services.model_policy import (
get_automation_model_eligibility,
)
from app.automations.triggers import get_trigger
from app.automations.triggers.schedule import compute_next_fire_at
from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, SearchSpace, User, get_async_session
from app.users import current_active_user
from app.utils.rbac import check_permission

View file

@ -13,7 +13,7 @@ from app.automations.persistence.models.automation import Automation
from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.schemas.api import TriggerCreate, TriggerUpdate
from app.automations.triggers import get_trigger
from app.automations.triggers.schedule import compute_next_fire_at
from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, User, get_async_session
from app.users import current_active_user
from app.utils.rbac import check_permission

View file

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

View file

@ -0,0 +1,5 @@
"""Built-in trigger types — each in its own subpackage, self-registering at import."""
from __future__ import annotations
from . import event, schedule # noqa: F401

View file

@ -0,0 +1,29 @@
"""``event`` trigger: fire an automation when a matching domain event is published.
Subscribes to the event bus and matches events against a user-authored JSON
filter (see :mod:`.filter`).
"""
from __future__ import annotations
from app.event_bus import bus
from .filter import FilterError, matches
from .inputs import event_runtime_inputs
from .match import trigger_matches_event
from .params import EventTriggerParams
from .source import on_event
__all__ = [
"EventTriggerParams",
"FilterError",
"event_runtime_inputs",
"matches",
"trigger_matches_event",
]
# Side-effect: register on the triggers store.
from . import definition # noqa: F401
# Side-effect: react to published events.
bus.subscribe(on_event)

View file

@ -0,0 +1,16 @@
"""``event`` ``TriggerDefinition`` registration."""
from __future__ import annotations
from app.automations.triggers.store import register_trigger
from app.automations.triggers.types import TriggerDefinition
from .params import EventTriggerParams
EVENT_TRIGGER = TriggerDefinition(
type="event",
description="Fire when a matching domain event is published.",
params_model=EventTriggerParams,
)
register_trigger(EVENT_TRIGGER)

View file

@ -0,0 +1,78 @@
"""Pure JSON filter grammar: ``matches(filter_expr, payload) -> bool``.
The ``event`` trigger uses it to decide whether an event fires the automation.
"""
from __future__ import annotations
import operator
from collections.abc import Callable
from typing import Any
class FilterError(ValueError):
"""Unknown operator in a filter. Raised (not silently false) so a bad filter
fails at authoring time instead of quietly disabling the trigger."""
# Scalar comparison operators: (actual, operand) -> bool.
_COMPARATORS: dict[str, Callable[[Any, Any], bool]] = {
"$eq": operator.eq,
"$ne": operator.ne,
"$gt": operator.gt,
"$gte": operator.ge,
"$lt": operator.lt,
"$lte": operator.le,
"$in": lambda actual, operand: actual in operand,
"$nin": lambda actual, operand: actual not in operand,
}
# Sentinel for "the payload has no such field" — distinct from a present None.
_MISSING = object()
def matches(filter_expr: dict[str, Any], payload: dict[str, Any]) -> bool:
"""Return ``True`` when ``payload`` satisfies every constraint in ``filter_expr``.
An empty filter expresses "no constraints" and matches every payload.
Sibling keys (fields and logical operators alike) are ANDed together.
"""
for key, value in filter_expr.items():
if key == "$and":
if not all(matches(sub, payload) for sub in value):
return False
elif key == "$or":
if not any(matches(sub, payload) for sub in value):
return False
elif key == "$not":
if matches(value, payload):
return False
elif key.startswith("$"):
raise FilterError(f"unknown logical operator: {key}")
elif not _match_condition(value, payload.get(key, _MISSING)):
return False
return True
def _match_condition(condition: Any, actual: Any) -> bool:
"""Match one field's ``actual`` value against its ``condition``.
A dict condition is an operator object (``{"$gt": 10}``); every operator in
it must hold. Any other value is an implicit equality check. A field absent
from the payload (``actual is _MISSING``) fails every constraint.
"""
if actual is _MISSING:
return False
if isinstance(condition, dict):
return all(
_apply_operator(op, operand, actual)
for op, operand in condition.items()
)
return actual == condition
def _apply_operator(op: str, operand: Any, actual: Any) -> bool:
comparator = _COMPARATORS.get(op)
if comparator is not None:
return comparator(actual, operand)
raise FilterError(f"unknown operator: {op}")

View file

@ -0,0 +1,17 @@
"""Build run inputs from a published event."""
from __future__ import annotations
from typing import Any
from app.event_bus import Event
def event_runtime_inputs(event: Event) -> dict[str, Any]:
"""Flatten the event payload and stamp event metadata as run inputs."""
return {
**event.payload,
"event_type": event.event_type,
"event_id": event.event_id,
"occurred_at": event.occurred_at.isoformat(),
}

View file

@ -0,0 +1,16 @@
"""Pure predicate: does an event trigger fire for a given event?"""
from __future__ import annotations
from typing import Any
from app.event_bus import Event
from .filter import matches
def trigger_matches_event(params: dict[str, Any], event: Event) -> bool:
"""True when an event trigger configured with ``params`` should fire for ``event``."""
if params.get("event_type") != event.event_type:
return False
return matches(params.get("filter") or {}, event.payload)

View file

@ -0,0 +1,23 @@
"""``EventTriggerParams`` — params for the ``event`` trigger type."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class EventTriggerParams(BaseModel):
model_config = ConfigDict(extra="forbid")
event_type: str = Field(
...,
min_length=1,
description="Event type to listen for.",
examples=["document.indexed"],
)
filter: dict[str, Any] = Field(
default_factory=dict,
description="JSON filter matched against the event payload.",
examples=[{"document_type": "FILE"}],
)

View file

@ -0,0 +1,75 @@
"""Event selector (worker task): pick the triggers an event fires, start each.
The source enqueues this with a serialized event. Here we load the enabled
``event`` triggers for that event type, keep the ones whose filter matches the
payload, and start a run for each. Per-trigger failures are isolated.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.dispatch import launch_run
from app.automations.persistence.enums.trigger_type import TriggerType
from app.automations.persistence.models.trigger import AutomationTrigger
from app.celery_app import celery_app
from app.event_bus import Event
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
from .inputs import event_runtime_inputs
from .match import trigger_matches_event
from .source import TASK_NAME
logger = logging.getLogger(__name__)
@celery_app.task(name=TASK_NAME)
def automation_event_select(event: dict[str, Any]) -> None:
"""Select and start the runs an event fires."""
return run_async_celery_task(lambda: _select_and_start(event))
async def _select_and_start(event_dict: dict[str, Any]) -> None:
event = Event.model_validate(event_dict)
session_maker = get_celery_session_maker()
async with session_maker() as session:
for trigger in await _eligible(session, event=event):
await _start_one(session, trigger=trigger, event=event)
async def _eligible(
session: AsyncSession, *, event: Event
) -> list[AutomationTrigger]:
"""Enabled ``event`` triggers for this event type whose filter matches."""
stmt = select(AutomationTrigger).where(
AutomationTrigger.type == TriggerType.EVENT,
AutomationTrigger.enabled.is_(True),
AutomationTrigger.params["event_type"].astext == event.event_type,
)
triggers = (await session.execute(stmt)).scalars().all()
return [t for t in triggers if trigger_matches_event(t.params, event)]
async def _start_one(
session: AsyncSession, *, trigger: AutomationTrigger, event: Event
) -> None:
try:
run = await launch_run(
session=session,
trigger=trigger,
runtime_inputs=event_runtime_inputs(event),
)
logger.info(
"event fire: trigger=%d automation=%d run=%d event=%s",
trigger.id,
trigger.automation_id,
run.id,
event.event_id,
)
except Exception:
logger.exception("event fire failed for trigger %d", trigger.id)
await session.rollback()

View file

@ -0,0 +1,19 @@
"""Event trigger source: the bus subscriber that enqueues the selector.
Runs in whatever process published the event, so it stays thin it only hands
the event to a worker (the selector does the DB matching).
"""
from __future__ import annotations
from app.event_bus import Event
TASK_NAME = "automation_event_select"
async def on_event(event: Event) -> None:
"""Enqueue the selector for ``event``."""
# Lazy import: keeps app.celery_app out of the triggers-package import graph.
from app.celery_app import celery_app
celery_app.send_task(TASK_NAME, kwargs={"event": event.model_dump(mode="json")})

View file

@ -3,14 +3,12 @@
from __future__ import annotations
from .cron import InvalidCronError, compute_next_fire_at, validate_cron
from .dispatch import dispatch_schedule_run
from .params import ScheduleTriggerParams
__all__ = [
"InvalidCronError",
"ScheduleTriggerParams",
"compute_next_fire_at",
"dispatch_schedule_run",
"validate_cron",
]

View file

@ -2,8 +2,9 @@
from __future__ import annotations
from ..store import register_trigger
from ..types import TriggerDefinition
from app.automations.triggers.store import register_trigger
from app.automations.triggers.types import TriggerDefinition
from .params import ScheduleTriggerParams
SCHEDULE_TRIGGER = TriggerDefinition(

View file

@ -0,0 +1,27 @@
"""Build run inputs from a schedule fire."""
from __future__ import annotations
from datetime import datetime
from typing import Any
def schedule_runtime_inputs(
*,
fired_at: datetime,
scheduled_for: datetime,
previous_last_fired_at: datetime | None,
) -> dict[str, Any]:
"""Calendar context for a scheduled run.
- ``fired_at`` actual fire time
- ``scheduled_for`` cron-derived target time for this fire
- ``last_fired_at`` previous fire time, or null on first fire
"""
return {
"fired_at": fired_at.isoformat(),
"scheduled_for": scheduled_for.isoformat(),
"last_fired_at": (
previous_last_fired_at.isoformat() if previous_last_fired_at else None
),
}

View file

@ -1,15 +1,12 @@
"""Celery Beat tick that fires due ``schedule`` triggers.
"""Schedule selector (worker task): claim due triggers and start each.
Runs every minute. Each tick performs two passes:
Beat ticks this every minute. Two passes:
1. **Self-heal**: enabled schedule triggers with NULL ``next_fire_at`` get
it computed from their ``cron`` + ``timezone`` (e.g. fresh inserts or
rows restored from backup).
2. **Claim & fire**: due rows are locked with ``FOR UPDATE SKIP LOCKED``,
their ``next_fire_at`` is advanced and ``last_fired_at`` is set, and
``dispatch_schedule_run`` is invoked for each. Dispatch errors are
logged; a missed fire stays missed (matches K8s CronJob / Airflow
``catchup=False`` semantics).
1. **Self-heal**: enabled schedule triggers with NULL ``next_fire_at`` get it
computed from their ``cron`` + ``timezone`` (fresh inserts, restored rows).
2. **Claim & start**: due rows are locked ``FOR UPDATE SKIP LOCKED``, their
``next_fire_at`` is advanced and ``last_fired_at`` set, and a run is started
for each. A missed fire stays missed (``catchup=False`` semantics).
"""
from __future__ import annotations
@ -21,19 +18,17 @@ from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.dispatch import launch_run
from app.automations.persistence.enums.trigger_type import TriggerType
from app.automations.persistence.models.trigger import AutomationTrigger
from app.automations.triggers.schedule import (
InvalidCronError,
compute_next_fire_at,
dispatch_schedule_run,
)
from app.celery_app import celery_app
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
logger = logging.getLogger(__name__)
from .cron import InvalidCronError, compute_next_fire_at
from .inputs import schedule_runtime_inputs
from .source import TASK_NAME
TASK_NAME = "automation_schedule_tick"
logger = logging.getLogger(__name__)
# Cap rows touched per tick so a backlog of due triggers can't starve the
# worker; remaining rows fire on the next tick.
@ -50,8 +45,8 @@ class _Claim:
@celery_app.task(name=TASK_NAME)
def automation_schedule_tick() -> None:
"""Tick once: self-heal NULL next_fire_at, claim due rows, fire each."""
def automation_schedule_select() -> None:
"""Tick once: self-heal NULL next_fire_at, claim due rows, start each."""
return run_async_celery_task(_tick)
@ -67,7 +62,7 @@ async def _tick() -> None:
return
for claim in claims:
await _fire_one(session, claim=claim, fired_at=now)
await _start_one(session, claim=claim, fired_at=now)
async def _self_heal_null_next_fire(session: AsyncSession, *, now: datetime) -> None:
@ -155,21 +150,23 @@ async def _claim_due_triggers(session: AsyncSession, *, now: datetime) -> list[_
return claims
async def _fire_one(
async def _start_one(
session: AsyncSession, *, claim: _Claim, fired_at: datetime
) -> None:
"""Reload the trigger post-commit and dispatch a run for it."""
"""Reload the trigger post-commit and start a run for it."""
trigger = await session.get(AutomationTrigger, claim.trigger_id)
if trigger is None:
return
try:
run = await dispatch_schedule_run(
run = await launch_run(
session=session,
trigger=trigger,
fired_at=fired_at,
scheduled_for=claim.scheduled_for,
previous_last_fired_at=claim.previous_last_fired_at,
runtime_inputs=schedule_runtime_inputs(
fired_at=fired_at,
scheduled_for=claim.scheduled_for,
previous_last_fired_at=claim.previous_last_fired_at,
),
)
logger.info(
"scheduled fire: trigger=%d automation=%d run=%d",

View file

@ -0,0 +1,20 @@
"""Schedule trigger source: Celery Beat ticks the selector every minute.
``BEAT_SCHEDULE`` is merged into ``celery_app.conf.beat_schedule``. Per-row cron
math is precomputed (the ``next_fire_at`` column), so each tick is an indexed
lookup rather than N cron evaluations.
"""
from __future__ import annotations
from celery.schedules import crontab
TASK_NAME = "automation_schedule_select"
BEAT_SCHEDULE = {
"automation-schedule-select": {
"task": TASK_NAME,
"schedule": crontab(minute="*"),
"options": {"expires": 50},
},
}

View file

@ -1,67 +0,0 @@
"""Schedule dispatch adapter: load + guard, then call generic dispatch."""
from __future__ import annotations
from datetime import datetime
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.models.automation import Automation
from app.automations.persistence.models.run import AutomationRun
from app.automations.persistence.models.trigger import AutomationTrigger
async def dispatch_schedule_run(
*,
session: AsyncSession,
trigger: AutomationTrigger,
fired_at: datetime,
scheduled_for: datetime,
previous_last_fired_at: datetime | None,
) -> AutomationRun:
"""Fire one scheduled run for ``trigger``.
Emits calendar context as runtime inputs:
- ``fired_at`` actual fire time
- ``scheduled_for`` cron-derived target time for this fire
- ``last_fired_at`` fire time of the previous run, or null on first fire
The caller (the schedule tick) is responsible for selecting due triggers
and advancing ``next_fire_at`` / ``last_fired_at`` before invoking this.
"""
automation = await _load_automation(session, trigger.automation_id)
if automation is None:
raise DispatchError(
f"automation {trigger.automation_id} not found for trigger {trigger.id}"
)
if automation.status != AutomationStatus.ACTIVE:
raise DispatchError(
f"automation {trigger.automation_id} is {automation.status.value}, not active"
)
runtime_inputs = {
"fired_at": fired_at.isoformat(),
"scheduled_for": scheduled_for.isoformat(),
"last_fired_at": (
previous_last_fired_at.isoformat() if previous_last_fired_at else None
),
}
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()