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:
CREDO23 2026-05-26 22:54:17 +02:00
parent be4d43d6c9
commit 7a96c0e29c
10 changed files with 291 additions and 4 deletions

View file

@ -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",
]

View file

@ -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)

View file

@ -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