Skip to content

Commit 1cb31bb

Browse files
authored
feat: Schedule API (#88)
# Agentex Schedule API - PR Summary ## Overview This PR introduces a new Schedule API for Agentex that enables agent-scoped management of Temporal schedules. The feature supports both cron-based and interval-based scheduling for recurring workflow executions, designed to power async agents. ## Goal Enable async agents to programmatically schedule when their pre-defined workflows run through the Agentex API. This allows agents to: - Schedule recurring executions of existing workflows - Pause/resume schedules based on external conditions - Trigger immediate workflow executions on-demand - Monitor schedule status and execution history ## Files Created ### Agentex Backend (`scale-agentex/agentex/`) | File | Description | |------|-------------| | `src/api/routes/schedules.py` | FastAPI router with schedule CRUD endpoints | | `src/api/schemas/schedules.py` | Pydantic models for request/response schemas | | `src/domain/services/schedule_service.py` | Business logic for schedule management | | `src/domain/use_cases/schedules_use_case.py` | Use case layer wrapping schedule service | ### Files Modified | File | Changes | |------|---------| | `src/adapters/temporal/adapter_temporal.py` | Added Temporal schedule operations (create, describe, list, pause, unpause, trigger, delete) | | `src/adapters/temporal/port.py` | Added schedule method signatures to Temporal port interface | | `src/api/app.py` | Registered schedules router | ## API Endpoints All endpoints are scoped under `/agents/{agent_id}/schedules`: | Endpoint | Method | Description | |----------|--------|-------------| | `/agents/{agent_id}/schedules` | `POST` | Create a new schedule | | `/agents/{agent_id}/schedules` | `GET` | List all schedules for an agent | | `/agents/{agent_id}/schedules/{schedule_name}` | `GET` | Get schedule details | | `/agents/{agent_id}/schedules/{schedule_name}/pause` | `POST` | Pause a schedule | | `/agents/{agent_id}/schedules/{schedule_name}/unpause` | `POST` | Resume a paused schedule | | `/agents/{agent_id}/schedules/{schedule_name}/trigger` | `POST` | Trigger immediate execution | | `/agents/{agent_id}/schedules/{schedule_name}` | `DELETE` | Delete a schedule | ## Request/Response Schemas ### CreateScheduleRequest ```json { "name": "string (required)", "workflow_name": "string (required)", "task_queue": "string (required)", "cron_expression": "string (optional, e.g., '0 * * * *')", "interval_seconds": "integer (optional)", "workflow_params": "object (optional)", "execution_timeout_seconds": "integer (optional)", "start_at": "datetime (optional)", "end_at": "datetime (optional)", "paused": "boolean (optional, default: false)" } ``` ### ScheduleResponse ```json { "schedule_id": "string", "name": "string", "agent_id": "string", "state": "ACTIVE | PAUSED", "action": { "workflow_name": "string", "workflow_id_prefix": "string", "task_queue": "string", "workflow_params": "list | null" }, "spec": { "cron_expressions": ["string"], "intervals_seconds": [integer], "start_at": "datetime | null", "end_at": "datetime | null" }, "num_actions_taken": "integer", "num_actions_missed": "integer", "next_action_times": ["datetime"], "last_action_time": "datetime | null", "created_at": "datetime | null" } ``` ## Bugs Fixed During Development ### 1. Async Iterator Bug in `list_schedules` **File:** `src/adapters/temporal/adapter_temporal.py:502-503` **Problem:** The Temporal SDK's `client.list_schedules()` returns a coroutine that yields an async iterator, but the code was treating it as a direct async iterator. **Error:** ```python 'async for' requires an object with __aiter__ method, got coroutine ``` **Fix:** ```python # Before (incorrect) async for schedule in self.client.list_schedules(page_size=page_size): # After (correct) async for schedule in await self.client.list_schedules(page_size=page_size): ``` ### 2. Temporal Payload Serialization Bug **File:** `src/domain/services/schedule_service.py:268-288` **Problem:** When fetching schedule details, `action.args` contains raw Temporal `Payload` protobuf objects that cannot be JSON-serialized by Pydantic. **Error:** ```python Unable to serialize unknown type: <class 'temporalio.api.common.v1.message_pb2.Payload'> ``` **Fix:** Added code to extract and decode payload data to JSON-serializable format: ```python if action.args: try: workflow_params = [] for arg in action.args: if hasattr(arg, "data"): try: import json workflow_params.append(json.loads(arg.data.decode("utf-8"))) except (json.JSONDecodeError, UnicodeDecodeError): workflow_params.append(str(arg.data)) else: workflow_params.append(str(arg)) except Exception: workflow_params = None ``` ### 3. ScheduleActionResult Attribute Name Bug **File:** `src/domain/services/schedule_service.py:313-317` **Problem:** The code was accessing `info.recent_actions[-1].actual_time`, but `ScheduleActionResult` uses `started_at` and `scheduled_at` attributes, not `actual_time`. **Error:** ```Python 'ScheduleActionResult' object has no attribute 'actual_time' ``` **Fix:** ```python # Before (incorrect) last_action_time = info.recent_actions[-1].actual_time # After (correct) last_action = info.recent_actions[-1] last_action_time = getattr(last_action, "started_at", None) or getattr(last_action, "scheduled_at", None) ``` ### 4. Schedule args double-wrapping Bug ScheduleActionStartWorkflow expects positional args, not a list. The args were being passed as `[params]` which became `[[params]]`. Fixed by unpacking with `*(args if args else [])`. ## Testing Results All endpoints were tested against a local Docker Compose environment with an existing agent which was registered locally: | Test | Result | |------|--------| | List schedules (initially empty) | ✅ Pass | | Create schedule with cron expression | ✅ Pass | | Create schedule with interval_seconds | ✅ Pass | | Get schedule details | ✅ Pass | | Pause schedule | ✅ Pass | | Unpause schedule | ✅ Pass | | Trigger schedule immediately | ✅ Pass | | Delete schedule | ✅ Pass | | Verify deletion | ✅ Pass | ### Sample Test Commands ```bash # List schedules curl -s http://localhost:5003/agents/{agent_id}/schedules # Create schedule with cron curl -s -X POST http://localhost:5003/agents/{agent_id}/schedules \ -H "Content-Type: application/json" \ -d '{"name": "hourly-job", "workflow_name": "my-orchestrator", "task_queue": "my-queue", "cron_expression": "0 * * * *"}' # Create schedule with interval curl -s -X POST http://localhost:5003/agents/{agent_id}/schedules \ -H "Content-Type: application/json" \ -d '{"name": "interval-job", "workflow_name": "my-orchestrator", "task_queue": "my-queue", "interval_seconds": 3600}' # Pause/Unpause curl -s -X POST http://localhost:5003/agents/{agent_id}/schedules/{name}/pause curl -s -X POST http://localhost:5003/agents/{agent_id}/schedules/{name}/unpause # Trigger immediately curl -s -X POST http://localhost:5003/agents/{agent_id}/schedules/{name}/trigger # Delete curl -s -X DELETE http://localhost:5003/agents/{agent_id}/schedules/{name} ``` ## Architecture Notes - **Schedule ID Format:** `{agent_id}--{schedule_name}` (using `--` as separator) - **Workflow ID Format:** `{schedule_id}-run-{timestamp}` (auto-generated by Temporal) - Schedules are stored in Temporal, not in the Agentex database *(!! Please lmk if this needs to be changed !!)* - Authorization uses existing agent-scoped authorization middleware ## Future Considerations 1. **SDK Integration:** The agentex-python SDK is Stainless-generated. Schedule endpoints should be auto-added when the OpenAPI spec is updated. 2. **Backfill Support:** Temporal supports schedule backfills which could be exposed as an additional endpoint if needed.
1 parent 8ceb59a commit 1cb31bb

9 files changed

Lines changed: 2734 additions & 3 deletions

File tree

agentex/src/adapters/temporal/adapter_temporal.py

Lines changed: 278 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,29 @@
44
from fastapi import Depends
55
from temporalio.client import (
66
Client,
7+
Schedule,
8+
ScheduleActionStartWorkflow,
9+
ScheduleAlreadyRunningError,
10+
ScheduleDescription,
11+
ScheduleHandle,
12+
ScheduleIntervalSpec,
13+
ScheduleSpec,
14+
ScheduleState,
715
WorkflowExecution,
816
WorkflowHandle,
917
)
1018
from temporalio.common import RetryPolicy, WorkflowIDReusePolicy
11-
from temporalio.exceptions import (
12-
WorkflowAlreadyStartedError,
13-
)
19+
from temporalio.exceptions import WorkflowAlreadyStartedError
1420

1521
from src.adapters.temporal.exceptions import (
1622
TemporalCancelError,
1723
TemporalConnectionError,
1824
TemporalError,
1925
TemporalInvalidArgumentError,
2026
TemporalQueryError,
27+
TemporalScheduleAlreadyExistsError,
28+
TemporalScheduleError,
29+
TemporalScheduleNotFoundError,
2130
TemporalSignalError,
2231
TemporalTerminateError,
2332
TemporalWorkflowAlreadyExistsError,
@@ -335,6 +344,272 @@ async def is_connected(self) -> bool:
335344
logger.warning(f"Temporal connectivity check failed: {e}")
336345
return False
337346

347+
# ==================== Schedule Operations ====================
348+
349+
async def create_schedule(
350+
self,
351+
schedule_id: str,
352+
workflow: str | type,
353+
workflow_id: str,
354+
args: list[Any] | None = None,
355+
task_queue: str | None = None,
356+
cron_expressions: list[str] | None = None,
357+
interval_seconds: int | None = None,
358+
execution_timeout: timedelta | None = None,
359+
start_at: Any | None = None,
360+
end_at: Any | None = None,
361+
paused: bool = False,
362+
) -> ScheduleHandle:
363+
"""
364+
Create a new schedule for recurring workflow execution.
365+
"""
366+
if not self.client:
367+
raise TemporalConnectionError("Temporal client is not connected")
368+
369+
if not cron_expressions and not interval_seconds:
370+
raise TemporalInvalidArgumentError(
371+
message="Either cron_expressions or interval_seconds must be provided",
372+
detail="A schedule requires at least one scheduling specification",
373+
)
374+
375+
try:
376+
# Build schedule spec
377+
spec = ScheduleSpec(
378+
cron_expressions=cron_expressions or [],
379+
intervals=[
380+
ScheduleIntervalSpec(every=timedelta(seconds=interval_seconds))
381+
]
382+
if interval_seconds
383+
else [],
384+
start_at=start_at,
385+
end_at=end_at,
386+
)
387+
388+
# Build workflow action
389+
action_kwargs: dict[str, Any] = {
390+
"id": workflow_id,
391+
}
392+
if task_queue:
393+
action_kwargs["task_queue"] = task_queue
394+
if execution_timeout:
395+
action_kwargs["execution_timeout"] = execution_timeout
396+
397+
action = ScheduleActionStartWorkflow(
398+
workflow,
399+
*(args if args else []),
400+
**action_kwargs,
401+
)
402+
403+
# Build schedule state
404+
state = ScheduleState(
405+
paused=paused,
406+
)
407+
408+
# Create the schedule
409+
handle = await self.client.create_schedule(
410+
schedule_id,
411+
Schedule(
412+
action=action,
413+
spec=spec,
414+
state=state,
415+
),
416+
)
417+
418+
logger.info(f"Created schedule {schedule_id} successfully")
419+
return handle
420+
421+
except ScheduleAlreadyRunningError as e:
422+
logger.error(f"Schedule {schedule_id} already exists: {e}")
423+
raise TemporalScheduleAlreadyExistsError(
424+
message=f"Schedule with ID '{schedule_id}' already exists",
425+
detail=str(e),
426+
) from e
427+
except ValueError as e:
428+
logger.error(f"Invalid arguments for schedule {schedule_id}: {e}")
429+
raise TemporalInvalidArgumentError(
430+
message=f"Invalid arguments provided for schedule '{schedule_id}'",
431+
detail=str(e),
432+
) from e
433+
except Exception as e:
434+
logger.error(f"Failed to create schedule {schedule_id}: {e}")
435+
raise TemporalScheduleError(
436+
message=f"Failed to create schedule '{schedule_id}'",
437+
detail=str(e),
438+
) from e
439+
440+
async def get_schedule(self, schedule_id: str) -> ScheduleHandle:
441+
"""
442+
Get a handle to an existing schedule.
443+
"""
444+
if not self.client:
445+
raise TemporalConnectionError("Temporal client is not connected")
446+
447+
try:
448+
handle = self.client.get_schedule_handle(schedule_id)
449+
# Verify the schedule exists by describing it
450+
await handle.describe()
451+
return handle
452+
except Exception as e:
453+
if "not found" in str(e).lower():
454+
logger.error(f"Schedule {schedule_id} not found: {e}")
455+
raise TemporalScheduleNotFoundError(
456+
message=f"Schedule '{schedule_id}' not found",
457+
detail=str(e),
458+
) from e
459+
logger.error(f"Failed to get schedule {schedule_id}: {e}")
460+
raise TemporalScheduleError(
461+
message=f"Failed to get schedule '{schedule_id}'",
462+
detail=str(e),
463+
) from e
464+
465+
async def describe_schedule(self, schedule_id: str) -> ScheduleDescription:
466+
"""
467+
Get detailed information about a schedule.
468+
"""
469+
if not self.client:
470+
raise TemporalConnectionError("Temporal client is not connected")
471+
472+
try:
473+
handle = self.client.get_schedule_handle(schedule_id)
474+
description = await handle.describe()
475+
logger.info(f"Retrieved description for schedule {schedule_id}")
476+
return description
477+
except Exception as e:
478+
if "not found" in str(e).lower():
479+
logger.error(f"Schedule {schedule_id} not found: {e}")
480+
raise TemporalScheduleNotFoundError(
481+
message=f"Schedule '{schedule_id}' not found",
482+
detail=str(e),
483+
) from e
484+
logger.error(f"Failed to describe schedule {schedule_id}: {e}")
485+
raise TemporalScheduleError(
486+
message=f"Failed to describe schedule '{schedule_id}'",
487+
detail=str(e),
488+
) from e
489+
490+
async def list_schedules(
491+
self,
492+
page_size: int = 100,
493+
) -> list[Any]:
494+
"""
495+
List all schedules.
496+
"""
497+
if not self.client:
498+
raise TemporalConnectionError("Temporal client is not connected")
499+
500+
try:
501+
schedules = []
502+
# list_schedules() returns a coroutine that yields an async iterator
503+
async for schedule in await self.client.list_schedules(page_size=page_size): # type: ignore[union-attr]
504+
schedules.append(schedule)
505+
if len(schedules) >= page_size:
506+
break
507+
508+
logger.info(f"Listed {len(schedules)} schedules")
509+
return schedules
510+
except Exception as e:
511+
logger.error(f"Failed to list schedules: {e}")
512+
raise TemporalError(
513+
message="Failed to list schedules",
514+
detail=str(e),
515+
) from e
516+
517+
async def pause_schedule(self, schedule_id: str, note: str | None = None) -> None:
518+
"""
519+
Pause a schedule.
520+
"""
521+
if not self.client:
522+
raise TemporalConnectionError("Temporal client is not connected")
523+
524+
try:
525+
handle = self.client.get_schedule_handle(schedule_id)
526+
await handle.pause(note=note or "Paused via API")
527+
logger.info(f"Paused schedule {schedule_id}")
528+
except Exception as e:
529+
if "not found" in str(e).lower():
530+
logger.error(f"Schedule {schedule_id} not found: {e}")
531+
raise TemporalScheduleNotFoundError(
532+
message=f"Schedule '{schedule_id}' not found",
533+
detail=str(e),
534+
) from e
535+
logger.error(f"Failed to pause schedule {schedule_id}: {e}")
536+
raise TemporalScheduleError(
537+
message=f"Failed to pause schedule '{schedule_id}'",
538+
detail=str(e),
539+
) from e
540+
541+
async def unpause_schedule(self, schedule_id: str, note: str | None = None) -> None:
542+
"""
543+
Unpause/resume a schedule.
544+
"""
545+
if not self.client:
546+
raise TemporalConnectionError("Temporal client is not connected")
547+
548+
try:
549+
handle = self.client.get_schedule_handle(schedule_id)
550+
await handle.unpause(note=note or "Unpaused via API")
551+
logger.info(f"Unpaused schedule {schedule_id}")
552+
except Exception as e:
553+
if "not found" in str(e).lower():
554+
logger.error(f"Schedule {schedule_id} not found: {e}")
555+
raise TemporalScheduleNotFoundError(
556+
message=f"Schedule '{schedule_id}' not found",
557+
detail=str(e),
558+
) from e
559+
logger.error(f"Failed to unpause schedule {schedule_id}: {e}")
560+
raise TemporalScheduleError(
561+
message=f"Failed to unpause schedule '{schedule_id}'",
562+
detail=str(e),
563+
) from e
564+
565+
async def trigger_schedule(self, schedule_id: str) -> None:
566+
"""
567+
Trigger a schedule to run immediately.
568+
"""
569+
if not self.client:
570+
raise TemporalConnectionError("Temporal client is not connected")
571+
572+
try:
573+
handle = self.client.get_schedule_handle(schedule_id)
574+
await handle.trigger()
575+
logger.info(f"Triggered schedule {schedule_id}")
576+
except Exception as e:
577+
if "not found" in str(e).lower():
578+
logger.error(f"Schedule {schedule_id} not found: {e}")
579+
raise TemporalScheduleNotFoundError(
580+
message=f"Schedule '{schedule_id}' not found",
581+
detail=str(e),
582+
) from e
583+
logger.error(f"Failed to trigger schedule {schedule_id}: {e}")
584+
raise TemporalScheduleError(
585+
message=f"Failed to trigger schedule '{schedule_id}'",
586+
detail=str(e),
587+
) from e
588+
589+
async def delete_schedule(self, schedule_id: str) -> None:
590+
"""
591+
Delete a schedule.
592+
"""
593+
if not self.client:
594+
raise TemporalConnectionError("Temporal client is not connected")
595+
596+
try:
597+
handle = self.client.get_schedule_handle(schedule_id)
598+
await handle.delete()
599+
logger.info(f"Deleted schedule {schedule_id}")
600+
except Exception as e:
601+
if "not found" in str(e).lower():
602+
logger.error(f"Schedule {schedule_id} not found: {e}")
603+
raise TemporalScheduleNotFoundError(
604+
message=f"Schedule '{schedule_id}' not found",
605+
detail=str(e),
606+
) from e
607+
logger.error(f"Failed to delete schedule {schedule_id}: {e}")
608+
raise TemporalScheduleError(
609+
message=f"Failed to delete schedule '{schedule_id}'",
610+
detail=str(e),
611+
) from e
612+
338613

339614
# Dependency injection annotation for FastAPI
340615
async def get_temporal_adapter() -> TemporalAdapter:

0 commit comments

Comments
 (0)