Files
wehub-resource-sync 6ede33ccdb
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:33:44 +08:00

273 lines
8.2 KiB
Python

"""Lock the ``execute_step`` orchestration contract.
Covers the pure step-execution logic: predicate gate, params rendering,
action lookup, retry budget, error shaping. The ``ActionContext.session``
is never touched by ``execute_step`` itself (it's only forwarded to the
handler), so unit tests pass ``None`` cast to the type.
"""
from __future__ import annotations
from typing import Any, cast
import pytest
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.automations.actions.store import register_action
from app.automations.actions.types import ActionContext, ActionDefinition
from app.automations.runtime.step import execute_step
from app.automations.schemas.definition.plan_step import PlanStep
pytestmark = pytest.mark.unit
class _AnyParams(BaseModel):
"""Open params model used by test actions — they never validate."""
model_config = {"extra": "allow"}
def _action_context() -> ActionContext:
"""Minimal context: session is unused by ``execute_step``, only forwarded."""
return ActionContext(
session=cast(AsyncSession, None),
run_id=1,
step_id="s1",
workspace_id=1,
creator_user_id=None,
)
async def test_execute_step_runs_registered_action_handler_and_wraps_result(
isolated_action_registry: None,
) -> None:
"""A step pointing at a registered action runs its handler with the
step's params and returns a ``succeeded`` entry carrying the handler's
output plus ``attempts=1`` (one try, no retries triggered)."""
invocations: list[dict[str, Any]] = []
async def echo(params: dict[str, Any]) -> dict[str, Any]:
invocations.append(params)
return {"echoed": params["value"]}
register_action(
ActionDefinition(
type="test_echo",
name="Echo",
description="Test action.",
params_model=_AnyParams,
build_handler=lambda _ctx: echo,
)
)
step = PlanStep(step_id="s1", action="test_echo", params={"value": "hello"})
result = await execute_step(
step=step,
template_context={},
action_context=_action_context(),
default_max_retries=0,
default_retry_backoff="none",
default_timeout_seconds=30,
)
assert result["status"] == "succeeded"
assert result["step_id"] == "s1"
assert result["action"] == "test_echo"
assert result["attempts"] == 1
assert result["result"] == {"echoed": "hello"}
assert invocations == [{"value": "hello"}]
async def test_execute_step_skips_step_when_predicate_is_falsy(
isolated_action_registry: None,
) -> None:
"""If ``step.when`` evaluates to falsy in the template context, the
handler is **not** invoked, the result entry has ``status=skipped``
and ``attempts=0``, and no ``result`` key is present."""
invoked = False
async def must_not_run(_params: dict[str, Any]) -> dict[str, Any]:
nonlocal invoked
invoked = True
return {}
register_action(
ActionDefinition(
type="test_guarded",
name="Guarded",
description="Test action that should not run.",
params_model=_AnyParams,
build_handler=lambda _ctx: must_not_run,
)
)
step = PlanStep(
step_id="s1",
action="test_guarded",
when="inputs.enabled",
params={},
)
result = await execute_step(
step=step,
template_context={"inputs": {"enabled": False}},
action_context=_action_context(),
default_max_retries=0,
default_retry_backoff="none",
default_timeout_seconds=30,
)
assert result["status"] == "skipped"
assert result["attempts"] == 0
assert "result" not in result
assert invoked is False
async def test_execute_step_fails_when_step_references_an_unknown_action(
isolated_action_registry: None,
) -> None:
"""A step pointing at an action that isn't in the registry must fail
with ``ActionNotFound`` rather than crashing. Catches typos in the
plan and removed actions without the run going off the rails."""
step = PlanStep(step_id="s1", action="no_such_action", params={})
result = await execute_step(
step=step,
template_context={},
action_context=_action_context(),
default_max_retries=0,
default_retry_backoff="none",
default_timeout_seconds=30,
)
assert result["status"] == "failed"
assert result["attempts"] == 0
assert result["error"]["type"] == "ActionNotFound"
assert "no_such_action" in result["error"]["message"]
async def test_execute_step_retries_failing_handler_up_to_default_budget(
isolated_action_registry: None,
) -> None:
"""A handler that raises on every attempt consumes the retry budget
(1 initial try + ``default_max_retries`` retries) and the step ends
``failed`` with the exception's type and message surfaced through
the error envelope."""
calls = 0
async def always_fails(_params: dict[str, Any]) -> dict[str, Any]:
nonlocal calls
calls += 1
raise RuntimeError("boom")
register_action(
ActionDefinition(
type="test_fails",
name="Fails",
description="Always raises.",
params_model=_AnyParams,
build_handler=lambda _ctx: always_fails,
)
)
step = PlanStep(step_id="s1", action="test_fails", params={})
result = await execute_step(
step=step,
template_context={},
action_context=_action_context(),
default_max_retries=2,
default_retry_backoff="none",
default_timeout_seconds=30,
)
assert result["status"] == "failed"
assert result["attempts"] == 3
assert calls == 3
assert result["error"]["type"] == "RuntimeError"
assert "boom" in result["error"]["message"]
async def test_execute_step_succeeds_when_handler_recovers_within_retry_budget(
isolated_action_registry: None,
) -> None:
"""A handler that fails the first N times and then succeeds yields a
``succeeded`` entry with ``attempts == N + 1``. Locks that retries
can actually recover (not just exhaust)."""
calls = 0
async def flaky(_params: dict[str, Any]) -> dict[str, Any]:
nonlocal calls
calls += 1
if calls < 3:
raise RuntimeError("transient")
return {"ok": True}
register_action(
ActionDefinition(
type="test_flaky",
name="Flaky",
description="Fails twice, succeeds third time.",
params_model=_AnyParams,
build_handler=lambda _ctx: flaky,
)
)
step = PlanStep(step_id="s1", action="test_flaky", params={})
result = await execute_step(
step=step,
template_context={},
action_context=_action_context(),
default_max_retries=2,
default_retry_backoff="none",
default_timeout_seconds=30,
)
assert result["status"] == "succeeded"
assert result["attempts"] == 3
assert result["result"] == {"ok": True}
assert calls == 3
async def test_execute_step_renders_step_params_through_template_engine(
isolated_action_registry: None,
) -> None:
"""Step params are rendered against the template context before the
handler is invoked. String values containing Jinja expressions get
substituted from ``inputs`` and ``steps`` in the run context."""
received: list[dict[str, Any]] = []
async def capture(params: dict[str, Any]) -> dict[str, Any]:
received.append(params)
return {}
register_action(
ActionDefinition(
type="test_capture",
name="Capture",
description="Captures the params passed in.",
params_model=_AnyParams,
build_handler=lambda _ctx: capture,
)
)
step = PlanStep(
step_id="s1",
action="test_capture",
params={"message": "Hello {{ inputs.name }}"},
)
await execute_step(
step=step,
template_context={"inputs": {"name": "World"}, "steps": {}},
action_context=_action_context(),
default_max_retries=0,
default_retry_backoff="none",
default_timeout_seconds=30,
)
assert received == [{"message": "Hello World"}]