feat(platform) : scheduling agent runner (#8634)

* add: ui for scheduling agent

* adding requests and type for schedule endpoints

* feat : monitor schdules on monitor page

* add: Complete monitor page

* fix filter on monitor page

* fix linting

* PR nits

* Added Docker Compose env var

---------

Co-authored-by: Toran Bruce Richards <toran.richards@gmail.com>
Co-authored-by: Swifty <craigswift13@gmail.com>
Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
This commit is contained in:
Abhimanyu Yadav 2024-11-14 20:48:56 +05:30 committed by GitHub
parent bbbdb5665b
commit dd0081ab35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1248 additions and 10 deletions

View File

@ -37,7 +37,7 @@ class ExecutionSchedule(BaseDbModel):
async def get_active_schedules(last_fetch_time: datetime) -> list[ExecutionSchedule]:
query = AgentGraphExecutionSchedule.prisma().find_many(
where={"isEnabled": True, "lastUpdated": {"gt": last_fetch_time}},
where={"lastUpdated": {"gt": last_fetch_time}},
order={"lastUpdated": "asc"},
)
return [ExecutionSchedule.from_db(schedule) for schedule in await query]

View File

@ -65,6 +65,7 @@ services:
- REDIS_PASSWORD=password
- ENABLE_AUTH=true
- PYRO_HOST=0.0.0.0
- EXECUTIONSCHEDULER_HOST=rest_server
- EXECUTIONMANAGER_HOST=executor
- DATABASEMANAGER_HOST=executor
- FRONTEND_BASE_URL=http://localhost:3000

View File

@ -34,6 +34,7 @@
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",

View File

@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import AutoGPTServerAPI, {
GraphMetaWithRuns,
ExecutionMeta,
Schedule,
} from "@/lib/autogpt-server-api";
import { Card } from "@/components/ui/card";
@ -15,17 +16,51 @@ import {
FlowRunsList,
FlowRunsStats,
} from "@/components/monitor";
import { SchedulesTable } from "@/components/monitor/scheduleTable";
const Monitor = () => {
const [flows, setFlows] = useState<GraphMetaWithRuns[]>([]);
const [flowRuns, setFlowRuns] = useState<FlowRun[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedFlow, setSelectedFlow] = useState<GraphMetaWithRuns | null>(
null,
);
const [selectedRun, setSelectedRun] = useState<FlowRun | null>(null);
const [sortColumn, setSortColumn] = useState<keyof Schedule>("id");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const api = useMemo(() => new AutoGPTServerAPI(), []);
const fetchSchedules = useCallback(async () => {
const schedulesData: Schedule[] = [];
for (const flow of flows) {
const flowSchedules = await api.getSchedules(flow.id);
Object.entries(flowSchedules).forEach(([id, schedule]) => {
schedulesData.push({
id,
schedule,
graph_id: flow.id,
});
});
}
setSchedules(schedulesData);
}, [api, flows]);
const toggleSchedule = useCallback(
async (scheduleId: string, enabled: boolean) => {
await api.updateSchedule(scheduleId, { is_enabled: enabled });
setSchedules((prevSchedules) =>
prevSchedules.map((schedule) =>
schedule.id === scheduleId
? { ...schedule, isEnabled: enabled }
: schedule,
),
);
},
[api],
);
const fetchAgents = useCallback(() => {
api.listGraphsWithRuns().then((agent) => {
setFlows(agent);
@ -44,15 +79,28 @@ const Monitor = () => {
fetchAgents();
}, [api, fetchAgents]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules, flows]);
useEffect(() => {
const intervalId = setInterval(() => fetchAgents(), 5000);
return () => clearInterval(intervalId);
}, [fetchAgents, flows]);
const column1 = "md:col-span-2 xl:col-span-3 xxl:col-span-2";
const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3 space-y-4";
const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3";
const column3 = "col-span-full xl:col-span-4 xxl:col-span-5";
const handleSort = (column: keyof Schedule) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10">
<AgentFlowList
@ -101,6 +149,16 @@ const Monitor = () => {
<FlowRunsStats flows={flows} flowRuns={flowRuns} />
</Card>
)}
<div className="col-span-full md:col-span-3 lg:col-span-2 xl:col-span-6">
<SchedulesTable
schedules={schedules} // all schedules
agents={flows} // for filtering purpose
onToggleSchedule={toggleSchedule}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
</div>
</div>
);
};

View File

@ -45,6 +45,7 @@ import RunnerUIWrapper, {
import PrimaryActionBar from "@/components/PrimaryActionButton";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import { CronScheduler } from "./cronScheduler";
// 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
@ -97,7 +98,10 @@ const FlowEditor: React.FC<{
requestSave,
requestSaveAndRun,
requestStopRun,
scheduleRunner,
isRunning,
isScheduling,
setIsScheduling,
nodes,
setNodes,
edges,
@ -119,6 +123,8 @@ const FlowEditor: React.FC<{
const runnerUIRef = useRef<RunnerUIWrapperRef>(null);
const [openCron, setOpenCron] = useState(false);
const { toast } = useToast();
const TUTORIAL_STORAGE_KEY = "shepherd-tour";
@ -146,6 +152,12 @@ const FlowEditor: React.FC<{
nodes.length,
]);
useEffect(() => {
if (params.get("open_scheduling") === "true") {
setOpenCron(true);
}
}, [params]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
@ -611,6 +623,24 @@ 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);
};
// This function Opens up form for creating cron expression
const handleScheduleButton = () => {
if (!savedAgent) {
toast({
title: `Please save the agent using the button in the left sidebar before running it.`,
duration: 2000,
});
return;
}
setOpenCron(true);
};
return (
<FlowContext.Provider
value={{ visualizeBeads, setIsAnyModalOpen, getNextNodeId }}
@ -673,18 +703,28 @@ const FlowEditor: React.FC<{
requestStopRun();
}
}}
onClickScheduleButton={handleScheduleButton}
isScheduling={isScheduling}
isDisabled={!savedAgent}
isRunning={isRunning}
requestStopRun={requestStopRun}
runAgentTooltip={!isRunning ? "Run Agent" : "Stop Agent"}
/>
<CronScheduler
afterCronCreation={afterCronCreation}
open={openCron}
setOpen={setOpenCron}
/>
</ReactFlow>
</div>
<RunnerUIWrapper
ref={runnerUIRef}
nodes={nodes}
setNodes={setNodes}
setIsScheduling={setIsScheduling}
isScheduling={isScheduling}
isRunning={isRunning}
scheduleRunner={scheduleRunner}
requestSaveAndRun={requestSaveAndRun}
/>
</FlowContext.Provider>

View File

@ -1,18 +1,21 @@
import React from "react";
import React, { useState } from "react";
import { Button } from "./ui/button";
import { LogOut } from "lucide-react";
import { Clock, LogOut, ChevronLeft } from "lucide-react";
import { IconPlay, IconSquare } from "@/components/ui/icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FaSpinner } from "react-icons/fa";
interface PrimaryActionBarProps {
onClickAgentOutputs: () => void;
onClickRunAgent: () => void;
onClickScheduleButton: () => void;
isRunning: boolean;
isDisabled: boolean;
isScheduling: boolean;
requestStopRun: () => void;
runAgentTooltip: string;
}
@ -20,8 +23,10 @@ interface PrimaryActionBarProps {
const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
onClickAgentOutputs,
onClickRunAgent,
onClickScheduleButton,
isRunning,
isDisabled,
isScheduling,
requestStopRun,
runAgentTooltip,
}) => {
@ -74,6 +79,30 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
<p>{runAgentTooltip}</p>
</TooltipContent>
</Tooltip>
<Tooltip key="ScheduleAgent" delayDuration={500}>
<TooltipTrigger asChild>
<Button
className="flex items-center gap-2"
onClick={onClickScheduleButton}
size="primary"
disabled={isScheduling}
variant="outline"
data-id="primary-action-schedule-agent"
>
{isScheduling ? (
<FaSpinner className="animate-spin" />
) : (
<Clock className="hidden h-5 w-5 md:flex" />
)}
<span className="text-sm font-medium md:text-lg">
Schedule Run
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Schedule this Agent</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@ -10,24 +10,55 @@ import { Node } from "@xyflow/react";
import { filterBlocksByType } from "@/lib/utils";
import { BlockIORootSchema, BlockUIType } from "@/lib/autogpt-server-api/types";
interface HardcodedValues {
name: any;
description: any;
value: any;
placeholder_values: any;
limit_to_placeholder_values: any;
}
export interface InputItem {
id: string;
type: "input";
inputSchema: BlockIORootSchema;
hardcodedValues: HardcodedValues;
}
interface RunnerUIWrapperProps {
nodes: Node[];
setNodes: React.Dispatch<React.SetStateAction<Node[]>>;
setIsScheduling: React.Dispatch<React.SetStateAction<boolean>>;
isRunning: boolean;
isScheduling: boolean;
requestSaveAndRun: () => void;
scheduleRunner: (cronExpression: string, input: InputItem[]) => Promise<void>;
}
export interface RunnerUIWrapperRef {
openRunnerInput: () => void;
openRunnerOutput: () => void;
runOrOpenInput: () => void;
collectInputsForScheduling: (cronExpression: string) => void;
}
const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
({ nodes, setNodes, isRunning, requestSaveAndRun }, ref) => {
(
{
nodes,
setIsScheduling,
setNodes,
isScheduling,
isRunning,
requestSaveAndRun,
scheduleRunner,
},
ref,
) => {
const [isRunnerInputOpen, setIsRunnerInputOpen] = useState(false);
const [isRunnerOutputOpen, setIsRunnerOutputOpen] = useState(false);
const [scheduledInput, setScheduledInput] = useState(false);
const [cronExpression, setCronExpression] = useState("");
const getBlockInputsAndOutputs = useCallback(() => {
const inputBlocks = filterBlocksByType(
nodes,
@ -107,10 +138,23 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
}
};
const collectInputsForScheduling = (cron_exp: string) => {
const { inputs } = getBlockInputsAndOutputs();
setCronExpression(cron_exp);
if (inputs.length > 0) {
setScheduledInput(true);
setIsRunnerInputOpen(true);
} else {
scheduleRunner(cron_exp, []);
}
};
useImperativeHandle(ref, () => ({
openRunnerInput,
openRunnerOutput,
runOrOpenInput,
collectInputsForScheduling,
}));
return (
@ -124,6 +168,18 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
setIsRunnerInputOpen(false);
requestSaveAndRun();
}}
scheduledInput={scheduledInput}
isScheduling={isScheduling}
onSchedule={async () => {
setIsScheduling(true);
await scheduleRunner(
cronExpression,
getBlockInputsAndOutputs().inputs,
);
setIsScheduling(false);
setIsRunnerInputOpen(false);
setScheduledInput(false);
}}
isRunning={isRunning}
/>
<RunnerOutputUI

View File

@ -0,0 +1,425 @@
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,
DialogTrigger,
} 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"
>("minute");
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [selectedTime, setSelectedTime] = useState<string>("00: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}>
<DialogTrigger asChild>
<Button variant="outline">Schedule</Button>
</DialogTrigger>
<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="minute"
>
<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

@ -0,0 +1,225 @@
import { Schedule } from "@/lib/autogpt-server-api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface SchedulesTableProps {
schedules: Schedule[];
agents: GraphMeta[];
onToggleSchedule: (scheduleId: string, enabled: boolean) => void;
sortColumn: keyof Schedule;
sortDirection: "asc" | "desc";
onSort: (column: keyof Schedule) => void;
}
export const SchedulesTable = ({
schedules,
agents,
onToggleSchedule,
sortColumn,
sortDirection,
onSort,
}: SchedulesTableProps) => {
const { toast } = useToast();
const router = useRouter();
const cron_manager = new CronExpressionManager();
const [selectedAgent, setSelectedAgent] = useState<string>("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<string>("");
const filteredAndSortedSchedules = [...schedules]
.filter(
(schedule) => !selectedFilter || schedule.graph_id === selectedFilter,
)
.sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
if (sortDirection === "asc") {
return String(aValue).localeCompare(String(bValue));
}
return String(bValue).localeCompare(String(aValue));
});
const handleToggleSchedule = (scheduleId: string, enabled: boolean) => {
onToggleSchedule(scheduleId, enabled);
if (!enabled) {
toast({
title: "Schedule Disabled",
description: "The schedule has been successfully disabled.",
});
}
};
const handleNewSchedule = () => {
setIsDialogOpen(true);
};
const handleAgentSelect = (agentId: string) => {
setSelectedAgent(agentId);
};
const handleSchedule = async () => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 100));
router.push(`/build?flowID=${selectedAgent}&open_scheduling=true`);
} catch (error) {
console.error("Navigation error:", error);
}
};
return (
<Card className="h-fit p-4">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Select Agent for New Schedule</DialogTitle>
</DialogHeader>
<Select onValueChange={handleAgentSelect}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent, i) => (
<SelectItem key={agent.id + i} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleSchedule}
disabled={isLoading || !selectedAgent}
className="mt-4"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
"Schedule"
)}
</Button>
</DialogContent>
</Dialog>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Schedules</h3>
<div className="flex gap-2">
<Select onValueChange={setSelectedFilter}>
<SelectTrigger className="h-8 w-[180px] rounded-md px-3 text-xs">
<SelectValue placeholder="Filter by graph" />
</SelectTrigger>
<SelectContent className="text-xs">
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={handleNewSchedule}>
<ClockIcon className="mr-2 h-4 w-4" />
New Schedule
</Button>
</div>
</div>
<ScrollArea className="max-h-[400px]">
<Table>
<TableHeader>
<TableRow>
<TableHead
onClick={() => onSort("graph_id")}
className="cursor-pointer"
>
Graph Name
</TableHead>
<TableHead
onClick={() => onSort("id")}
className="cursor-pointer"
>
ID
</TableHead>
<TableHead
onClick={() => onSort("schedule")}
className="cursor-pointer"
>
Schedule
</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedSchedules.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="py-8 text-center text-lg text-gray-400"
>
No schedules are available
</TableCell>
</TableRow>
) : (
filteredAndSortedSchedules.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell className="font-medium">
{agents.find((a) => a.id === schedule.graph_id)?.name ||
schedule.graph_id}
</TableCell>
<TableCell>{schedule.id}</TableCell>
<TableCell>
<Badge variant="secondary">
{cron_manager.generateDescription(schedule.schedule)}
</Badge>
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
variant={"destructive"}
onClick={() => handleToggleSchedule(schedule.id, false)}
>
Disable
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</ScrollArea>
</Card>
);
};

