2929from .exceptions import (
3030 LoopAlreadyDefinedError ,
3131 LoopNotFoundError ,
32+ TaskNotFoundError ,
3233 WorkflowNotFoundError ,
3334)
3435from .integrations import Integration
3536from .logging import configure_logging , setup_logger
3637from .loop import Loop , LoopManager
3738from .models import LoopEvent
3839from .monitor import LoopMonitor
40+ from .scheduler import Schedule , validate_cron
3941from .state .state import StateManager , create_state_manager
40- from .types import BaseConfig , LoopStatus , RetryPolicy
42+ from .task import TaskManager , TaskResult
43+ from .types import BaseConfig , ExecutorType , LoopStatus , RetryPolicy
4144from .utils import get_func_import_path , import_func_from_path , infer_application_path
4245from .workflow import Workflow , WorkflowManager
4346
@@ -83,6 +86,7 @@ async def lifespan(_: FastAPI):
8386 self ._monitor_task .cancel ()
8487 await self .loop_manager .stop_all ()
8588 await self .workflow_manager .stop_all ()
89+ await self .task_manager .stop_all ()
8690
8791 super ().__init__ (* args , ** kwargs , lifespan = lifespan )
8892
@@ -102,10 +106,12 @@ async def lifespan(_: FastAPI):
102106 )
103107 self .loop_manager : LoopManager = LoopManager (self .config , self .state_manager )
104108 self .workflow_manager : WorkflowManager = WorkflowManager (self .state_manager )
109+ self .task_manager : TaskManager = TaskManager (self .state_manager )
105110 self ._monitor_task : asyncio .Task [None ] | None = None
106111 self ._loop_start_func : Callable [[LoopContext ], None ] | None = None
107112 self ._loop_metadata : dict [str , dict [str , Any ]] = {}
108113 self ._workflow_metadata : dict [str , dict [str , Any ]] = {}
114+ self ._task_metadata : dict [str , dict [str , Any ]] = {}
109115
110116 configure_logging (
111117 pretty_print = self .config_manager .get ("prettyPrintLogs" , False )
@@ -802,3 +808,139 @@ async def restart_workflow(self, workflow_run_id: str) -> bool:
802808 extra = {"workflow_run_id" : workflow_run_id , "error" : str (e )},
803809 )
804810 return False
811+
812+ def task (
813+ self ,
814+ name : str ,
815+ retry : RetryPolicy | None = None ,
816+ executor : ExecutorType = ExecutorType .ASYNC ,
817+ ) -> Callable [[Callable [..., Any ]], Callable [..., Any ]]:
818+ """Register a task. Creates POST /{name} and GET /{name}/{task_id} endpoints."""
819+
820+ def _decorator (func : Callable [..., Any ]) -> Callable [..., Any ]:
821+ if name in self ._task_metadata :
822+ raise LoopAlreadyDefinedError (f"Task { name } already registered" )
823+
824+ self ._task_metadata [name ] = {
825+ "func" : func ,
826+ "retry" : retry ,
827+ "executor" : executor ,
828+ }
829+
830+ async def _invoke_handler (request : dict [str , Any ]):
831+ result = await self .task_manager .submit (
832+ func = func ,
833+ args = request ,
834+ task_name = name ,
835+ retry_policy = retry ,
836+ executor_type = executor ,
837+ )
838+ return JSONResponse (
839+ content = {"task_id" : result .task_id , "status" : "pending" },
840+ status_code = HTTPStatus .ACCEPTED ,
841+ )
842+
843+ async def _status_handler (task_id : str ):
844+ try :
845+ task = await self .state_manager .get_task (task_id )
846+ return JSONResponse (content = task .to_dict ())
847+ except TaskNotFoundError as e :
848+ raise HTTPException (
849+ status_code = HTTPStatus .NOT_FOUND , detail = str (e )
850+ ) from e
851+
852+ self .add_api_route (f"/{ name } " , _invoke_handler , methods = ["POST" ])
853+ self .add_api_route (f"/{ name } /{{task_id}}" , _status_handler , methods = ["GET" ])
854+
855+ return func
856+
857+ return _decorator
858+
859+ async def invoke (
860+ self ,
861+ task_name : str ,
862+ wait : bool = False ,
863+ timeout : float | None = None ,
864+ ** kwargs : Any ,
865+ ) -> str | TaskResult | Any :
866+ """Invoke a task. Returns task_id if wait=False, else waits and returns result."""
867+ if task_name not in self ._task_metadata :
868+ raise ValueError (f"Unknown task: { task_name } " )
869+
870+ metadata = self ._task_metadata [task_name ]
871+ handle = await self .task_manager .submit (
872+ func = metadata ["func" ],
873+ args = kwargs ,
874+ task_name = task_name ,
875+ retry_policy = metadata .get ("retry" ),
876+ executor_type = metadata .get ("executor" , ExecutorType .ASYNC ),
877+ )
878+
879+ if wait :
880+ return await handle .result (timeout = timeout )
881+
882+ return handle .task_id
883+
884+ def schedule (
885+ self ,
886+ name : str ,
887+ cron : str | None = None ,
888+ interval : float | None = None ,
889+ retry : RetryPolicy | None = None ,
890+ executor : ExecutorType = ExecutorType .ASYNC ,
891+ args : dict [str , Any ] | None = None ,
892+ ) -> Callable [[Callable [..., Any ]], Callable [..., Any ]]:
893+ """Register a scheduled task. Use cron="*/5 * * * *" or interval=60."""
894+ if not cron and not interval :
895+ raise ValueError ("Must specify either cron or interval" )
896+
897+ if cron and not validate_cron (cron ):
898+ raise ValueError (f"Invalid cron expression: { cron } " )
899+
900+ def _decorator (func : Callable [..., Any ]) -> Callable [..., Any ]:
901+ self .task (name = name , retry = retry , executor = executor )(func )
902+
903+ schedule = Schedule (
904+ task_name = name ,
905+ cron = cron ,
906+ interval_seconds = interval ,
907+ args = args or {},
908+ )
909+
910+ self ._task_metadata [name ]["schedule" ] = schedule
911+
912+ return func
913+
914+ return _decorator
915+
916+ async def schedule_task (
917+ self ,
918+ task_name : str ,
919+ cron : str | None = None ,
920+ interval : float | None = None ,
921+ args : dict [str , Any ] | None = None ,
922+ schedule_id : str | None = None ,
923+ ) -> str :
924+ """Programmatically schedule a registered task. Returns the schedule_id."""
925+ if task_name not in self ._task_metadata :
926+ raise ValueError (f"Unknown task: { task_name } " )
927+
928+ if not cron and not interval :
929+ raise ValueError ("Must specify either cron or interval" )
930+
931+ if cron and not validate_cron (cron ):
932+ raise ValueError (f"Invalid cron expression: { cron } " )
933+
934+ sid = schedule_id or task_name
935+ schedule = Schedule (
936+ task_name = task_name ,
937+ cron = cron ,
938+ interval_seconds = interval ,
939+ args = args or {},
940+ )
941+ await self .state_manager .save_schedule (sid , schedule )
942+ return sid
943+
944+ async def unschedule_task (self , schedule_id : str ) -> None :
945+ """Remove a scheduled task by its schedule_id."""
946+ await self .state_manager .delete_schedule (schedule_id )
0 commit comments