diff --git a/surfsense_backend/app/automations/registries/__init__.py b/surfsense_backend/app/automations/registries/__init__.py index e7334cca8..47023f903 100644 --- a/surfsense_backend/app/automations/registries/__init__.py +++ b/surfsense_backend/app/automations/registries/__init__.py @@ -2,4 +2,40 @@ from __future__ import annotations -__all__: list[str] = [] +from .actions import ( + ActionDefinition, + ActionHandler, + all_actions, + get_action, + register_action, +) +from .capabilities import ( + Capability, + CapabilityHandler, + all_capabilities, + get_capability, + register_capability, +) +from .triggers import ( + TriggerDefinition, + all_triggers, + get_trigger, + register_trigger, +) + +__all__ = [ + "ActionDefinition", + "ActionHandler", + "Capability", + "CapabilityHandler", + "TriggerDefinition", + "all_actions", + "all_capabilities", + "all_triggers", + "get_action", + "get_capability", + "get_trigger", + "register_action", + "register_capability", + "register_trigger", +] diff --git a/surfsense_backend/app/automations/registries/actions/__init__.py b/surfsense_backend/app/automations/registries/actions/__init__.py index 6b19b7091..c6b550096 100644 --- a/surfsense_backend/app/automations/registries/actions/__init__.py +++ b/surfsense_backend/app/automations/registries/actions/__init__.py @@ -2,4 +2,13 @@ from __future__ import annotations -__all__: list[str] = [] +from .store import all_actions, get_action, register_action +from .types import ActionDefinition, ActionHandler + +__all__ = [ + "ActionDefinition", + "ActionHandler", + "all_actions", + "get_action", + "register_action", +] diff --git a/surfsense_backend/app/automations/registries/actions/store.py b/surfsense_backend/app/automations/registries/actions/store.py new file mode 100644 index 000000000..720243b83 --- /dev/null +++ b/surfsense_backend/app/automations/registries/actions/store.py @@ -0,0 +1,33 @@ +"""Action registry: in-memory dict + ``register_action`` API.""" + +from __future__ import annotations + +from .types import ActionDefinition + +_REGISTRY: dict[str, ActionDefinition] = {} + + +def register_action(action: ActionDefinition) -> None: + """Add an action to the in-memory registry. + + Raises ``ValueError`` on duplicate ``type`` — registration runs + once per process, so a duplicate is always a bug. + """ + + if action.type in _REGISTRY: + raise ValueError( + f"Action already registered: {action.type!r}" + ) + _REGISTRY[action.type] = action + + +def get_action(action_type: str) -> ActionDefinition | None: + """Look up one action by type. Returns ``None`` on miss.""" + + return _REGISTRY.get(action_type) + + +def all_actions() -> dict[str, ActionDefinition]: + """Snapshot of the registry as a defensive copy.""" + + return dict(_REGISTRY) diff --git a/surfsense_backend/app/automations/registries/actions/types.py b/surfsense_backend/app/automations/registries/actions/types.py new file mode 100644 index 000000000..2ab2906b1 --- /dev/null +++ b/surfsense_backend/app/automations/registries/actions/types.py @@ -0,0 +1,44 @@ +"""``ActionDefinition`` dataclass — the v1-minimum action shape.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +ActionHandler = Callable[[dict[str, Any]], Awaitable[Any]] +"""The signature every action handler must satisfy. + +Identical in shape to ``CapabilityHandler`` — both receive a +caller-validated input dict and return an arbitrary output. The +distinction is purely architectural: capabilities are the low-level +"what SurfSense can do" surface, actions are the user-facing +building blocks composed into a plan. +""" + + +@dataclass(frozen=True, slots=True) +class ActionDefinition: + """A user-facing step type the plan editor can compose. + + v1 trims the dataclass to the five fields necessary for + registry dispatch and form rendering. The full design (§4) + includes ``output_contract``, ``uses_capabilities``, and + ``produces_artifacts``; all three are deferred until a consumer + feature requires them: + + - ``output_contract`` — the loose ``agent_task`` action declares + its output shape per-step via ``config.output_schema``, so the + action-level contract is not needed in v1. + - ``uses_capabilities`` — would let the NL generator do static + analysis of which capabilities each action invokes; deferred + because v1 ships a single (``agent_task``) action. + - ``produces_artifacts`` — deferred alongside the artifact + pipeline (see §13 decision 26). + """ + + type: str + name: str + description: str + config_schema: dict[str, Any] + handler: ActionHandler diff --git a/surfsense_backend/app/automations/registries/capabilities/__init__.py b/surfsense_backend/app/automations/registries/capabilities/__init__.py index 77f9f88b7..6fa2c8246 100644 --- a/surfsense_backend/app/automations/registries/capabilities/__init__.py +++ b/surfsense_backend/app/automations/registries/capabilities/__init__.py @@ -2,4 +2,13 @@ from __future__ import annotations -__all__: list[str] = [] +from .store import all_capabilities, get_capability, register_capability +from .types import Capability, CapabilityHandler + +__all__ = [ + "Capability", + "CapabilityHandler", + "all_capabilities", + "get_capability", + "register_capability", +] diff --git a/surfsense_backend/app/automations/registries/capabilities/store.py b/surfsense_backend/app/automations/registries/capabilities/store.py new file mode 100644 index 000000000..3c8822d76 --- /dev/null +++ b/surfsense_backend/app/automations/registries/capabilities/store.py @@ -0,0 +1,40 @@ +"""Capability registry: in-memory dict + ``register_capability`` API.""" + +from __future__ import annotations + +from .types import Capability + +_REGISTRY: dict[str, Capability] = {} + + +def register_capability(capability: Capability) -> None: + """Add a capability to the in-memory registry. + + Raises ``ValueError`` on duplicate ``id`` — registration is + idempotent only at the module level (a module's + ``register_capability`` call runs once per process), so a + duplicate is always a bug. + """ + + if capability.id in _REGISTRY: + raise ValueError( + f"Capability already registered: {capability.id!r}" + ) + _REGISTRY[capability.id] = capability + + +def get_capability(capability_id: str) -> Capability | None: + """Look up one capability by id. Returns ``None`` on miss.""" + + return _REGISTRY.get(capability_id) + + +def all_capabilities() -> dict[str, Capability]: + """Snapshot of the registry as a defensive copy. + + Returned dict is safe to iterate while other code calls + ``register_capability`` (which v1 never does post-startup, but + the contract holds anyway). + """ + + return dict(_REGISTRY) diff --git a/surfsense_backend/app/automations/registries/capabilities/types.py b/surfsense_backend/app/automations/registries/capabilities/types.py new file mode 100644 index 000000000..001f26ac1 --- /dev/null +++ b/surfsense_backend/app/automations/registries/capabilities/types.py @@ -0,0 +1,40 @@ +"""``Capability`` dataclass — the v1-minimum five-field shape.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +CapabilityHandler = Callable[[dict[str, Any]], Awaitable[Any]] +"""The signature every capability handler must satisfy. + +The handler is a closure that already holds whatever runtime context +it needs (DB session, search-space scope, logger, etc.). The +registry only passes through the caller's input dict — the same dict +that was validated against ``input_schema``. +""" + + +@dataclass(frozen=True, slots=True) +class Capability: + """The unit of "what SurfSense can do," consumed by every layer. + + v1 keeps the dataclass to exactly five fields. Earlier drafts + considered ``name``, ``required_credentials``, ``side_effects``, + ``expected_duration_seconds``, and ``cost_estimate``; every one + of those has been removed until a concrete consumer feature + requires it (see ``automation-design-plan.md`` §3, decision v1). + + The handler is a ready-to-call function. It does not receive a + context argument — context is bound at registration time by the + factory that builds the closure (so a capability returned to an + agent's tool list looks identical to one returned to an + automation's action runtime). + """ + + id: str + description: str + input_schema: dict[str, Any] + output_schema: dict[str, Any] + handler: CapabilityHandler diff --git a/surfsense_backend/app/automations/registries/triggers/__init__.py b/surfsense_backend/app/automations/registries/triggers/__init__.py index bc795b61a..f69c6fe8d 100644 --- a/surfsense_backend/app/automations/registries/triggers/__init__.py +++ b/surfsense_backend/app/automations/registries/triggers/__init__.py @@ -2,4 +2,12 @@ from __future__ import annotations -__all__: list[str] = [] +from .store import all_triggers, get_trigger, register_trigger +from .types import TriggerDefinition + +__all__ = [ + "TriggerDefinition", + "all_triggers", + "get_trigger", + "register_trigger", +] diff --git a/surfsense_backend/app/automations/registries/triggers/store.py b/surfsense_backend/app/automations/registries/triggers/store.py new file mode 100644 index 000000000..0a5fbdadb --- /dev/null +++ b/surfsense_backend/app/automations/registries/triggers/store.py @@ -0,0 +1,33 @@ +"""Trigger registry: in-memory dict + ``register_trigger`` API.""" + +from __future__ import annotations + +from .types import TriggerDefinition + +_REGISTRY: dict[str, TriggerDefinition] = {} + + +def register_trigger(trigger: TriggerDefinition) -> None: + """Add a trigger to the in-memory registry. + + Raises ``ValueError`` on duplicate ``type`` — registration runs + once per process, so a duplicate is always a bug. + """ + + if trigger.type in _REGISTRY: + raise ValueError( + f"Trigger already registered: {trigger.type!r}" + ) + _REGISTRY[trigger.type] = trigger + + +def get_trigger(trigger_type: str) -> TriggerDefinition | None: + """Look up one trigger by type. Returns ``None`` on miss.""" + + return _REGISTRY.get(trigger_type) + + +def all_triggers() -> dict[str, TriggerDefinition]: + """Snapshot of the registry as a defensive copy.""" + + return dict(_REGISTRY) diff --git a/surfsense_backend/app/automations/registries/triggers/types.py b/surfsense_backend/app/automations/registries/triggers/types.py new file mode 100644 index 000000000..256944823 --- /dev/null +++ b/surfsense_backend/app/automations/registries/triggers/types.py @@ -0,0 +1,35 @@ +"""``TriggerDefinition`` dataclass — declarative trigger metadata, no handler.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class TriggerDefinition: + """A trigger type the dispatcher knows how to fire. + + Triggers are purely declarative: the dispatcher (a single + process-wide component, not a per-type handler) reads the + ``automation_triggers`` table and decides when each row should + fire. The trigger's job here is to declare its input/output + contract: + + - ``config_schema``: JSON Schema for the persisted + ``AutomationTrigger.config`` — used by the form editor and + validated on save. + - ``payload_schema``: JSON Schema for the payload the dispatcher + will deliver to the executor at fire time (e.g., a schedule + trigger emits ``fired_at`` / ``scheduled_for`` / + ``last_fired_at``). + + No ``handler`` field — firing is a dispatcher responsibility, + not a per-trigger one. This keeps the dispatcher single and + leaves trigger types as pure metadata. + """ + + type: str + description: str + config_schema: dict[str, Any] + payload_schema: dict[str, Any]