From 05931375f4c5d92d5737c3c06bef718a1712d374 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 26 May 2026 22:42:50 +0200 Subject: [PATCH] feat(automation): add SQLAlchemy models for the three v1 tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three enums (one file each) plus three models (one file each), all under app/automations/persistence/. The module imports from app.db only (Base/BaseModel/TimestampMixin and FK targets searchspaces.id / user.id); no business-logic imports. Enums: - AutomationStatus: active | paused | archived - RunStatus: pending | running | succeeded | failed | cancelled | timed_out - TriggerType: schedule | manual (Phase-2/3 add webhook | event) Models: - Automation: search_space-scoped, created_by_user_id (SET NULL), name + description, status enum, definition JSONB, version int, updated_at with onupdate. - AutomationTrigger: FK → automations (CASCADE), type enum, config JSONB, enabled bool, last_fired_at. Webhook secret_hash is omitted until Phase 2. - AutomationRun: FK → automations (CASCADE), nullable trigger_id (SET NULL — null = manual via UI), status enum, definition_snapshot for immutable history, trigger_payload / resolved_inputs / step_results / output / artifacts / error JSONB columns, started_at / finished_at timestamps, agent_session_id for linking to the LangGraph trace. cost_usd column omitted until at least one v1 capability records token-level cost. Verified: Base.metadata exposes all three table names; columns and enums introspect as documented; no linter errors. --- .../app/automations/persistence/__init__.py | 12 ++- .../automations/persistence/enums/__init__.py | 10 ++- .../persistence/enums/automation_status.py | 18 +++++ .../persistence/enums/run_status.py | 28 +++++++ .../persistence/enums/trigger_type.py | 21 ++++++ .../persistence/models/__init__.py | 10 ++- .../persistence/models/automation.py | 75 +++++++++++++++++++ .../app/automations/persistence/models/run.py | 72 ++++++++++++++++++ .../automations/persistence/models/trigger.py | 57 ++++++++++++++ 9 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 surfsense_backend/app/automations/persistence/enums/automation_status.py create mode 100644 surfsense_backend/app/automations/persistence/enums/run_status.py create mode 100644 surfsense_backend/app/automations/persistence/enums/trigger_type.py create mode 100644 surfsense_backend/app/automations/persistence/models/automation.py create mode 100644 surfsense_backend/app/automations/persistence/models/run.py create mode 100644 surfsense_backend/app/automations/persistence/models/trigger.py diff --git a/surfsense_backend/app/automations/persistence/__init__.py b/surfsense_backend/app/automations/persistence/__init__.py index 05c39014e..265742a85 100644 --- a/surfsense_backend/app/automations/persistence/__init__.py +++ b/surfsense_backend/app/automations/persistence/__init__.py @@ -2,4 +2,14 @@ from __future__ import annotations -__all__: list[str] = [] +from .enums import AutomationStatus, RunStatus, TriggerType +from .models import Automation, AutomationRun, AutomationTrigger + +__all__ = [ + "Automation", + "AutomationRun", + "AutomationStatus", + "AutomationTrigger", + "RunStatus", + "TriggerType", +] diff --git a/surfsense_backend/app/automations/persistence/enums/__init__.py b/surfsense_backend/app/automations/persistence/enums/__init__.py index f221687dc..cf9e7dd1b 100644 --- a/surfsense_backend/app/automations/persistence/enums/__init__.py +++ b/surfsense_backend/app/automations/persistence/enums/__init__.py @@ -2,4 +2,12 @@ from __future__ import annotations -__all__: list[str] = [] +from .automation_status import AutomationStatus +from .run_status import RunStatus +from .trigger_type import TriggerType + +__all__ = [ + "AutomationStatus", + "RunStatus", + "TriggerType", +] diff --git a/surfsense_backend/app/automations/persistence/enums/automation_status.py b/surfsense_backend/app/automations/persistence/enums/automation_status.py new file mode 100644 index 000000000..3f2ca9621 --- /dev/null +++ b/surfsense_backend/app/automations/persistence/enums/automation_status.py @@ -0,0 +1,18 @@ +"""``AutomationStatus`` — lifecycle of a stored automation definition.""" + +from __future__ import annotations + +from enum import StrEnum + + +class AutomationStatus(StrEnum): + """Status of an automation in the registry. + + ``active`` — eligible to fire from its triggers. + ``paused`` — definition retained, triggers do not fire. + ``archived`` — kept for run history only; no edits, no fires. + """ + + ACTIVE = "active" + PAUSED = "paused" + ARCHIVED = "archived" diff --git a/surfsense_backend/app/automations/persistence/enums/run_status.py b/surfsense_backend/app/automations/persistence/enums/run_status.py new file mode 100644 index 000000000..0f619bd82 --- /dev/null +++ b/surfsense_backend/app/automations/persistence/enums/run_status.py @@ -0,0 +1,28 @@ +"""``RunStatus`` — the state machine of a single ``AutomationRun``.""" + +from __future__ import annotations + +from enum import StrEnum + + +class RunStatus(StrEnum): + """Lifecycle states of an ``AutomationRun`` row. + + Transitions are linear with three terminal branches: + + pending → running → (succeeded | failed | cancelled | timed_out) + + ``pending`` — row created, executor task enqueued, work not started. + ``running`` — executor has picked up the run. + ``succeeded`` — terminal: plan completed without error. + ``failed`` — terminal: at least one step raised an unrecoverable error. + ``cancelled`` — terminal: caller asked for cancellation. + ``timed_out`` — terminal: run exceeded its configured timeout. + """ + + PENDING = "pending" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELLED = "cancelled" + TIMED_OUT = "timed_out" diff --git a/surfsense_backend/app/automations/persistence/enums/trigger_type.py b/surfsense_backend/app/automations/persistence/enums/trigger_type.py new file mode 100644 index 000000000..eb06fe773 --- /dev/null +++ b/surfsense_backend/app/automations/persistence/enums/trigger_type.py @@ -0,0 +1,21 @@ +"""``TriggerType`` — the trigger-kind discriminator (v1 = schedule, manual).""" + +from __future__ import annotations + +from enum import StrEnum + + +class TriggerType(StrEnum): + """Kind of trigger an ``AutomationTrigger`` row represents. + + v1 ships two kinds: + + ``schedule`` — fires on a cron expression managed by Celery Beat. + ``manual`` — fires on demand from the UI's "Run now" affordance. + + ``webhook`` and ``event`` are deferred to Phase 2 and Phase 3 + respectively; adding them is an enum-value extension only. + """ + + SCHEDULE = "schedule" + MANUAL = "manual" diff --git a/surfsense_backend/app/automations/persistence/models/__init__.py b/surfsense_backend/app/automations/persistence/models/__init__.py index da73c9e41..4aca02a03 100644 --- a/surfsense_backend/app/automations/persistence/models/__init__.py +++ b/surfsense_backend/app/automations/persistence/models/__init__.py @@ -2,4 +2,12 @@ from __future__ import annotations -__all__: list[str] = [] +from .automation import Automation +from .run import AutomationRun +from .trigger import AutomationTrigger + +__all__ = [ + "Automation", + "AutomationRun", + "AutomationTrigger", +] diff --git a/surfsense_backend/app/automations/persistence/models/automation.py b/surfsense_backend/app/automations/persistence/models/automation.py new file mode 100644 index 000000000..fc4a1ed93 --- /dev/null +++ b/surfsense_backend/app/automations/persistence/models/automation.py @@ -0,0 +1,75 @@ +"""``Automation`` table — the editable, versioned automation definition.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import ( + TIMESTAMP, + Column, + Enum as SQLAlchemyEnum, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID + +from app.db import BaseModel, TimestampMixin + +from ..enums.automation_status import AutomationStatus + + +class Automation(BaseModel, TimestampMixin): + """The editable, versioned spec a user authors. + + The ``definition`` JSON is what the user (or the NL generator) writes + and edits. Each save bumps ``version`` by one; the previous JSON is + not kept in this row — version history is reconstructed from the + ``definition_snapshot`` column on every ``AutomationRun`` that fired + against a given version. + """ + + __tablename__ = "automations" + + search_space_id = Column( + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + created_by_user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + + status = Column( + SQLAlchemyEnum(AutomationStatus, name="automation_status"), + nullable=False, + default=AutomationStatus.ACTIVE, + server_default=AutomationStatus.ACTIVE.value, + index=True, + ) + + definition = Column(JSONB, nullable=False) + + version = Column( + Integer, + nullable=False, + default=1, + server_default="1", + ) + + updated_at = Column( + TIMESTAMP(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + index=True, + ) diff --git a/surfsense_backend/app/automations/persistence/models/run.py b/surfsense_backend/app/automations/persistence/models/run.py new file mode 100644 index 000000000..5c6ec93ec --- /dev/null +++ b/surfsense_backend/app/automations/persistence/models/run.py @@ -0,0 +1,72 @@ +"""``AutomationRun`` table — the immutable per-fire execution record.""" + +from __future__ import annotations + +from sqlalchemy import ( + TIMESTAMP, + Column, + Enum as SQLAlchemyEnum, + ForeignKey, + Integer, + String, +) +from sqlalchemy.dialects.postgresql import JSONB + +from app.db import BaseModel, TimestampMixin + +from ..enums.run_status import RunStatus + + +class AutomationRun(BaseModel, TimestampMixin): + """One execution of an automation. + + Every fire of any trigger inserts exactly one row here. The row is + immutable from the user's perspective — the executor only updates + ``status``, ``step_results``, ``output``, ``artifacts``, ``error``, + ``started_at``, ``finished_at`` as the run progresses; the + ``definition_snapshot`` is locked at fire time so the user can always + see exactly what code path executed for any historical run. + """ + + __tablename__ = "automation_runs" + + automation_id = Column( + Integer, + ForeignKey("automations.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + trigger_id = Column( + Integer, + ForeignKey("automation_triggers.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + status = Column( + SQLAlchemyEnum(RunStatus, name="automation_run_status"), + nullable=False, + default=RunStatus.PENDING, + server_default=RunStatus.PENDING.value, + index=True, + ) + + definition_snapshot = Column(JSONB, nullable=False) + + trigger_payload = Column(JSONB, nullable=True) + + resolved_inputs = Column(JSONB, nullable=False, server_default="{}") + + step_results = Column(JSONB, nullable=False, server_default="[]") + + output = Column(JSONB, nullable=True) + + artifacts = Column(JSONB, nullable=False, server_default="[]") + + error = Column(JSONB, nullable=True) + + started_at = Column(TIMESTAMP(timezone=True), nullable=True) + finished_at = Column(TIMESTAMP(timezone=True), nullable=True) + + agent_session_id = Column(String(200), nullable=True) diff --git a/surfsense_backend/app/automations/persistence/models/trigger.py b/surfsense_backend/app/automations/persistence/models/trigger.py new file mode 100644 index 000000000..3173770d6 --- /dev/null +++ b/surfsense_backend/app/automations/persistence/models/trigger.py @@ -0,0 +1,57 @@ +"""``AutomationTrigger`` table — one row per (automation, trigger-instance) pair.""" + +from __future__ import annotations + +from sqlalchemy import ( + TIMESTAMP, + Boolean, + Column, + Enum as SQLAlchemyEnum, + ForeignKey, + Integer, +) +from sqlalchemy.dialects.postgresql import JSONB + +from app.db import BaseModel, TimestampMixin + +from ..enums.trigger_type import TriggerType + + +class AutomationTrigger(BaseModel, TimestampMixin): + """One trigger attached to an automation. + + An automation may have multiple triggers — e.g. a ``schedule`` trigger + for the autonomous path and a ``manual`` trigger backing the UI's + "Run now" affordance. Each trigger's ``config`` is validated against + the registered ``TriggerDefinition.config_schema`` for its ``type``. + """ + + __tablename__ = "automation_triggers" + + automation_id = Column( + Integer, + ForeignKey("automations.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + type = Column( + SQLAlchemyEnum(TriggerType, name="automation_trigger_type"), + nullable=False, + index=True, + ) + + config = Column(JSONB, nullable=False) + + enabled = Column( + Boolean, + nullable=False, + default=True, + server_default="true", + index=True, + ) + + last_fired_at = Column( + TIMESTAMP(timezone=True), + nullable=True, + )