View File

@ -29,6 +29,9 @@ interface RunSettingsUiProps {
blockInputs: BlockInput[];
onInputChange: (nodeId: string, field: string, value: string) => void;
onRun: () => void;
onSchedule: () => Promise<void>;
scheduledInput: boolean;
isScheduling: boolean;
isRunning: boolean;
}
@ -36,8 +39,11 @@ export function RunnerInputUI({
isOpen,
onClose,
blockInputs,
isScheduling,
onInputChange,
onRun,
onSchedule,
scheduledInput,
isRunning,
}: RunSettingsUiProps) {
const handleRun = () => {
@ -45,11 +51,18 @@ export function RunnerInputUI({
onClose();
};
const handleSchedule = async () => {
onClose();
await onSchedule();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-[400px] md:max-w-[500px] lg:max-w-[600px]">
<DialogHeader className="px-4 py-4">
<DialogTitle className="text-2xl">Run Settings</DialogTitle>
<DialogTitle className="text-2xl">
{scheduledInput ? "Schedule Settings" : "Run Settings"}
</DialogTitle>
<DialogDescription className="mt-2 text-sm">
Configure settings for running your agent.
</DialogDescription>
@ -59,11 +72,11 @@ export function RunnerInputUI({
</div>
<DialogFooter className="px-6 py-4">
<Button
onClick={handleRun}
onClick={scheduledInput ? handleSchedule : handleRun}
className="px-8 py-2 text-lg"
disabled={isRunning}
disabled={scheduledInput ? isScheduling : isRunning}
>
{isRunning ? "Running..." : "Run"}
{scheduledInput ? "Schedule" : isRunning ? "Running..." : "Run"}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -0,0 +1,43 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { cn } from "@/lib/utils";
import { DotFilledIcon } from "@radix-ui/react-icons";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-neutral-200 border-neutral-900 text-neutral-900 shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-50 dark:border-neutral-800 dark:text-neutral-50 dark:focus-visible:ring-neutral-300",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<DotFilledIcon className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@ -19,6 +19,7 @@ import Ajv from "ajv";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useToast } from "@/components/ui/use-toast";
import { InputItem } from "@/components/RunnerUIWrapper";
import { GraphMeta } from "@/lib/autogpt-server-api";
const ajv = new Ajv({ strict: false, allErrors: true });
@ -34,6 +35,7 @@ export default function useAgentGraph(
useSearchParams(),
usePathname(),
];
const [isScheduling, setIsScheduling] = useState(false);
const [savedAgent, setSavedAgent] = useState<Graph | null>(null);
const [agentDescription, setAgentDescription] = useState<string>("");
const [agentName, setAgentName] = useState<string>("");
@ -863,6 +865,50 @@ export default function useAgentGraph(
}));
}, [saveRunRequest]);
// runs after saving cron expression and inputs (if exists)
const scheduleRunner = useCallback(
async (cronExpression: string, inputs: InputItem[]) => {
await saveAgent();
try {
const converted = inputs.reduce(
(acc, input) => ({
...acc,
[input.id]: {
type: input.type,
inputSchema: input.inputSchema,
hardcodedValues: input.hardcodedValues,
},
}),
{},
);
if (flowID) {
await api.createSchedule(flowID, {
cron: cronExpression,
input_data: converted,
});
toast({
title: "Agent scheduling successful",
});
// if scheduling is done from the monitor page, then redirect to monitor page after successful scheduling
if (searchParams.get("open_scheduling") === "true") {
router.push("/");
}
} else {
return;
}
} catch (error) {
console.log(error);
toast({
variant: "destructive",
title: "Error scheduling agent",
description: "Please retry",
});
}
},
[api, flowID, saveAgent, toast, router, searchParams],
);
return {
agentName,
setAgentName,
@ -875,9 +921,12 @@ export default function useAgentGraph(
requestSave,
requestSaveAndRun,
requestStopRun,
scheduleRunner,
isSaving: saveRunRequest.state == "saving",
isRunning: saveRunRequest.state == "running",
isStopping: saveRunRequest.state == "stopping",
isScheduling,
setIsScheduling,
nodes,
setNodes,
edges,

View File

@ -16,6 +16,8 @@ import {
NodeExecutionResult,
OAuth2Credentials,
User,
ScheduleCreatable,
ScheduleUpdateable,
} from "./types";
export default class BaseAutoGPTServerAPI {
@ -238,6 +240,31 @@ export default class BaseAutoGPTServerAPI {
return this._request("GET", path, query);
}
// Scheduling request
async createSchedule(
graphId: string,
schedule: ScheduleCreatable,
): Promise<{ id: string }> {
return this._request(
"POST",
`/graphs/${graphId}/schedules?cron=${schedule.cron}`,
{
input_data: schedule.input_data,
},
);
}
async updateSchedule(
scheduleId: string,
update: ScheduleUpdateable,
): Promise<{ id: string }> {
return this._request("PUT", `/graphs/schedules/${scheduleId}`, update);
}
async getSchedules(graphId: string): Promise<{ [key: string]: string }> {
return this._get(`/graphs/${graphId}/schedules`);
}
private async _request(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
path: string,

View File

@ -329,3 +329,19 @@ export type AnalyticsDetails = {
data: { [key: string]: any };
index: string;
};
// Schedule types
export type Schedule = {
id: string;
schedule: string; //cron expression
graph_id: string;
};
export type ScheduleCreatable = {
cron: string;
input_data: { [key: string]: any };
};
export type ScheduleUpdateable = {
is_enabled: boolean;
};

View File

@ -0,0 +1,239 @@
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;
}
}

View File

@ -2326,6 +2326,22 @@
dependencies:
"@radix-ui/react-slot" "1.1.0"
"@radix-ui/react-radio-group@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz#42b914c85f3a77be3ab766b6e49a9598680f76d1"
integrity sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==
dependencies:
"@radix-ui/primitive" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-presence" "1.1.1"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-roving-focus" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-use-previous" "1.1.0"
"@radix-ui/react-use-size" "1.1.0"
"@radix-ui/react-roving-focus@1.1.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz"