feat(platform/library): Scheduling UX (#10246)

Complete the implementation of the Agent Run Scheduling UX in the
Library.

Demo:


https://github.com/user-attachments/assets/701adc63-452c-4d37-aeea-51788b2774f2

### Changes 🏗️

Frontend:
- Add "Schedule" button + dialog + logic to `AgentRunDraftView`
  - Update corresponding logic on `AgentRunsPage`
  - Add schedule name field to `CronSchedulerDialog`
- Amend Builder components `useAgentGraph`, `FlowEditor`,
`RunnerUIWrapper` to also handle schedule name input
    - Split `CronScheduler` into `CronScheduler`+`CronSchedulerDialog`
- Make `AgentScheduleDetailsView` more fully functional
  - Add schedule description to info box
  - Add "Delete schedule" button
- Update schedule create/select/delete logic in `AgentRunsPage`
- Improve schedule UX in `AgentRunsSelectorList`
  - Switch tabs automatically when a run or schedule is selected
  - Remove now-redundant schedule filters
- Refactor `@/lib/monitor/cronExpressionManager` into
`@/lib/cron-expression-utils`

Backend + API:
- Add name and credentials to graph execution schedule job params
- Update schedule API
  - `POST /schedules` -> `POST /graphs/{graph_id}/schedules`
  - Add `GET /graphs/{graph_id}/schedules`
  - Add not found error handling to `DELETE /schedules/{schedule_id}`
  - Minor refactoring

Backend:
- Fix "`GraphModel`->`NodeModel` is not fully defined" error in
scheduler
- Add support for all exceptions defined in `backend.util.exceptions` to
RPC logic in `backend.util.service`
- Fix inconsistent log prefixing in `backend.executor.scheduler`

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- Create a simple agent with inputs and blocks that require credentials;
go to this agent in the Library
- Fill out the inputs and click "Schedule"; make it run every minute
(for testing purposes)
  - [x] -> newly created schedule appears in the list
  - [x] -> scheduled runs are successful
  - Click "Delete schedule"
  - [x] -> schedule no longer in list
- [x] -> on deleting the last schedule, view switches back to the Runs
list
  - [x] -> no new runs occur from the deleted schedule
This commit is contained in:
Reinier van der Leer
2025-06-27 16:31:44 +01:00
committed by GitHub
parent c4056cbae9
commit 5421ccf86a
25 changed files with 1096 additions and 809 deletions

View File

@@ -0,0 +1,5 @@
from .graph import NodeModel
from .integrations import Webhook # noqa: F401
# Resolve Webhook <- NodeModel forward reference
NodeModel.model_rebuild()

View File

@@ -77,10 +77,6 @@ class WebhookWithRelations(Webhook):
)
# Fix Webhook <- NodeModel relations
NodeModel.model_rebuild()
# --------------------- CRUD functions --------------------- #

View File

@@ -3,6 +3,7 @@ import logging
import os
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
@@ -14,13 +15,16 @@ from apscheduler.triggers.cron import CronTrigger
from autogpt_libs.utils.cache import thread_cached
from dotenv import load_dotenv
from prisma.enums import NotificationType
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import MetaData, create_engine
from backend.data.block import BlockInput
from backend.data.execution import ExecutionStatus
from backend.data.model import CredentialsMetaInput
from backend.executor import utils as execution_utils
from backend.notifications.notifications import NotificationManagerClient
from backend.util.exceptions import NotAuthorizedError, NotFoundError
from backend.util.logging import PrefixFilter
from backend.util.metrics import sentry_capture_error
from backend.util.service import (
AppService,
@@ -52,19 +56,19 @@ def _extract_schema_from_url(database_url) -> tuple[str, str]:
logger = logging.getLogger(__name__)
logger.addFilter(PrefixFilter("[Scheduler]"))
apscheduler_logger = logger.getChild("apscheduler")
apscheduler_logger.addFilter(PrefixFilter("[Scheduler] [APScheduler]"))
config = Config()
def log(msg, **kwargs):
logger.info("[Scheduler] " + msg, **kwargs)
def job_listener(event):
"""Logs job execution outcomes for better monitoring."""
if event.exception:
log(f"Job {event.job_id} failed.")
logger.error(f"Job {event.job_id} failed.")
else:
log(f"Job {event.job_id} completed successfully.")
logger.info(f"Job {event.job_id} completed successfully.")
@thread_cached
@@ -84,16 +88,17 @@ def execute_graph(**kwargs):
async def _execute_graph(**kwargs):
args = GraphExecutionJobArgs(**kwargs)
try:
log(f"Executing recurring job for graph #{args.graph_id}")
logger.info(f"Executing recurring job for graph #{args.graph_id}")
await execution_utils.add_graph_execution(
graph_id=args.graph_id,
inputs=args.input_data,
user_id=args.user_id,
graph_id=args.graph_id,
graph_version=args.graph_version,
inputs=args.input_data,
graph_credentials_inputs=args.input_credentials,
use_db_query=False,
)
except Exception as e:
logger.exception(f"Error executing graph {args.graph_id}: {e}")
logger.error(f"Error executing graph {args.graph_id}: {e}")
class LateExecutionException(Exception):
@@ -137,20 +142,20 @@ def report_late_executions() -> str:
def process_existing_batches(**kwargs):
args = NotificationJobArgs(**kwargs)
try:
log(
logger.info(
f"Processing existing batches for notification type {args.notification_types}"
)
get_notification_client().process_existing_batches(args.notification_types)
except Exception as e:
logger.exception(f"Error processing existing batches: {e}")
logger.error(f"Error processing existing batches: {e}")
def process_weekly_summary(**kwargs):
try:
log("Processing weekly summary")
logger.info("Processing weekly summary")
get_notification_client().queue_weekly_summary()
except Exception as e:
logger.exception(f"Error processing weekly summary: {e}")
logger.error(f"Error processing weekly summary: {e}")
class Jobstores(Enum):
@@ -160,11 +165,12 @@ class Jobstores(Enum):
class GraphExecutionJobArgs(BaseModel):
graph_id: str
input_data: BlockInput
user_id: str
graph_id: str
graph_version: int
cron: str
input_data: BlockInput
input_credentials: dict[str, CredentialsMetaInput] = Field(default_factory=dict)
class GraphExecutionJobInfo(GraphExecutionJobArgs):
@@ -247,7 +253,8 @@ class Scheduler(AppService):
),
# These don't really need persistence
Jobstores.WEEKLY_NOTIFICATIONS.value: MemoryJobStore(),
}
},
logger=apscheduler_logger,
)
if self.register_system_tasks:
@@ -285,34 +292,40 @@ class Scheduler(AppService):
def cleanup(self):
super().cleanup()
logger.info(f"[{self.service_name}] ⏳ Shutting down scheduler...")
logger.info("⏳ Shutting down scheduler...")
if self.scheduler:
self.scheduler.shutdown(wait=False)
@expose
def add_graph_execution_schedule(
self,
user_id: str,
graph_id: str,
graph_version: int,
cron: str,
input_data: BlockInput,
user_id: str,
input_credentials: dict[str, CredentialsMetaInput],
name: Optional[str] = None,
) -> GraphExecutionJobInfo:
job_args = GraphExecutionJobArgs(
graph_id=graph_id,
input_data=input_data,
user_id=user_id,
graph_id=graph_id,
graph_version=graph_version,
cron=cron,
input_data=input_data,
input_credentials=input_credentials,
)
job = self.scheduler.add_job(
execute_graph,
CronTrigger.from_crontab(cron),
kwargs=job_args.model_dump(),
replace_existing=True,
name=name,
trigger=CronTrigger.from_crontab(cron),
jobstore=Jobstores.EXECUTION.value,
replace_existing=True,
)
logger.info(
f"Added job {job.id} with cron schedule '{cron}' input data: {input_data}"
)
log(f"Added job {job.id} with cron schedule '{cron}' input data: {input_data}")
return GraphExecutionJobInfo.from_db(job_args, job)
@expose
@@ -321,14 +334,13 @@ class Scheduler(AppService):
) -> GraphExecutionJobInfo:
job = self.scheduler.get_job(schedule_id, jobstore=Jobstores.EXECUTION.value)
if not job:
log(f"Job {schedule_id} not found.")
raise ValueError(f"Job #{schedule_id} not found.")
raise NotFoundError(f"Job #{schedule_id} not found.")
job_args = GraphExecutionJobArgs(**job.kwargs)
if job_args.user_id != user_id:
raise ValueError("User ID does not match the job's user ID.")
raise NotAuthorizedError("User ID does not match the job's user ID")
log(f"Deleting job {schedule_id}")
logger.info(f"Deleting job {schedule_id}")
job.remove()
return GraphExecutionJobInfo.from_db(job_args, job)

