mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(automation): add empty Capability / Action / Trigger registries
Three registries under app/automations/registries/, each as its own
folder with the same SRP-per-file split (types.py for the dataclass,
store.py for the in-memory dict + register/get/all functions). All
three start empty; concrete entries land when the user signs off on
which capabilities / actions / triggers to include (step 2).
Capability (locked at v1-minimum five fields — see commit 2):
- id, description, input_schema, output_schema, handler
- CapabilityHandler = Callable[[dict[str, Any]], Awaitable[Any]]
- Frozen, slotted dataclass (immutable post-registration).
ActionDefinition (v1-trim of design plan §4):
- type, name, description, config_schema, handler
- Defers output_contract (handled per-step by agent_task's
config.output_schema), uses_capabilities (no static analysis
needed until >1 action ships), and produces_artifacts (deferred
alongside the artifact pipeline).
TriggerDefinition (declarative, no handler):
- type, description, config_schema, payload_schema
- No handler field — firing is a single dispatcher's
responsibility, not a per-trigger one.
store.py contract for all three:
- register_*: idempotent at process startup, raises on duplicate
- get_*: returns None on miss
- all_*: returns a defensive copy of the registry dict
Verified by an inline smoke test (10 checks): empty initial state,
registration and lookup work, duplicates raise, frozen dataclasses
reject mutation, snapshots are copies, handlers are awaitable.
Isolation invariant audit: grep across the full app/automations/
tree shows only three app.* imports, all of them
``from app.db import BaseModel, TimestampMixin`` in the model files.
No imports from app.agents.*, app.services.*, app.tasks.*,
app.routes.*, or any other business-logic module.
This commit is contained in:
parent
be4d43d6c9
commit
7a96c0e29c
10 changed files with 291 additions and 4 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue