-
Notifications
You must be signed in to change notification settings - Fork 9
AUT-56 | Add decorator: automation_activity #829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,12 @@ | ||
| import asyncio | ||
| from concurrent.futures import ThreadPoolExecutor | ||
| from typing import Any, Dict, List, Optional, Tuple, Type | ||
|
|
||
| from application_sdk.activities import ActivitiesInterface | ||
| from application_sdk.clients.base import BaseClient | ||
| from application_sdk.clients.utils import get_workflow_client | ||
| from application_sdk.constants import ENABLE_MCP | ||
| from application_sdk.decorators.automation_activity import ACTIVITY_SPECS, flush_activity_registrations | ||
| from application_sdk.events.models import EventRegistration | ||
| from application_sdk.handlers.base import BaseHandler | ||
| from application_sdk.observability.logger_adaptor import get_logger | ||
|
|
@@ -152,6 +154,20 @@ async def setup_workflow( | |
| workflow_and_activities_classes=workflow_and_activities_classes | ||
| ) | ||
|
|
||
| # Register activities via HTTP API for automation engine (non-blocking) | ||
| # The 5 second delay allows the automation engine's server to come up | ||
| async def _register_activities_with_delay(): | ||
| """Register activities after a 5 second delay to allow automation | ||
| engine server to start.""" | ||
| await asyncio.sleep(1) | ||
| await asyncio.to_thread( | ||
| flush_activity_registrations, | ||
| app_name=self.application_name, | ||
| activity_specs=ACTIVITY_SPECS, | ||
| ) | ||
|
|
||
| asyncio.create_task(_register_activities_with_delay()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Background task lacks exception handlerThe background task created with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Fire-and-forget task risks early garbage collectionCreating a task without storing a reference to it risks the task being garbage collected before completion in some Python implementations. While CPython typically keeps running tasks alive, storing the task reference ensures the registration completes reliably across all implementations, similar to how There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Background task exceptions silently ignoredThe background task created with |
||
|
|
||
| async def start_workflow(self, workflow_args, workflow_class) -> Any: | ||
| """ | ||
| Start a new workflow execution. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| """ | ||
| Activity registration decorator for automatic registration with the automation engine. | ||
| """ | ||
|
|
||
| import inspect | ||
| import json | ||
| from typing import Any, Callable, Dict, List, Union, get_args, get_origin | ||
|
|
||
| from pydantic import BaseModel | ||
| import requests | ||
|
|
||
| from application_sdk.constants import AUTOMATION_ENGINE_API_URL | ||
| from application_sdk.observability.logger_adaptor import get_logger | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
||
| # Global registry to collect decorated activities | ||
| ACTIVITY_SPECS: List[dict[str, Any]] = [] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Activity registry shared across multiple appsThe global |
||
|
|
||
|
|
||
| def _type_to_json_schema(annotation: Any) -> Dict[str, Any]: | ||
| """Convert Python type annotation to JSON Schema using reflection.""" | ||
| if inspect.isclass(annotation) and issubclass(annotation, BaseModel): | ||
| schema = annotation.model_json_schema(mode="serialization") | ||
| if "$defs" in schema: | ||
| defs = schema.pop("$defs") | ||
| schema = {**schema, **defs} | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return schema | ||
|
|
||
| origin = get_origin(annotation) | ||
| if origin is Union: | ||
| args = get_args(annotation) | ||
| if type(None) in args: | ||
| non_none_types = [arg for arg in args if arg is not type(None)] | ||
| if non_none_types: | ||
| schema = _type_to_json_schema(non_none_types[0]) | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| schema["nullable"] = True | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return schema | ||
|
||
|
|
||
| if origin is dict or annotation is dict: | ||
| args = get_args(annotation) | ||
| if args: | ||
| return { | ||
| "type": "object", | ||
| "additionalProperties": _type_to_json_schema(args[1]), | ||
| } | ||
| return {"type": "object"} | ||
|
|
||
| if origin is list or annotation is list: | ||
| args = get_args(annotation) | ||
| if args: | ||
| return {"type": "array", "items": _type_to_json_schema(args[0])} | ||
| return {"type": "array"} | ||
|
|
||
| type_mapping = { | ||
| str: {"type": "string"}, | ||
| int: {"type": "integer"}, | ||
| float: {"type": "number"}, | ||
| bool: {"type": "boolean"}, | ||
| Any: {}, | ||
| } | ||
|
|
||
| if annotation in type_mapping: | ||
| return type_mapping[annotation] | ||
|
|
||
| return {} | ||
|
|
||
|
|
||
| def _generate_input_schema(func: Any) -> Dict[str, Any]: | ||
| """Generate JSON Schema for function inputs using reflection.""" | ||
| sig = inspect.signature(func) | ||
| properties = {} | ||
| required: list[str] = [] | ||
|
|
||
| for param_name, param in sig.parameters.items(): | ||
| if param_name == "self": | ||
| continue | ||
|
|
||
| param_schema = ( | ||
| _type_to_json_schema(param.annotation) | ||
| if param.annotation != inspect.Parameter.empty | ||
| else {} | ||
| ) | ||
|
|
||
| if param.default != inspect.Parameter.empty: | ||
| if isinstance(param.default, (str, int, float, bool, type(None))): | ||
| param_schema["default"] = param.default | ||
| else: | ||
| required.append(param_name) | ||
|
|
||
| properties[param_name] = param_schema | ||
|
|
||
| schema = {"type": "object", "properties": properties} | ||
| if required: | ||
| schema["required"] = required | ||
|
|
||
| return schema | ||
|
|
||
|
|
||
| def _generate_output_schema(func: Any) -> Dict[str, Any]: | ||
| """Generate JSON Schema for function outputs using reflection.""" | ||
| return_annotation = inspect.signature(func).return_annotation | ||
| if return_annotation == inspect.Signature.empty: | ||
| return {} | ||
| return _type_to_json_schema(return_annotation) | ||
|
|
||
|
|
||
| def automation_activity( | ||
| name: str, | ||
| description: str, | ||
| ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: | ||
| """Decorator to mark an activity for automatic registration.""" | ||
|
|
||
| def decorator(func: Callable[..., Any]) -> Callable[..., Any]: | ||
| input_schema: dict[str, Any] = _generate_input_schema(func) | ||
| output_schema: dict[str, Any] = _generate_output_schema(func) | ||
|
|
||
| logger.info(f"Collected automation activity: {name}") | ||
| ACTIVITY_SPECS.append( | ||
| { | ||
| "name": name, | ||
| "description": description, | ||
| "func": func, | ||
| "input_schema": json.dumps(input_schema) if input_schema else None, | ||
| "output_schema": json.dumps(output_schema) if output_schema else None, | ||
| } | ||
| ) | ||
| return func | ||
|
|
||
| return decorator | ||
|
|
||
|
|
||
| def flush_activity_registrations( | ||
| app_name: str, | ||
| activity_specs: List[dict[str, Any]], | ||
| ) -> None: | ||
| """Flush all collected registrations by calling the activities create API via HTTP.""" | ||
| if not activity_specs: | ||
| logger.info("No activities to register") | ||
| return | ||
|
|
||
| if not AUTOMATION_ENGINE_API_URL: | ||
| logger.warning( | ||
| "Automation engine API URL not configured. Skipping activity registration." | ||
| ) | ||
| return | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| # Perform health check first | ||
| try: | ||
| health_check_url: str = f"{AUTOMATION_ENGINE_API_URL}/api/health" | ||
| health_response: requests.Response = requests.get(health_check_url, timeout=5.0) | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| health_response.raise_for_status() | ||
| logger.info("Automation engine health check passed") | ||
| except Exception as e: | ||
| logger.warning( | ||
| f"Automation engine health check failed: {e}. " | ||
| "Skipping activity registration. " | ||
| "Check if the automation engine is deployed and accessible." | ||
| ) | ||
| return | ||
|
|
||
| logger.info( | ||
| f"Registering {len(ACTIVITY_SPECS)} activities with automation engine" | ||
|
||
| ) | ||
|
|
||
| # Generate app qualified name | ||
| app_qualified_name: str = f"default/apps/{app_name}" | ||
|
|
||
| # Build tools payload without function objects (not JSON serializable) | ||
| tools = [ | ||
| { | ||
| "name": item["name"], | ||
| "description": item["description"], | ||
| "input_schema": item["input_schema"], | ||
| "output_schema": item["output_schema"], | ||
| } | ||
| for item in activity_specs | ||
| ] | ||
|
|
||
| payload = { | ||
| "app_qualified_name": app_qualified_name, | ||
| "app_name": app_name, | ||
| "tools": tools, | ||
| } | ||
|
|
||
| try: | ||
| response: requests.Response = requests.post( | ||
| f"{AUTOMATION_ENGINE_API_URL}/api/tools", | ||
| json=payload, | ||
| timeout=30.0, | ||
| ) | ||
| response.raise_for_status() | ||
| result = response.json() | ||
|
|
||
| if result.get("status") == "success": | ||
| logger.info( | ||
| f"Successfully registered {len(tools)} activities with automation engine" | ||
| ) | ||
| else: | ||
| logger.warning( | ||
| f"Failed to register activities with automation engine: {result.get('message')}" | ||
| ) | ||
| except Exception as e: | ||
| raise Exception( | ||
| f"Failed to register activities with automation engine: {e}" | ||
| ) from e | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Comment states 5 seconds but code sleeps 1
The comment on line 158 states "The 5 second delay allows the automation engine's server to come up" and the docstring on line 160 says "after a 5 second delay", but the actual sleep on line 162 is only 1 second. This mismatch suggests either incomplete testing or a last-minute change that wasn't reflected in documentation.