File size: 6,365 Bytes
bd654eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import ast
import asyncio
import sys
import types
import unittest
from enum import IntEnum
from pathlib import Path

from pydantic import ValidationError

BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT))


def _install_auth_stub() -> None:
    auth_guard = types.ModuleType("api.auth_guard")

    class AuthRole(IntEnum):
        MACHINE = 1

    auth_guard.AuthRole = AuthRole
    auth_guard.require_role = lambda _role: (lambda: None)
    sys.modules["api.auth_guard"] = auth_guard


_install_auth_stub()

from agents.workflow_engine import Workflow, WorkflowExecutor, WorkflowStep
from api import workflows


class FakeKernel:
    def __init__(self, resolutions):
        self.resolutions = resolutions
        self.calls = []

    async def resolve_capability(self, tool_name):
        self.calls.append(tool_name)
        return self.resolutions.get(tool_name, {"status": "error"})


class FakeExecutor:
    def __init__(self, results=None):
        self.results = results or {}
        self.calls = []

    async def run_tool(self, tool_name, inputs, timeout=30.0, worker_hint=None):
        self.calls.append({
            "tool_name": tool_name,
            "inputs": inputs,
            "timeout": timeout,
            "worker_hint": worker_hint,
        })
        result = self.results.get(tool_name, {"success": True, "output": tool_name})
        if isinstance(result, Exception):
            raise result
        return result


class WorkflowExecutorTests(unittest.IsolatedAsyncioTestCase):
    async def test_uses_resolved_worker_hint_and_records_completed_step(self):
        kernel = FakeKernel({"search": {"status": "resolved", "worker": {"id": "worker-eu"}}})
        executor = FakeExecutor()
        engine = WorkflowExecutor(kernel=kernel, executor=executor)
        workflow = Workflow(name="ricerca", steps=[WorkflowStep(tool_name="search", args={"query": "AI"})])

        result = await engine.execute_workflow(workflow)

        self.assertEqual(result.status, "completed")
        self.assertEqual(result.steps[0].status, "completed")
        self.assertIsNotNone(result.steps[0].started_at)
        self.assertIsNotNone(result.steps[0].finished_at)
        self.assertEqual(executor.calls[0]["worker_hint"], "worker-eu")

    async def test_preserves_local_fallback_when_no_worker_is_resolved(self):
        kernel = FakeKernel({})
        executor = FakeExecutor()
        engine = WorkflowExecutor(kernel=kernel, executor=executor)
        workflow = Workflow(name="fallback", steps=[WorkflowStep(tool_name="local", args={"value": 1})])

        result = await engine.execute_workflow(workflow)

        self.assertEqual(result.status, "completed")
        self.assertIsNone(executor.calls[0]["worker_hint"])

    async def test_stops_after_unsuccessful_tool_result(self):
        kernel = FakeKernel({})
        executor = FakeExecutor({"first": {"success": False, "error": "denied"}})
        engine = WorkflowExecutor(kernel=kernel, executor=executor)
        workflow = Workflow(
            name="errore",
            steps=[
                WorkflowStep(tool_name="first", args={}),
                WorkflowStep(tool_name="second", args={}),
            ],
        )

        result = await engine.execute_workflow(workflow)

        self.assertEqual(result.status, "failed")
        self.assertEqual(result.steps[0].status, "failed")
        self.assertEqual(result.steps[0].error, "denied")
        self.assertEqual(result.steps[1].status, "pending")
        self.assertEqual([call["tool_name"] for call in executor.calls], ["first"])


class WorkflowApiTests(unittest.IsolatedAsyncioTestCase):
    def setUp(self):
        self.previous_executor = workflows._workflow_executor
        self.previous_tasks = workflows._workflow_tasks.copy()
        workflows._workflow_executor = None
        workflows._workflow_tasks.clear()

    async def asyncTearDown(self):
        for task in list(workflows._workflow_tasks.values()):
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass
        workflows._workflow_tasks.clear()
        workflows._workflow_executor = self.previous_executor
        workflows._workflow_tasks.update(self.previous_tasks)

    def test_router_exposes_start_and_status_paths(self):
        routes = {
            (route.path, method)
            for route in workflows.router.routes
            for method in (getattr(route, "methods", set()) or set())
        }

        self.assertEqual(workflows.router.prefix, "/api/workflows")
        self.assertIn(("/api/workflows", "POST"), routes)
        self.assertIn(("/api/workflows/{workflow_id}", "GET"), routes)

    def test_main_router_map_mounts_workflows(self):
        tree = ast.parse((BACKEND_ROOT / "main.py").read_text(encoding="utf-8"))
        router_map = next(
            node.value
            for node in tree.body
            if isinstance(node, ast.Assign)
            and any(isinstance(target, ast.Name) and target.id == "_ROUTER_MAP" for target in node.targets)
        )
        routes = {
            key.value: value.value
            for key, value in zip(router_map.keys, router_map.values)
            if isinstance(key, ast.Constant) and isinstance(value, ast.Constant)
        }

        self.assertEqual(routes.get("workflows"), "workflows")

    def test_start_contract_rejects_empty_steps(self):
        with self.assertRaises(ValidationError):
            workflows.WorkflowStartIn(name="vuoto", steps=[])

    async def test_start_and_get_workflow_use_background_execution(self):
        engine = WorkflowExecutor(kernel=FakeKernel({}), executor=FakeExecutor())
        workflows._workflow_executor = engine
        body = workflows.WorkflowStartIn(
            name="API workflow",
            steps=[WorkflowStep(tool_name="local", args={"n": 1})],
        )

        started = await workflows.start_workflow(body)
        task = workflows._workflow_tasks[started.workflow_id]
        await task
        fetched = await workflows.get_workflow(started.workflow_id)

        self.assertEqual(fetched.workflow_id, started.workflow_id)
        self.assertEqual(fetched.status, "completed")
        self.assertEqual(fetched.steps[0].status, "completed")


if __name__ == "__main__":
    unittest.main()