SurfSense/surfsense_backend/app/automations/schemas/definition/envelope.py

90 lines
2.9 KiB
Python
Raw Normal View History

feat(automation): add Pydantic schemas for the automation definition Three layers of Pydantic models under app/automations/schemas/, one file per concern (SRP), matching the envelope in automation-design-plan.md §5. definition/ — the editable envelope persisted in automations.definition: - envelope.py AutomationDefinition (top-level shape) - plan_step.py PlanStep (one step in the sequential plan) - inputs.py InputsBlock (the inputs JSON Schema wrapper) - execution.py ExecutionBlock (timeouts, retries, concurrency, budget cap, on_failure plan) - metadata.py MetadataBlock (tags + created_from_nl + extras) - trigger_spec.py TriggerSpec (one entry in triggers[]) triggers/ — per-trigger config schemas, dispatched by registry on the TriggerSpec.type discriminator: - schedule.py ScheduleTriggerConfig(cron, timezone) - manual.py ManualTriggerConfig() — empty in v1 actions/ — per-action config schemas, dispatched by registry on the PlanStep.action discriminator: - agent_task.py AgentTaskActionConfig(prompt, tools, model, output_schema) Design properties verified by an inline smoke test: - The §5 worked example round-trips through model_validate_json / model_dump_json byte-for-byte (InputsBlock uses serialize_by_alias so the JSON key stays "schema" not "schema_"). - Envelope rejects unknown top-level keys (extra="forbid"). - MetadataBlock tolerates unknown keys (extra="allow"). - ExecutionBlock defaults apply when the block is omitted. - retry_backoff and concurrency are typed as Literal — bogus values rejected at validation time. - Per-type configs enforce their required fields (cron + timezone on schedule; non-empty prompt on agent_task). The envelope keeps trigger and action configs as untyped dicts on purpose — per-type validation is a registry-driven dispatch (commit 10), keeping the envelope free of every-type-knows-every-type coupling.
2026-05-26 22:50:52 +02:00
"""``AutomationDefinition`` — the top-level envelope persisted in ``automations.definition``."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from .execution import ExecutionBlock
from .inputs import InputsBlock
from .metadata import MetadataBlock
from .plan_step import PlanStep
from .trigger_spec import TriggerSpec
class AutomationDefinition(BaseModel):
"""The top-level JSON shape stored in ``automations.definition``.
This is the editable spec a user authors (or the NL generator
produces). The envelope is structural only every nested
discriminator (``triggers[].type``, ``plan[].action``) is resolved
against the registries at validation time, so adding a new
trigger or action type does not require touching this schema.
See ``automation-design-plan.md`` §5 for the worked example and
rationale.
"""
model_config = ConfigDict(extra="forbid")
schema_version: str = Field(
default="1.0",
description=(
"Schema version of the envelope itself. Migrations bump "
"this when the envelope shape changes; nested per-type "
"configs evolve independently via the registries."
),
)
name: str = Field(
...,
description="Short, user-facing name shown in lists.",
min_length=1,
max_length=200,
)
goal: str | None = Field(
default=None,
description=(
"Optional plain-language statement of what the "
"automation is for. Used by the NL generator's review "
"pass and by the UI's run dialog."
),
)
inputs: InputsBlock | None = Field(
default=None,
description=(
"Optional input contract. When omitted, the automation "
"accepts no inputs at fire time."
),
)
triggers: list[TriggerSpec] = Field(
default_factory=list,
description=(
"Triggers that fire this automation. Empty list means "
"the automation is only runnable via the manual "
"``Run now`` path."
),
)
plan: list[PlanStep] = Field(
...,
description=(
"Ordered sequence of steps. Executed in array order — "
"no parallelism, no DAGs, no loops at the envelope "
"level."
),
min_length=1,
)
execution: ExecutionBlock = Field(
default_factory=ExecutionBlock,
description=(
"Execution defaults (timeouts, retries, concurrency, "
"budget). All fields default to safe values; the block "
"may be omitted entirely."
),
)
metadata: MetadataBlock = Field(
default_factory=MetadataBlock,
description=(
"Free-form metadata (tags, NL-generator breadcrumbs, "
"UI annotations). Tolerates unknown keys by design."
),
)