View File

@@ -27,6 +27,7 @@ async def test_agent_schedule(server: SpinTestServer):
graph_version=1,
cron="0 0 * * *",
input_data={"input": "data"},
input_credentials={},
)
assert schedule

View File

@@ -9,7 +9,7 @@ import stripe
from autogpt_libs.auth.middleware import auth_middleware
from autogpt_libs.feature_flag.client import feature_flag
from autogpt_libs.utils.cache import thread_cached
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response
from starlette.status import HTTP_204_NO_CONTENT, HTTP_404_NOT_FOUND
from typing_extensions import Optional, TypedDict
@@ -72,6 +72,7 @@ from backend.server.model import (
UpdatePermissionsRequest,
)
from backend.server.utils import get_user_id
from backend.util.exceptions import NotFoundError
from backend.util.service import get_service_client
from backend.util.settings import Settings
@@ -765,70 +766,94 @@ async def delete_graph_execution(
class ScheduleCreationRequest(pydantic.BaseModel):
graph_version: Optional[int] = None
name: str
cron: str
input_data: dict[Any, Any]
graph_id: str
graph_version: int
inputs: dict[str, Any]
credentials: dict[str, CredentialsMetaInput] = pydantic.Field(default_factory=dict)
@v1_router.post(
path="/schedules",
path="/graphs/{graph_id}/schedules",
summary="Create execution schedule",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def create_schedule(
async def create_graph_execution_schedule(
user_id: Annotated[str, Depends(get_user_id)],
schedule: ScheduleCreationRequest,
graph_id: str = Path(..., description="ID of the graph to schedule"),
schedule_params: ScheduleCreationRequest = Body(),
) -> scheduler.GraphExecutionJobInfo:
graph = await graph_db.get_graph(
schedule.graph_id, schedule.graph_version, user_id=user_id
graph_id=graph_id,
version=schedule_params.graph_version,
user_id=user_id,
)
if not graph:
raise HTTPException(
status_code=404,
detail=f"Graph #{schedule.graph_id} v.{schedule.graph_version} not found.",
detail=f"Graph #{graph_id} v{schedule_params.graph_version} not found.",
)
return await execution_scheduler_client().add_execution_schedule(
graph_id=schedule.graph_id,
graph_version=graph.version,
cron=schedule.cron,
input_data=schedule.input_data,
user_id=user_id,
graph_id=graph_id,
graph_version=graph.version,
name=schedule_params.name,
cron=schedule_params.cron,
input_data=schedule_params.inputs,
input_credentials=schedule_params.credentials,
)
@v1_router.get(
path="/graphs/{graph_id}/schedules",
summary="List execution schedules for a graph",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def list_graph_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
graph_id: str = Path(),
) -> list[scheduler.GraphExecutionJobInfo]:
return await execution_scheduler_client().get_execution_schedules(
user_id=user_id,
graph_id=graph_id,
)
@v1_router.get(
path="/schedules",
summary="List execution schedules for a user",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def list_all_graphs_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
) -> list[scheduler.GraphExecutionJobInfo]:
return await execution_scheduler_client().get_execution_schedules(user_id=user_id)
@v1_router.delete(
path="/schedules/{schedule_id}",
summary="Delete execution schedule",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def delete_schedule(
schedule_id: str,
async def delete_graph_execution_schedule(
user_id: Annotated[str, Depends(get_user_id)],
) -> dict[Any, Any]:
await execution_scheduler_client().delete_schedule(schedule_id, user_id=user_id)
schedule_id: str = Path(..., description="ID of the schedule to delete"),
) -> dict[str, Any]:
try:
await execution_scheduler_client().delete_schedule(schedule_id, user_id=user_id)
except NotFoundError:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Schedule #{schedule_id} not found",
)
return {"id": schedule_id}
@v1_router.get(
path="/schedules",
summary="List execution schedules",
tags=["schedules"],
dependencies=[Depends(auth_middleware)],
)
async def get_execution_schedules(
user_id: Annotated[str, Depends(get_user_id)],
graph_id: str | None = None,
) -> list[scheduler.GraphExecutionJobInfo]:
return await execution_scheduler_client().get_execution_schedules(
user_id=user_id,
graph_id=graph_id,
)
########################################################
##################### API KEY ##############################
########################################################

View File

@@ -10,6 +10,10 @@ class NeedConfirmation(Exception):
"""The user must explicitly confirm that they want to proceed"""
class NotAuthorizedError(ValueError):
"""The user is not authorized to perform the requested operation"""
class InsufficientBalanceError(ValueError):
user_id: str
message: str

View File

@@ -1,4 +1,4 @@
from logging import Logger
import logging
from backend.util.settings import AppEnvironment, BehaveAs, Settings
@@ -6,8 +6,6 @@ settings = Settings()
def configure_logging():
import logging
import autogpt_libs.logging.config
if (
@@ -25,7 +23,7 @@ def configure_logging():
class TruncatedLogger:
def __init__(
self,
logger: Logger,
logger: logging.Logger,
prefix: str = "",
metadata: dict | None = None,
max_length: int = 1000,
@@ -65,3 +63,13 @@ class TruncatedLogger:
if len(text) > self.max_length:
text = text[: self.max_length] + "..."
return text
class PrefixFilter(logging.Filter):
def __init__(self, prefix: str):
super().__init__()
self.prefix = prefix
def filter(self, record):
record.msg = f"{self.prefix} {record.msg}"
return True

View File

@@ -31,7 +31,7 @@ from tenacity import (
wait_exponential_jitter,
)
from backend.util.exceptions import InsufficientBalanceError
import backend.util.exceptions as exceptions
from backend.util.json import to_dict
from backend.util.metrics import sentry_init
from backend.util.process import AppProcess, get_service_name
@@ -106,7 +106,13 @@ EXCEPTION_MAPPING = {
ValueError,
TimeoutError,
ConnectionError,
InsufficientBalanceError,
*[
ErrorType
for _, ErrorType in inspect.getmembers(exceptions)
if inspect.isclass(ErrorType)
and issubclass(ErrorType, Exception)
and ErrorType.__module__ == exceptions.__name__
],
]
}

View File

@@ -3191,6 +3191,48 @@
}
}
},
"/api/library/agents/by-graph/{graph_id}": {
"get": {
"tags": ["v2", "library", "private"],
"summary": "Get Library Agent By Graph Id",
"operationId": "getV2GetLibraryAgentByGraphId",
"parameters": [
{
"name": "graph_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Graph Id" }
},
{
"name": "version",
"in": "query",
"required": false,
"schema": {
"anyOf": [{ "type": "integer" }, { "type": "null" }],
"title": "Version"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LibraryAgent" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/library/agents/marketplace/{store_listing_version_id}": {
"get": {
"tags": ["v2", "library", "private", "store, library"],
@@ -5012,6 +5054,7 @@
"AGENT_INPUT",
"CONGRATS",
"GET_RESULTS",
"RUN_AGENTS",
"MARKETPLACE_VISIT",
"MARKETPLACE_ADD_AGENT",
"MARKETPLACE_RUN_AGENT",

View File

@@ -64,9 +64,10 @@ export default function AgentRunsPage(): React.ReactElement {
const [selectedRun, setSelectedRun] = useState<
GraphExecution | GraphExecutionMeta | null
>(null);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(
null,
);
const selectedSchedule =
selectedView.type == "schedule"
? schedules.find((s) => s.id == selectedView.id)
: null;
const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true);
const [agentDeleteDialogOpen, setAgentDeleteDialogOpen] =
useState<boolean>(false);
@@ -100,9 +101,8 @@ export default function AgentRunsPage(): React.ReactElement {
selectView({ type: "preset", id });
}, []);
const selectSchedule = useCallback((schedule: Schedule) => {
selectView({ type: "schedule", id: schedule.id });
setSelectedSchedule(schedule);
const selectSchedule = useCallback((id: ScheduleID) => {
selectView({ type: "schedule", id });
}, []);
const graphVersions = useRef<Record<number, Graph>>({});
@@ -315,11 +315,8 @@ export default function AgentRunsPage(): React.ReactElement {
const fetchSchedules = useCallback(async () => {
if (!agent) return;
// TODO: filter in backend - https://github.com/Significant-Gravitas/AutoGPT/issues/9183
setSchedules(
(await api.listSchedules()).filter((s) => s.graph_id == agent.graph_id),
);
}, [api, agent]);
setSchedules(await api.listGraphExecutionSchedules(agent.graph_id));
}, [api, agent?.graph_id]);
useEffect(() => {
fetchSchedules();
@@ -358,8 +355,28 @@ export default function AgentRunsPage(): React.ReactElement {
const deleteSchedule = useCallback(
async (scheduleID: ScheduleID) => {
const removedSchedule = await api.deleteSchedule(scheduleID);
setSchedules(schedules.filter((s) => s.id !== removedSchedule.id));
const removedSchedule =
await api.deleteGraphExecutionSchedule(scheduleID);
setSchedules((schedules) => {
const newSchedules = schedules.filter(
(s) => s.id !== removedSchedule.id,
);
if (
selectedView.type == "schedule" &&
selectedView.id == removedSchedule.id
) {
if (newSchedules.length > 0) {
// Select next schedule if available
selectSchedule(newSchedules[0].id);
} else {
// Reset to draft view if current schedule was deleted
openRunDraftView();
}
}
return newSchedules;
});
openRunDraftView();
},
[schedules, api],
);
@@ -417,6 +434,14 @@ export default function AgentRunsPage(): React.ReactElement {
[agent, downloadGraph],
);
const onCreateSchedule = useCallback(
(schedule: Schedule) => {
setSchedules((prev) => [...prev, schedule]);
selectSchedule(schedule.id);
},
[selectView],
);
const onCreatePreset = useCallback(
(preset: LibraryAgentPreset) => {
setAgentPresets((prev) => [...prev, preset]);
@@ -454,9 +479,9 @@ export default function AgentRunsPage(): React.ReactElement {
onSelectPreset={selectPreset}
onSelectSchedule={selectSchedule}
onSelectDraftNewRun={openRunDraftView}
onDeleteRun={setConfirmingDeleteAgentRun}
onDeletePreset={setConfirmingDeleteAgentPreset}
onDeleteSchedule={deleteSchedule}
doDeleteRun={setConfirmingDeleteAgentRun}
doDeletePreset={setConfirmingDeleteAgentPreset}
doDeleteSchedule={deleteSchedule}
/>
<div className="flex-1">
@@ -486,6 +511,7 @@ export default function AgentRunsPage(): React.ReactElement {
<AgentRunDraftView
agent={agent}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onCreatePreset={onCreatePreset}
agentActions={agentActions}
/>
@@ -497,6 +523,7 @@ export default function AgentRunsPage(): React.ReactElement {
agentPresets.find((preset) => preset.id == selectedView.id)!
}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onUpdatePreset={onUpdatePreset}
doDeletePreset={setConfirmingDeleteAgentPreset}
agentActions={agentActions}
@@ -506,8 +533,10 @@ export default function AgentRunsPage(): React.ReactElement {
<AgentScheduleDetailsView
graph={graph}
schedule={selectedSchedule}
onForcedRun={selectRun}
// agent={agent}
agentActions={agentActions}
onForcedRun={selectRun}
doDeleteSchedule={deleteSchedule}
/>
)
) : null) || <LoadingBox className="h-[70vh]" />}

View File

@@ -32,12 +32,13 @@ const Monitor = () => {
const api = useBackendAPI();
const fetchSchedules = useCallback(async () => {
setSchedules(await api.listSchedules());
setSchedules(await api.listAllGraphsExecutionSchedules());
}, [api]);
const removeSchedule = useCallback(
async (scheduleId: ScheduleID) => {
const removedSchedule = await api.deleteSchedule(scheduleId);
const removedSchedule =
await api.deleteGraphExecutionSchedule(scheduleId);
setSchedules(schedules.filter((s) => s.id !== removedSchedule.id));
},
[schedules, api],

View File

@@ -52,7 +52,7 @@ import PrimaryActionBar from "@/components/PrimaryActionButton";
import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import { CronScheduler } from "./cronScheduler";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
// This is for the history, this is the minimum distance a block must move before it is logged
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
@@ -639,8 +639,11 @@ const FlowEditor: React.FC<{
// This function is called after cron expression is created
// So you can collect inputs for scheduling
const afterCronCreation = (cronExpression: string) => {
runnerUIRef.current?.collectInputsForScheduling(cronExpression);
const afterCronCreation = (cronExpression: string, scheduleName: string) => {
runnerUIRef.current?.collectInputsForScheduling(
cronExpression,
scheduleName,
);
};
// This function Opens up form for creating cron expression
@@ -728,10 +731,11 @@ const FlowEditor: React.FC<{
requestStopRun={requestStopRun}
runAgentTooltip={!isRunning ? "Run Agent" : "Stop Agent"}
/>
<CronScheduler
afterCronCreation={afterCronCreation}
<CronSchedulerDialog
open={openCron}
setOpen={setOpenCron}
afterCronCreation={afterCronCreation}
defaultScheduleName={agentName}
/>
</ReactFlow>
</div>

View File

@@ -36,14 +36,21 @@ interface RunnerUIWrapperProps {
isRunning: boolean;
isScheduling: boolean;
requestSaveAndRun: () => void;
scheduleRunner: (cronExpression: string, input: InputItem[]) => Promise<void>;
scheduleRunner: (
cronExpression: string,
input: InputItem[],
scheduleName: string,
) => Promise<void>;
}
export interface RunnerUIWrapperRef {
openRunnerInput: () => void;
openRunnerOutput: () => void;
runOrOpenInput: () => void;
collectInputsForScheduling: (cronExpression: string) => void;
collectInputsForScheduling: (
cronExpression: string,
scheduleName: string,
) => void;
}
const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
@@ -63,6 +70,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
const [isRunnerOutputOpen, setIsRunnerOutputOpen] = useState(false);
const [scheduledInput, setScheduledInput] = useState(false);
const [cronExpression, setCronExpression] = useState("");
const [scheduleName, setScheduleName] = useState("");
const getBlockInputsAndOutputs = useCallback((): {
inputs: InputItem[];
@@ -149,15 +157,19 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
}
};
const collectInputsForScheduling = (cron_exp: string) => {
const collectInputsForScheduling = (
cronExpression: string,
scheduleName: string,
) => {
const { inputs } = getBlockInputsAndOutputs();
setCronExpression(cron_exp);
setCronExpression(cronExpression);
setScheduleName(scheduleName);
if (inputs.length > 0) {
setScheduledInput(true);
setIsRunnerInputOpen(true);
} else {
scheduleRunner(cron_exp, []);
scheduleRunner(cronExpression, [], scheduleName);
}
};
@@ -186,6 +198,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
await scheduleRunner(
cronExpression,
getBlockInputsAndOutputs().inputs,
scheduleName,
);
setIsScheduling(false);
setIsRunnerInputOpen(false);

View File

@@ -9,17 +9,19 @@ import {
LibraryAgentPreset,
LibraryAgentPresetID,
LibraryAgentPresetUpdatable,
Schedule,
} from "@/lib/autogpt-server-api";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconCross, IconPlay, IconSave } from "@/components/ui/icons";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import { CredentialsInput } from "@/components/integrations/credentials-input";
import { TypeBasedInput } from "@/components/type-based-input";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { Trash2Icon } from "lucide-react";
import SchemaTooltip from "@/components/SchemaTooltip";
import { useToast } from "@/components/ui/use-toast";
import { isEmpty } from "lodash";
@@ -32,11 +34,13 @@ export default function AgentRunDraftView({
onCreatePreset,
onUpdatePreset,
doDeletePreset,
onCreateSchedule,
agentActions,
}: {
agent: LibraryAgent;
agentActions: ButtonAction[];
onRun: (runID: GraphExecutionID) => void;
onCreateSchedule: (schedule: Schedule) => void;
} & (
| {
onCreatePreset: (preset: LibraryAgentPreset) => void;
@@ -66,6 +70,7 @@ export default function AgentRunDraftView({
>(new Set());
const { state: onboardingState, completeStep: completeOnboardingStep } =
useOnboarding();
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] = useState(false);
// Update values if agentPreset parameter is changed
useEffect(() => {
@@ -314,6 +319,43 @@ export default function AgentRunDraftView({
completeOnboardingStep,
]);
const openScheduleDialog = useCallback(() => {
// Scheduling is not supported for webhook-triggered agents
if (agent.has_external_trigger) return;
if (!allRequiredInputsAreSet || !allCredentialsAreSet) {
notifyMissingInputs(false);
return;
}
setCronScheduleDialogOpen(true);
}, [
agent,
allRequiredInputsAreSet,
allCredentialsAreSet,
notifyMissingInputs,
]);
const doSetupSchedule = useCallback(
(cronExpression: string, scheduleName: string) => {
// Scheduling is not supported for webhook-triggered agents
if (agent.has_external_trigger) return;
api
.createGraphExecutionSchedule({
graph_id: agent.graph_id,
graph_version: agent.graph_version,
name: scheduleName || agent.name,
cron: cronExpression,
inputs: inputValues,
credentials: inputCredentials,
})
.then((schedule) => onCreateSchedule(schedule))
.catch(toastOnFail("set up agent run schedule"));
},
[api, agent, inputValues, inputCredentials, onCreateSchedule, toastOnFail],
);
const runActions: ButtonAction[] = useMemo(
() => [
// "Regular" agent: [run] + [save as preset] buttons
@@ -328,6 +370,14 @@ export default function AgentRunDraftView({
variant: "accent",
callback: doRun,
},
{
label: (
<>
<CalendarClockIcon className="mr-2 size-4" /> Schedule
</>
),
callback: openScheduleDialog,
},
// {
// label: (
// <>
@@ -418,12 +468,12 @@ export default function AgentRunDraftView({
[
agent.has_external_trigger,
agentPreset,
api,
doRun,
doSetupTrigger,
doCreatePreset,
doUpdatePreset,
doDeletePreset,
openScheduleDialog,
changedPresetAttributes,
presetName,
allRequiredInputsAreSet,
@@ -545,6 +595,12 @@ export default function AgentRunDraftView({
title={`${agent.has_external_trigger ? "Trigger" : agentPreset ? "Preset" : "Run"} actions`}
actions={runActions}
/>
<CronSchedulerDialog
open={cronScheduleDialogOpen}
setOpen={setCronScheduleDialogOpen}
afterCronCreation={doSetupSchedule}
defaultScheduleName={agent.name}
/>
<ActionButtonGroup title="Agent actions" actions={agentActions} />
</div>

View File

@@ -1,5 +1,5 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -30,11 +30,11 @@ interface AgentRunsSelectorListProps {
allowDraftNewRun?: boolean;
onSelectRun: (id: GraphExecutionID) => void;
onSelectPreset: (preset: LibraryAgentPresetID) => void;
onSelectSchedule: (schedule: Schedule) => void;
onSelectSchedule: (id: ScheduleID) => void;
onSelectDraftNewRun: () => void;
onDeleteRun: (id: GraphExecutionMeta) => void;
onDeletePreset: (id: LibraryAgentPresetID) => void;
onDeleteSchedule: (id: ScheduleID) => void;
doDeleteRun: (id: GraphExecutionMeta) => void;
doDeletePreset: (id: LibraryAgentPresetID) => void;
doDeleteSchedule: (id: ScheduleID) => void;
className?: string;
}
@@ -49,15 +49,23 @@ export default function AgentRunsSelectorList({
onSelectPreset,
onSelectSchedule,
onSelectDraftNewRun,
onDeleteRun,
onDeletePreset,
onDeleteSchedule,
doDeleteRun,
doDeletePreset,
doDeleteSchedule,
className,
}: AgentRunsSelectorListProps): React.ReactElement {
const [activeListTab, setActiveListTab] = useState<"runs" | "scheduled">(
"runs",
);
useEffect(() => {
if (selectedView.type === "schedule") {
setActiveListTab("scheduled");
} else {
setActiveListTab("runs");
}
}, [selectedView]);
const listItemClasses = "h-28 w-72 lg:h-32 xl:w-80";
return (
@@ -94,9 +102,7 @@ export default function AgentRunsSelectorList({
onClick={() => setActiveListTab("scheduled")}
>
<span>Scheduled</span>
<span className="text-neutral-600">
{schedules.filter((s) => s.graph_id === agent.graph_id).length}
</span>
<span className="text-neutral-600">{schedules.length}</span>
</Badge>
</div>
@@ -136,7 +142,7 @@ export default function AgentRunsSelectorList({
// timestamp={preset.last_run_time} // TODO: implement this
selected={selectedView.id === preset.id}
onClick={() => onSelectPreset(preset.id)}
onDelete={() => onDeletePreset(preset.id)}
onDelete={() => doDeletePreset(preset.id)}
/>
))}
{agentPresets.length > 0 && <Separator className="my-1" />}
@@ -158,26 +164,24 @@ export default function AgentRunsSelectorList({
timestamp={run.started_at}
selected={selectedView.id === run.id}
onClick={() => onSelectRun(run.id)}
onDelete={() => onDeleteRun(run)}
onDelete={() => doDeleteRun(run)}
/>
))}
</>
) : (
schedules
.filter((schedule) => schedule.graph_id === agent.graph_id)
.map((schedule) => (
<AgentRunSummaryCard
className={listItemClasses}
key={schedule.id}
type="schedule"
status="scheduled" // TODO: implement active/inactive status for schedules
title={schedule.name}
timestamp={schedule.next_run_time}
selected={selectedView.id === schedule.id}
onClick={() => onSelectSchedule(schedule)}
onDelete={() => onDeleteSchedule(schedule.id)}
/>
))
schedules.map((schedule) => (
<AgentRunSummaryCard
className={listItemClasses}
key={schedule.id}
type="schedule"
status="scheduled" // TODO: implement active/inactive status for schedules
title={schedule.name}
timestamp={schedule.next_run_time}
selected={selectedView.id === schedule.id}
onClick={() => onSelectSchedule(schedule.id)}
onDelete={() => doDeleteSchedule(schedule.id)}
/>
))
)}
</div>
</ScrollArea>

View File

@@ -5,27 +5,33 @@ import {
GraphExecutionID,
GraphMeta,
Schedule,
ScheduleID,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { AgentRunStatus } from "@/components/agents/agent-run-status-chip";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import { IconCross } from "@/components/ui/icons";
import { PlayIcon } from "lucide-react";
import LoadingBox from "@/components/ui/loading";
import { Input } from "@/components/ui/input";
export default function AgentScheduleDetailsView({
graph,
schedule,
onForcedRun,
agentActions,
onForcedRun,
doDeleteSchedule,
}: {
graph: GraphMeta;
schedule: Schedule;
onForcedRun: (runID: GraphExecutionID) => void;
agentActions: ButtonAction[];
onForcedRun: (runID: GraphExecutionID) => void;
doDeleteSchedule: (scheduleID: ScheduleID) => void;
}): React.ReactNode {
const api = useBackendAPI();
@@ -42,7 +48,11 @@ export default function AgentScheduleDetailsView({
selectedRunStatus.slice(1),
},
{
label: "Scheduled for",
label: "Schedule",
value: humanizeCronExpression(schedule.cron),
},
{
label: "Next run",
value: schedule.next_run_time.toLocaleString(),
},
];
@@ -70,14 +80,39 @@ export default function AgentScheduleDetailsView({
const runNow = useCallback(
() =>
api
.executeGraph(graph.id, graph.version, schedule.input_data)
.executeGraph(
graph.id,
graph.version,
schedule.input_data,
schedule.input_credentials,
)
.then((run) => onForcedRun(run.graph_exec_id))
.catch(toastOnFail("execute agent")),
[api, graph, schedule, onForcedRun, toastOnFail],
);
const runActions: ButtonAction[] = useMemo(
() => [{ label: "Run now", callback: () => runNow() }],
() => [
{
label: (
<>
<PlayIcon className="mr-2 size-4" />
Run now
</>
),
callback: runNow,
},
{
label: (
<>
<IconCross className="mr-2 size-4 px-0.5" />
Delete schedule
</>
),
callback: () => doDeleteSchedule(schedule.id),
variant: "destructive",
},
],
[runNow],
);

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { Separator } from "@/components/ui/separator";
import { CronScheduler } from "@/components/cron-scheduler";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
type CronSchedulerDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
afterCronCreation: (cronExpression: string, scheduleName: string) => void;
defaultScheduleName?: string;
};
export function CronSchedulerDialog({
open,
setOpen,
afterCronCreation,
defaultScheduleName = "",
}: CronSchedulerDialogProps) {
const { toast } = useToast();
const [cronExpression, setCronExpression] = useState<string>("");
const [scheduleName, setScheduleName] = useState<string>(defaultScheduleName);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setScheduleName(defaultScheduleName);
setCronExpression("");
}
}, [open]);
const handleDone = () => {
if (!scheduleName.trim()) {
toast({
title: "Please enter a schedule name",
variant: "destructive",
});
return;
}
afterCronCreation(cronExpression, scheduleName);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>Schedule Task</DialogTitle>
<div className="p-2">
<div className="flex flex-col gap-4">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">Schedule Name</label>
<Input
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
placeholder="Enter a name for this schedule"
/>
</div>
<CronScheduler onCronExpressionChange={setCronExpression} />
</div>
<Separator className="my-4" />
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleDone}>Done</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,338 @@
import React, { useEffect, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CronFrequency, makeCronExpression } from "@/lib/cron-expression-utils";
const weekDays = [
{ label: "Su", value: 0 },
{ label: "Mo", value: 1 },
{ label: "Tu", value: 2 },
{ label: "We", value: 3 },
{ label: "Th", value: 4 },
{ label: "Fr", value: 5 },
{ label: "Sa", value: 6 },
];
const months = [
{ label: "Jan", value: "January" },
{ label: "Feb", value: "February" },
{ label: "Mar", value: "March" },
{ label: "Apr", value: "April" },
{ label: "May", value: "May" },
{ label: "Jun", value: "June" },
{ label: "Jul", value: "July" },
{ label: "Aug", value: "August" },
{ label: "Sep", value: "September" },
{ label: "Oct", value: "October" },
{ label: "Nov", value: "November" },
{ label: "Dec", value: "December" },
];
type CronSchedulerProps = {
onCronExpressionChange: (cronExpression: string) => void;
};
export function CronScheduler({
onCronExpressionChange,
}: CronSchedulerProps): React.ReactElement {
const [frequency, setFrequency] = useState<CronFrequency>("daily");
const [selectedMinute, setSelectedMinute] = useState<string>("0");
const [selectedTime, setSelectedTime] = useState<string>("09:00");
const [selectedWeekDays, setSelectedWeekDays] = useState<number[]>([]);
const [selectedMonthDays, setSelectedMonthDays] = useState<number[]>([]);
const [selectedMonths, setSelectedMonths] = useState<number[]>([]);
const [customInterval, setCustomInterval] = useState<{
value: number;
unit: "minutes" | "hours" | "days";
}>({ value: 1, unit: "minutes" });
const [showCustomDays, setShowCustomDays] = useState<boolean>(false);
useEffect(() => {
const cronExpr = makeCronExpression({
frequency,
minute:
frequency === "hourly"
? parseInt(selectedMinute)
: parseInt(selectedTime.split(":")[1]),
hour: parseInt(selectedTime.split(":")[0]),
days:
frequency === "weekly"
? selectedWeekDays
: frequency === "monthly"
? selectedMonthDays
: [],
months: frequency === "yearly" ? selectedMonths : [],
customInterval:
frequency === "custom" ? customInterval : { unit: "minutes", value: 1 },
});
onCronExpressionChange(cronExpr);
}, [
frequency,
selectedMinute,
selectedTime,
selectedWeekDays,
selectedMonthDays,
selectedMonths,
customInterval,
onCronExpressionChange,
]);
return (
<div className="max-w-md space-y-6">
<div className="space-y-4">
<Label className="text-base font-medium">Repeat</Label>
<Select
value={frequency}
onValueChange={(value: CronFrequency) => setFrequency(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="every minute">Every Minute</SelectItem>
<SelectItem value="hourly">Every Hour</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
{frequency === "hourly" && (
<div className="flex items-center gap-2">
<Label>At minute</Label>
<Select value={selectedMinute} onValueChange={setSelectedMinute}>
<SelectTrigger className="w-24">
<SelectValue placeholder="Select minute" />
</SelectTrigger>
<SelectContent>
{[0, 15, 30, 45].map((min) => (
<SelectItem key={min} value={min.toString()}>
{min}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{frequency === "custom" && (
<div className="flex items-center gap-2">
<Label>Every</Label>
<Input
type="number"
min="1"
className="w-20"
value={customInterval.value}
onChange={(e) =>
setCustomInterval({
...customInterval,
value: parseInt(e.target.value),
})
}
/>
<Select
value={customInterval.unit}
onValueChange={(value: any) =>
setCustomInterval({ ...customInterval, unit: value })
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{frequency === "weekly" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>On</Label>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedWeekDays.length === weekDays.length) {
setSelectedWeekDays([]);
} else {
setSelectedWeekDays(weekDays.map((day) => day.value));
}
}}
>
{selectedWeekDays.length === weekDays.length
? "Deselect All"
: "Select All"}
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedWeekDays([1, 2, 3, 4, 5])}
>
Weekdays
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedWeekDays([0, 6])}
>
Weekends
</Button>
</div>
<div className="flex flex-wrap gap-2">
{weekDays.map((day) => (
<Button
key={day.value}
variant={
selectedWeekDays.includes(day.value) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedWeekDays((prev) =>
prev.includes(day.value)
? prev.filter((d) => d !== day.value)
: [...prev, day.value],
);
}}
>
{day.label}
</Button>
))}
</div>
</div>
)}
{frequency === "monthly" && (
<div className="space-y-4">
<Label>Days of Month</Label>
<div className="flex gap-2">
<Button
variant={!showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(false);
setSelectedMonthDays(
Array.from({ length: 31 }, (_, i) => i + 1),
);
}}
>
All Days
</Button>
<Button
variant={showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(true);
setSelectedMonthDays([]);
}}
>
Customize
</Button>
<Button
variant="outline"
onClick={() => setSelectedMonthDays([15])}
>
15th
</Button>
<Button
variant="outline"
onClick={() => setSelectedMonthDays([31])}
>
Last Day
</Button>
</div>
{showCustomDays && (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 31 }, (_, i) => (
<Button
key={i + 1}
variant={
selectedMonthDays.includes(i + 1) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedMonthDays((prev) =>
prev.includes(i + 1)
? prev.filter((d) => d !== i + 1)
: [...prev, i + 1],
);
}}
>
{i + 1}
</Button>
))}
</div>
)}
</div>
)}
{frequency === "yearly" && (
<div className="space-y-4">
<Label>Months</Label>
<div className="flex gap-2">
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedMonths.length === months.length) {
setSelectedMonths([]);
} else {
setSelectedMonths(Array.from({ length: 12 }, (_, i) => i));
}
}}
>
{selectedMonths.length === months.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{months.map((month, i) => {
const monthNumber = i + 1;
return (
<Button
key={i}
variant={
selectedMonths.includes(monthNumber) ? "default" : "outline"
}
className="px-2 py-1"
onClick={() => {
setSelectedMonths((prev) =>
prev.includes(monthNumber)
? prev.filter((m) => m !== monthNumber)
: [...prev, monthNumber],
);
}}
>
{month.label}
</Button>
);
})}
</div>
</div>
)}
{frequency !== "every minute" && frequency !== "hourly" && (
<div className="flex items-center gap-4 space-y-2">
<Label className="pt-2">At</Label>
<Input
type="time"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
/>
</div>
)}
</div>
);
}

View File

@@ -1,417 +0,0 @@
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "./ui/separator";
import { CronExpressionManager } from "@/lib/monitor/cronExpressionManager";
interface CronSchedulerProps {
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
open: boolean;
afterCronCreation: (cronExpression: string) => void;
}
export function CronScheduler({
setOpen,
open,
afterCronCreation,
}: CronSchedulerProps) {
const [frequency, setFrequency] = useState<
"minute" | "hour" | "daily" | "weekly" | "monthly" | "yearly" | "custom"
>("daily");
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [selectedTime, setSelectedTime] = useState<string>("09:00");
const [showCustomDays, setShowCustomDays] = useState<boolean>(false);
const [selectedMinute, setSelectedMinute] = useState<string>("0");
const [customInterval, setCustomInterval] = useState<{
value: number;
unit: "minutes" | "hours" | "days";
}>({ value: 1, unit: "minutes" });
// const [endType, setEndType] = useState<"never" | "on" | "after">("never");
// const [endDate, setEndDate] = useState<Date | undefined>();
// const [occurrences, setOccurrences] = useState<number>(1);
const weekDays = [
{ label: "Su", value: 0 },
{ label: "Mo", value: 1 },
{ label: "Tu", value: 2 },
{ label: "We", value: 3 },
{ label: "Th", value: 4 },
{ label: "Fr", value: 5 },
{ label: "Sa", value: 6 },
];
const months = [
{ label: "Jan", value: "January" },
{ label: "Feb", value: "February" },
{ label: "Mar", value: "March" },
{ label: "Apr", value: "April" },
{ label: "May", value: "May" },
{ label: "Jun", value: "June" },
{ label: "Jul", value: "July" },
{ label: "Aug", value: "August" },
{ label: "Sep", value: "September" },
{ label: "Oct", value: "October" },
{ label: "Nov", value: "November" },
{ label: "Dec", value: "December" },
];
const cron_manager = new CronExpressionManager();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>Schedule Task</DialogTitle>
<div className="max-w-md space-y-6 p-2">
<div className="space-y-4">
<Label className="text-base font-medium">Repeat</Label>
<Select
onValueChange={(value: any) => setFrequency(value)}
defaultValue="daily"
>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="minute">Every Minute</SelectItem>
<SelectItem value="hour">Every Hour</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
{frequency === "hour" && (
<div className="flex items-center gap-2">
<Label>At minute</Label>
<Select
value={selectedMinute}
onValueChange={setSelectedMinute}
>
<SelectTrigger className="w-24">
<SelectValue placeholder="Select minute" />
</SelectTrigger>
<SelectContent>
{[0, 15, 30, 45].map((min) => (
<SelectItem key={min} value={min.toString()}>
{min}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{frequency === "custom" && (
<div className="flex items-center gap-2">
<Label>Every</Label>
<Input
type="number"
min="1"
className="w-20"
value={customInterval.value}
onChange={(e) =>
setCustomInterval({
...customInterval,
value: parseInt(e.target.value),
})
}
/>
<Select
value={customInterval.unit}
onValueChange={(value: any) =>
setCustomInterval({ ...customInterval, unit: value })
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{frequency === "weekly" && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>On</Label>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedDays.length === weekDays.length) {
setSelectedDays([]);
} else {
setSelectedDays(weekDays.map((day) => day.value));
}
}}
>
{selectedDays.length === weekDays.length
? "Deselect All"
: "Select All"}
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedDays([1, 2, 3, 4, 5])}
>
Weekdays
</Button>
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => setSelectedDays([0, 6])}
>
Weekends
</Button>
</div>
<div className="flex flex-wrap gap-2">
{weekDays.map((day) => (
<Button
key={day.value}
variant={
selectedDays.includes(day.value) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedDays((prev) =>
prev.includes(day.value)
? prev.filter((d) => d !== day.value)
: [...prev, day.value],
);
}}
>
{day.label}
</Button>
))}
</div>
</div>
)}
{frequency === "monthly" && (
<div className="space-y-4">
<Label>Days of Month</Label>
<div className="flex gap-2">
<Button
variant={!showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(false);
setSelectedDays(
Array.from({ length: 31 }, (_, i) => i + 1),
);
}}
>
All Days
</Button>
<Button
variant={showCustomDays ? "default" : "outline"}
onClick={() => {
setShowCustomDays(true);
setSelectedDays([]);
}}
>
Customize
</Button>
<Button variant="outline" onClick={() => setSelectedDays([15])}>
15th
</Button>
<Button variant="outline" onClick={() => setSelectedDays([31])}>
Last Day
</Button>
</div>
{showCustomDays && (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 31 }, (_, i) => (
<Button
key={i + 1}
variant={
selectedDays.includes(i + 1) ? "default" : "outline"
}
className="h-10 w-10 p-0"
onClick={() => {
setSelectedDays((prev) =>
prev.includes(i + 1)
? prev.filter((d) => d !== i + 1)
: [...prev, i + 1],
);
}}
>
{i + 1}
</Button>
))}
</div>
)}
</div>
)}
{frequency === "yearly" && (
<div className="space-y-4">
<Label>Months</Label>
<div className="flex gap-2">
<Button
variant="outline"
className="h-8 px-2 py-1 text-xs"
onClick={() => {
if (selectedDays.length === months.length) {
setSelectedDays([]);
} else {
setSelectedDays(Array.from({ length: 12 }, (_, i) => i));
}
}}
>
{selectedDays.length === months.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{months.map((month, index) => (
<Button
key={index}
variant={
selectedDays.includes(index) ? "default" : "outline"
}
className="px-2 py-1"
onClick={() => {
setSelectedDays((prev) =>
prev.includes(index)
? prev.filter((m) => m !== index)
: [...prev, index],
);
}}
>
{month.label}
</Button>
))}
</div>
</div>
)}
{frequency !== "minute" && frequency !== "hour" && (
<div className="flex items-center gap-4 space-y-2">
<Label className="pt-2">At</Label>
<Input
type="time"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
/>
</div>
)}
<Separator />
{/*
On the backend, we are using standard cron expressions,
which makes it challenging to add an end date or stop execution
after a certain time using only cron expressions.
(since standard cron expressions have limitations, like the lack of a year field or more...).
We could also use ranges in cron expression for end dates but It doesm't cover all cases (sometimes break)
To automatically end the scheduler, we need to store the end date and time occurrence in the database
and modify scheduler.add_job. Currently, we can only stop the scheduler manually from the monitor tab.
*/}
{/* <div className="space-y-6">
<Label className="text-lg font-medium">Ends</Label>
<RadioGroup
value={endType}
onValueChange={(value: "never" | "on" | "after") =>
setEndType(value)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="never" id="never" />
<Label htmlFor="never">Never</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="on" id="on" />
<Label htmlFor="on" className="w-[50px]">
On
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full"
disabled={endType !== "on"}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={endDate}
onSelect={setEndDate}
disabled={(date) => date < new Date()}
fromDate={new Date()}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="after" id="after" />
<Label htmlFor="after" className="ml-2 w-[50px]">
After
</Label>
<Input
type="number"
className="ml-2 w-[100px]"
value={occurrences}
onChange={(e) => setOccurrences(Number(e.target.value))}
/>
<span>times</span>
</div>
</RadioGroup>
</div> */}
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
const cronExpr = cron_manager.generateCronExpression(
frequency,
selectedTime,
selectedDays,
selectedMinute,
customInterval,
);
setFrequency("minute");
setSelectedDays([]);
setSelectedTime("00:00");
setShowCustomDays(false);
setSelectedMinute("0");
setCustomInterval({ value: 1, unit: "minutes" });
setOpen(false);
afterCronCreation(cronExpr);
}}
>
Done
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ClockIcon, Loader2 } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { CronExpressionManager } from "@/lib/monitor/cronExpressionManager";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import {
Select,
SelectContent,
@@ -52,7 +52,6 @@ export const SchedulesTable = ({
}: SchedulesTableProps) => {
const { toast } = useToast();
const router = useRouter();
const cron_manager = new CronExpressionManager();
const [selectedAgent, setSelectedAgent] = useState<string>(""); // Library Agent ID
const [selectedVersion, setSelectedVersion] = useState<number>(0); // Graph version
const [maxVersion, setMaxVersion] = useState<number>(0);
@@ -246,7 +245,7 @@ export const SchedulesTable = ({
</TableCell>
<TableCell>
<Badge variant="secondary">
{cron_manager.generateDescription(schedule.cron || "")}
{humanizeCronExpression(schedule.cron)}
</Badge>
</TableCell>

View File

@@ -1073,16 +1073,21 @@ export default function useAgentGraph(
// runs after saving cron expression and inputs (if exists)
const scheduleRunner = useCallback(
async (cronExpression: string, inputs: InputItem[]) => {
async (
cronExpression: string,
inputs: InputItem[],
scheduleName: string,
) => {
await saveAgent();
try {
if (flowID) {
await api.createSchedule({
await api.createGraphExecutionSchedule({
graph_id: flowID,
// flowVersion is always defined here because scheduling is opened for a specific version
graph_version: flowVersion!,
name: scheduleName,
cron: cronExpression,
input_data: inputs.reduce(
inputs: inputs.reduce(
(acc, input) => ({
...acc,
[input.hardcodedValues.name]: input.hardcodedValues.value,

View File

@@ -742,22 +742,35 @@ export default class BackendAPI {
/////////// SCHEDULES ////////////
//////////////////////////////////
async createSchedule(schedule: ScheduleCreatable): Promise<Schedule> {
return this._request("POST", `/schedules`, schedule).then(
parseScheduleTimestamp,
async createGraphExecutionSchedule(
params: ScheduleCreatable,
): Promise<Schedule> {
return this._request(
"POST",
`/graphs/${params.graph_id}/schedules`,
params,
).then(parseScheduleTimestamp);
}
async listGraphExecutionSchedules(graphID: GraphID): Promise<Schedule[]> {
return this._get(`/graphs/${graphID}/schedules`).then((schedules) =>
schedules.map(parseScheduleTimestamp),
);
}
async deleteSchedule(scheduleId: ScheduleID): Promise<{ id: string }> {
return this._request("DELETE", `/schedules/${scheduleId}`);
}
async listSchedules(): Promise<Schedule[]> {
/** @deprecated only used in legacy `Monitor` */
async listAllGraphsExecutionSchedules(): Promise<Schedule[]> {
return this._get(`/schedules`).then((schedules) =>
schedules.map(parseScheduleTimestamp),
);
}
async deleteGraphExecutionSchedule(
scheduleID: ScheduleID,
): Promise<{ id: ScheduleID }> {
return this._request("DELETE", `/schedules/${scheduleID}`);
}
//////////////////////////////////
////////////// OTTO //////////////
//////////////////////////////////

View File

@@ -771,6 +771,7 @@ export type ProfileDetails = {
avatar_url: string;
};
/* Mirror of backend/executor/scheduler.py:GraphExecutionJobInfo */
export type Schedule = {
id: ScheduleID;
name: string;
@@ -778,17 +779,21 @@ export type Schedule = {
user_id: UserID;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
input_data: Record<string, any>;
input_credentials: Record<string, CredentialsMetaInput>;
next_run_time: Date;
};
export type ScheduleID = Brand<string, "ScheduleID">;
/* Mirror of backend/server/routers/v1.py:ScheduleCreationRequest */
export type ScheduleCreatable = {
cron: string;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
name: string;
cron: string;
inputs: Record<string, any>;
credentials?: Record<string, CredentialsMetaInput>;
};
export type MyAgent = {

View File

@@ -0,0 +1,265 @@
export type CronFrequency =
| "every minute"
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "custom";
export type CronExpressionParams =
| { frequency: "every minute" }
| {
frequency: "hourly";
minute: number;
}
| ((
| {
frequency: "daily";
}
| {
frequency: "weekly";
/** 0-based list of weekdays: 0 = Monday ... 6 = Sunday */
days: number[];
}
| {
frequency: "monthly";
/** 1-based list of month days */
days: number[];
}
| {
frequency: "yearly";
/** 1-based list of months (1-12) */
months: number[];
}
| {
frequency: "custom";
customInterval: { unit: string; value: number };
}
) & {
minute: number;
hour: number;
});
export function makeCronExpression(params: CronExpressionParams): string {
const frequency = params.frequency;
if (frequency === "every minute") return "* * * * *";
if (frequency === "hourly") return `${params.minute} * * * *`;
if (frequency === "daily") return `${params.minute} ${params.hour} * * *`;
if (frequency === "weekly") {
const { minute, hour, days } = params;
const weekDaysExpr = days.sort((a, b) => a - b).join(",");
return `${minute} ${hour} * * ${weekDaysExpr}`;
}
if (frequency === "monthly") {
const { minute, hour, days } = params;
const monthDaysExpr = days.sort((a, b) => a - b).join(",");
return `${minute} ${hour} ${monthDaysExpr} * *`;
}
if (frequency === "yearly") {
const { minute, hour, months } = params;
const monthList = months.sort((a, b) => a - b).join(",");
return `${minute} ${hour} 1 ${monthList} *`;
}
if (frequency === "custom") {
const { minute, hour, customInterval } = params;
if (customInterval.unit === "minutes") {
return `*/${customInterval.value} * * * *`;
} else if (customInterval.unit === "hours") {
return `0 */${customInterval.value} * * *`;
} else {
return `${minute} ${hour} */${customInterval.value} * *`;
}
}
return "";
}
export function humanizeCronExpression(cronExpression: string): string {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error("Invalid cron expression format.");
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Handle every minute
if (cronExpression === "* * * * *") {
return "Every minute";
}
// Handle minute intervals (e.g., */5 * * * *)
if (
minute.startsWith("*/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.substring(2);
return `Every ${interval} minutes`;
}
// Handle hour intervals (e.g., 30 * * * *)
if (
hour === "*" &&
!minute.includes("/") &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
return `Every hour at minute ${minute}`;
}
// Handle every N hours (e.g., 0 */2 * * *)
if (
hour.startsWith("*/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.substring(2);
return `Every ${interval} hours`;
}
// Handle daily (e.g., 30 14 * * *)
if (
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
return `Every day at ${formatTime(hour, minute)}`;
}
// Handle weekly (e.g., 30 14 * * 1,3,5)
if (
dayOfWeek !== "*" &&
dayOfMonth === "*" &&
month === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = getDayNames(dayOfWeek);
return `Every ${days} at ${formatTime(hour, minute)}`;
}
// Handle monthly (e.g., 30 14 1,15 * *)
if (
dayOfMonth !== "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = dayOfMonth.split(",").map(Number);
const dayList = days.join(", ");
return `On day ${dayList} of every month at ${formatTime(hour, minute)}`;
}
// Handle yearly (e.g., 30 14 1 1,6,12 *)
if (
dayOfMonth !== "*" &&
month !== "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const months = getMonthNames(month);
return `Every year on the 1st day of ${months} at ${formatTime(hour, minute)}`;
}
// Handle custom minute intervals with other fields as * (e.g., every N minutes)
if (
minute.includes("/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.split("/")[1];
return `Every ${interval} minutes`;
}
// Handle custom hour intervals with other fields as * (e.g., every N hours)
if (
hour.includes("/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.split("/")[1];
return `Every ${interval} hours`;
}
// Handle specific days with custom intervals (e.g., every N days)
if (
dayOfMonth.startsWith("*/") &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const interval = dayOfMonth.substring(2);
return `Every ${interval} days at ${formatTime(hour, minute)}`;
}
return `Cron Expression: ${cronExpression}`;
}
function formatTime(hour: string, minute: string): string {
const formattedHour = padZero(hour);
const formattedMinute = padZero(minute);
return `${formattedHour}:${formattedMinute}`;
}
function padZero(value: string): string {
return value.padStart(2, "0");
}
function getDayNames(dayOfWeek: string): string {
const days = dayOfWeek.split(",").map(Number);
const dayNames = days
.map((d) => {
const names = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
return names[d] || `Unknown(${d})`;
})
.join(", ");
return dayNames;
}
function getMonthNames(month: string): string {
const months = month.split(",").map(Number);
const monthNames = months
.map((m) => {
const names = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return names[m - 1] || `Unknown(${m})`;
})
.join(", ");
return monthNames;
}

View File

@@ -1,239 +0,0 @@
export class CronExpressionManager {
generateCronExpression(
frequency: string,
selectedTime: string,
selectedDays: number[],
selectedMinute: string,
customInterval: { unit: string; value: number },
): string {
const [hours, minutes] = selectedTime.split(":").map(Number);
let expression = "";
switch (frequency) {
case "minute":
expression = "* * * * *";
break;
case "hour":
expression = `${selectedMinute} * * * *`;
break;
case "daily":
expression = `${minutes} ${hours} * * *`;
break;
case "weekly":
const days = selectedDays.join(",");
expression = `${minutes} ${hours} * * ${days}`;
break;
case "monthly":
const monthDays = selectedDays.sort((a, b) => a - b).join(",");
expression = `${minutes} ${hours} ${monthDays} * *`;
break;
case "yearly":
const monthList = selectedDays
.map((d) => d + 1)
.sort((a, b) => a - b)
.join(",");
expression = `${minutes} ${hours} 1 ${monthList} *`;
break;
case "custom":
if (customInterval.unit === "minutes") {
expression = `*/${customInterval.value} * * * *`;
} else if (customInterval.unit === "hours") {
expression = `0 */${customInterval.value} * * *`;
} else {
expression = `${minutes} ${hours} */${customInterval.value} * *`;
}
break;
default:
expression = "";
}
return expression;
}
generateDescription(cronExpression: string): string {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error("Invalid cron expression format.");
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Handle every minute
if (cronExpression === "* * * * *") {
return "Every minute";
}
// Handle minute intervals (e.g., */5 * * * *)
if (
minute.startsWith("*/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.substring(2);
return `Every ${interval} minutes`;
}
// Handle hour intervals (e.g., 30 * * * *)
if (
hour === "*" &&
!minute.includes("/") &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
return `Every hour at minute ${minute}`;
}
// Handle every N hours (e.g., 0 */2 * * *)
if (
hour.startsWith("*/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.substring(2);
return `Every ${interval} hours`;
}
// Handle daily (e.g., 30 14 * * *)
if (
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
return `Every day at ${this.formatTime(hour, minute)}`;
}
// Handle weekly (e.g., 30 14 * * 1,3,5)
if (
dayOfWeek !== "*" &&
dayOfMonth === "*" &&
month === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = this.getDayNames(dayOfWeek);
return `Every ${days} at ${this.formatTime(hour, minute)}`;
}
// Handle monthly (e.g., 30 14 1,15 * *)
if (
dayOfMonth !== "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const days = dayOfMonth.split(",").map(Number);
const dayList = days.join(", ");
return `On day ${dayList} of every month at ${this.formatTime(hour, minute)}`;
}
// Handle yearly (e.g., 30 14 1 1,6,12 *)
if (
dayOfMonth !== "*" &&
month !== "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const months = this.getMonthNames(month);
return `Every year on the 1st day of ${months} at ${this.formatTime(hour, minute)}`;
}
// Handle custom minute intervals with other fields as * (e.g., every N minutes)
if (
minute.includes("/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = minute.split("/")[1];
return `Every ${interval} minutes`;
}
// Handle custom hour intervals with other fields as * (e.g., every N hours)
if (
hour.includes("/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
const interval = hour.split("/")[1];
return `Every ${interval} hours`;
}
// Handle specific days with custom intervals (e.g., every N days)
if (
dayOfMonth.startsWith("*/") &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
const interval = dayOfMonth.substring(2);
return `Every ${interval} days at ${this.formatTime(hour, minute)}`;
}
return `Cron Expression: ${cronExpression}`;
}
private formatTime(hour: string, minute: string): string {
const formattedHour = this.padZero(hour);
const formattedMinute = this.padZero(minute);
return `${formattedHour}:${formattedMinute}`;
}
private padZero(value: string): string {
return value.padStart(2, "0");
}
private getDayNames(dayOfWeek: string): string {
const days = dayOfWeek.split(",").map(Number);
const dayNames = days
.map((d) => {
const names = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
return names[d] || `Unknown(${d})`;
})
.join(", ");
return dayNames;
}
private getMonthNames(month: string): string {
const months = month.split(",").map(Number);
const monthNames = months
.map((m) => {
const names = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return names[m - 1] || `Unknown(${m})`;
})
.join(", ");
return monthNames;
}
}