feat: add frontend schedule panel to workflow editor (#SKY-8184) (#5146)
Some checks failed
Build Skyvern SDK and publish to PyPI / run-ci (push) Blocked by required conditions
Build Skyvern SDK and publish to PyPI / build-sdk (push) Blocked by required conditions
Run tests and pre-commit / Run tests and pre-commit hooks (push) Failing after 6s
Auto Create GitHub Release on Version Change / check-version-change (push) Successful in 1m45s
Run tests and pre-commit / Frontend Lint and Build (push) Successful in 3m48s
Publish Fern Docs / run (push) Failing after 2m43s
Auto Create GitHub Release on Version Change / create-release (push) Has been skipped
Build Skyvern SDK and publish to PyPI / check-version-change (push) Successful in 3m14s

This commit is contained in:
Aaron Perez 2026-03-19 03:07:30 -05:00 committed by GitHub
parent b76de94e5f
commit cfe01b0abe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 3330 additions and 73 deletions

View file

@ -45,6 +45,8 @@
"cmdk": "^1.0.0",
"cors": "^2.8.5",
"country-state-city": "^3.2.1",
"cron-parser": "^5.5.0",
"cronstrue": "^3.13.0",
"cross-spawn": "^7.0.6",
"embla-carousel-react": "^8.0.0",
"express": "^4.21.2",
@ -5148,6 +5150,25 @@
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
},
"node_modules/cron-parser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
"dependencies": {
"luxon": "^3.7.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cronstrue": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz",
"integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==",
"bin": {
"cronstrue": "bin/cli.js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -6077,9 +6098,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true
},
"node_modules/follow-redirects": {
@ -6910,6 +6931,14 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",

View file

@ -56,6 +56,8 @@
"cors": "^2.8.5",
"country-state-city": "^3.2.1",
"cross-spawn": "^7.0.6",
"cron-parser": "^5.5.0",
"cronstrue": "^3.13.0",
"embla-carousel-react": "^8.0.0",
"@microsoft/fetch-event-source": "^2.0.1",
"express": "^4.21.2",

View file

@ -19,6 +19,14 @@ export const ArtifactType = {
export type ArtifactType = (typeof ArtifactType)[keyof typeof ArtifactType];
export const TriggerType = {
Manual: "manual",
Api: "api",
Scheduled: "scheduled",
} as const;
export type TriggerType = (typeof TriggerType)[keyof typeof TriggerType];
export const Status = {
Created: "created",
Running: "running",
@ -394,6 +402,11 @@ export type WorkflowRunApiResponse = {
script_run: boolean | null;
status: Status;
title?: string;
trigger_type?: TriggerType | null;
schedule_id?: string | null;
schedule_name?: string | null;
schedule_cron?: string | null;
scheduled_for?: string | null;
webhook_callback_url: string;
workflow_id: string;
workflow_permanent_id: string;

View file

@ -0,0 +1,48 @@
import { TriggerType } from "@/api/types";
import {
CalendarIcon,
CursorArrowIcon,
LightningBoltIcon,
} from "@radix-ui/react-icons";
import { Tip } from "@/components/Tip";
type Props = {
triggerType: TriggerType | null | undefined;
};
const triggerConfig: Record<
TriggerType,
{ icon: React.ReactNode; label: string }
> = {
[TriggerType.Manual]: {
icon: <CursorArrowIcon className="size-3.5 text-slate-400" />,
label: "Manual",
},
[TriggerType.Api]: {
icon: <LightningBoltIcon className="size-3.5 text-amber-400" />,
label: "API",
},
[TriggerType.Scheduled]: {
icon: <CalendarIcon className="size-3.5 text-blue-400" />,
label: "Scheduled",
},
};
function TriggerTypeBadge({ triggerType }: Props) {
if (!triggerType) {
return null;
}
const config = triggerConfig[triggerType];
if (!config) {
return null;
}
return (
<Tip content={config.label}>
<span className="inline-flex shrink-0 items-center">{config.icon}</span>
</Tip>
);
}
export { TriggerTypeBadge };

View file

@ -0,0 +1,81 @@
import { CalendarIcon, ChevronDownIcon } from "@radix-ui/react-icons";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { Checkbox } from "./ui/checkbox";
import { TriggerType } from "@/api/types";
type TriggerTypeDropdownItem = {
label: string;
value: TriggerType;
};
const triggerTypeDropdownItems: Array<TriggerTypeDropdownItem> = [
{
label: "Manual",
value: TriggerType.Manual,
},
{
label: "API",
value: TriggerType.Api,
},
{
label: "Scheduled",
value: TriggerType.Scheduled,
},
];
type Props = {
values: Array<TriggerType>;
onChange: (values: Array<TriggerType>) => void;
};
function TriggerTypeFilterDropdown({ values, onChange }: Props) {
const label =
values.length === 0
? "All Runs"
: values.length === 1
? triggerTypeDropdownItems.find((i) => i.value === values[0])?.label ??
"All Runs"
: `${values.length} types`;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<CalendarIcon className="mr-2 size-4" />
{label}
<ChevronDownIcon className="ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{triggerTypeDropdownItems.map((item) => {
return (
<div
key={item.value}
className="flex items-center gap-2 p-2 text-sm"
>
<Checkbox
id={`trigger-${item.value}`}
checked={values.includes(item.value)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...values, item.value]);
} else {
onChange(values.filter((value) => value !== item.value));
}
}}
/>
<label htmlFor={`trigger-${item.value}`}>{item.label}</label>
</div>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export { TriggerTypeFilterDropdown };

View file

@ -1,7 +1,7 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { Status, Task, WorkflowRunApiResponse } from "@/api/types";
import { Status, Task, TriggerType, WorkflowRunApiResponse } from "@/api/types";
type QueryReturnType = Array<Task | WorkflowRunApiResponse>;
type UseQueryOptions = Omit<
@ -13,6 +13,7 @@ type Props = {
page?: number;
pageSize?: number;
statusFilters?: Array<Status>;
triggerTypeFilters?: Array<TriggerType>;
search?: string;
} & UseQueryOptions;
@ -20,11 +21,18 @@ function useRunsQuery({
page = 1,
pageSize = 10,
statusFilters,
triggerTypeFilters,
search,
}: Props) {
const credentialGetter = useCredentialGetter();
return useQuery<Array<Task | WorkflowRunApiResponse>>({
queryKey: ["runs", { statusFilters }, page, pageSize, search],
queryKey: [
"runs",
{ statusFilters, triggerTypeFilters },
page,
pageSize,
search,
],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
@ -35,6 +43,11 @@ function useRunsQuery({
params.append("status", status);
});
}
if (triggerTypeFilters) {
triggerTypeFilters.forEach((triggerType) => {
params.append("trigger_type", triggerType);
});
}
if (search) {
params.append("search_key", search);
}

View file

@ -32,6 +32,8 @@ import { WorkflowRunCode } from "@/routes/workflows/workflowRun/WorkflowRunCode"
import { DebugStoreProvider } from "@/store/DebugStoreContext";
import { CredentialsPage } from "@/routes/credentials/CredentialsPage.tsx";
import { RunRouter } from "@/routes/runs/RunRouter";
import { SchedulesRoute } from "@/routes/schedules/SchedulesRoute";
import { ScheduleDetailRoute } from "@/routes/schedules/ScheduleDetailRoute";
const router = createBrowserRouter([
{
@ -66,6 +68,20 @@ const router = createBrowserRouter([
path: "runs/:runId/*",
element: <RunRouter />,
},
{
path: "schedules",
element: <PageLayout />,
children: [
{
index: true,
element: <SchedulesRoute />,
},
{
path: ":workflowPermanentId/:scheduleId",
element: <ScheduleDetailRoute />,
},
],
},
{
path: "browser-sessions",
element: <BrowserSessions />,

View file

@ -4,11 +4,14 @@ import { Tip } from "@/components/Tip";
import {
Status,
Task,
TriggerType,
WorkflowRunApiResponse,
WorkflowRunStatusApiResponse,
} from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusFilterDropdown } from "@/components/StatusFilterDropdown";
import { TriggerTypeBadge } from "@/components/TriggerTypeBadge";
import { TriggerTypeFilterDropdown } from "@/components/TriggerTypeFilterDropdown";
import {
Pagination,
PaginationContent,
@ -66,6 +69,9 @@ function RunHistory() {
? Number(searchParams.get("page_size"))
: 10;
const [statusFilters, setStatusFilters] = useState<Array<Status>>([]);
const [triggerTypeFilters, setTriggerTypeFilters] = useState<
Array<TriggerType>
>([]);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
@ -73,13 +79,20 @@ function RunHistory() {
page,
pageSize: itemsPerPage,
statusFilters,
triggerTypeFilters,
search: debouncedSearch,
});
const navigate = useNavigate();
const { data: nextPageRuns } = useQuery<Array<Task | WorkflowRunApiResponse>>(
{
queryKey: ["runs", { statusFilters }, page + 1, itemsPerPage],
queryKey: [
"runs",
{ statusFilters, triggerTypeFilters },
page + 1,
itemsPerPage,
debouncedSearch,
],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
@ -90,6 +103,14 @@ function RunHistory() {
params.append("status", status);
});
}
if (triggerTypeFilters) {
triggerTypeFilters.forEach((triggerType) => {
params.append("trigger_type", triggerType);
});
}
if (debouncedSearch) {
params.append("search_key", debouncedSearch);
}
return client.get("/runs", { params }).then((res) => res.data);
},
enabled: runs && runs.length === itemsPerPage,
@ -154,7 +175,7 @@ function RunHistory() {
<header>
<h1 className="text-2xl">Run History</h1>
</header>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<TableSearchInput
value={search}
onChange={(value) => {
@ -166,9 +187,23 @@ function RunHistory() {
placeholder="Search by run ID or parameter..."
className="w-48 lg:w-72"
/>
<TriggerTypeFilterDropdown
values={triggerTypeFilters}
onChange={(values) => {
setTriggerTypeFilters(values);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
setSearchParams(params, { replace: true });
}}
/>
<StatusFilterDropdown
values={statusFilters}
onChange={setStatusFilters}
onChange={(values) => {
setStatusFilters(values);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
setSearchParams(params, { replace: true });
}}
/>
</div>
<div className="rounded-lg border">
@ -241,17 +276,17 @@ function RunHistory() {
);
}
const workflowTitle =
run.script_run === true ? (
<div className="flex items-center gap-2">
const workflowTitle = (
<div className="flex items-center gap-2">
<span className="truncate">{run.workflow_title ?? ""}</span>
{run.script_run === true && (
<Tip content="Ran with code">
<LightningBoltIcon className="text-[gold]" />
</Tip>
<span>{run.workflow_title ?? ""}</span>
</div>
) : (
run.workflow_title ?? ""
);
)}
<TriggerTypeBadge triggerType={run.trigger_type} />
</div>
);
const isExpanded = expandedRows.has(run.workflow_run_id);
const workflowExecutionTime = formatExecutionTime(
@ -292,9 +327,25 @@ function RunHistory() {
</TableCell>
<TableCell
className="max-w-0 truncate"
title={basicTimeFormat(run.created_at)}
title={
run.trigger_type === TriggerType.Scheduled &&
run.scheduled_for
? `Scheduled: ${basicTimeFormat(run.scheduled_for)}\nStarted: ${basicTimeFormat(run.created_at)}`
: basicTimeFormat(run.created_at)
}
>
{basicLocalTimeFormat(run.created_at)}
<div className="flex flex-col">
<span>{basicLocalTimeFormat(run.created_at)}</span>
{run.trigger_type === TriggerType.Scheduled &&
run.schedule_name && (
<span className="text-xs text-slate-400">
{run.schedule_name}
{run.schedule_cron
? ` (${run.schedule_cron})`
: ""}
</span>
)}
</div>
</TableCell>
<TableCell className="text-slate-400">
{workflowExecutionTime ?? "-"}

View file

@ -3,15 +3,18 @@ import { NavLinkGroup } from "@/components/NavLinkGroup";
import { useSidebarStore } from "@/store/SidebarStore";
import { cn } from "@/util/utils";
import {
CalendarIcon,
CounterClockwiseClockIcon,
GearIcon,
GlobeIcon,
LightningBoltIcon,
} from "@radix-ui/react-icons";
import { KeyIcon } from "@/components/icons/KeyIcon.tsx";
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
function SideNav() {
const { collapsed } = useSidebarStore();
const schedulesEnabled = useFeatureFlag("WORKFLOW_SCHEDULES");
return (
<nav
@ -37,6 +40,15 @@ function SideNav() {
to: "/runs",
icon: <CounterClockwiseClockIcon className="size-6" />,
},
...(schedulesEnabled
? [
{
label: "Schedules",
to: "/schedules",
icon: <CalendarIcon className="size-6" />,
},
]
: []),
{
label: "Browsers",
to: "/browser-sessions",

View file

@ -0,0 +1,319 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import {
CRON_PRESETS,
cronToHumanReadable,
formatNextRun,
getLocalTimezone,
getNextRuns,
getTimezones,
isValidCron,
} from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
import { cn } from "@/util/utils";
import { useCreateOrgScheduleMutation } from "./useCreateOrgScheduleMutation";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
function CreateOrgScheduleDialog({ open, onOpenChange }: Readonly<Props>) {
const navigate = useNavigate();
const credentialGetter = useCredentialGetter();
const createMutation = useCreateOrgScheduleMutation();
const [workflowSearch, setWorkflowSearch] = useState("");
const [workflowPickerOpen, setWorkflowPickerOpen] = useState(false);
const [selectedWorkflow, setSelectedWorkflow] =
useState<WorkflowApiResponse | null>(null);
const [cronExpression, setCronExpression] = useState("0 9 * * *");
const [timezone, setTimezone] = useState(getLocalTimezone);
const [timezoneFilter, setTimezoneFilter] = useState<string | null>(null);
const [scheduleName, setScheduleName] = useState("");
const [scheduleDescription, setScheduleDescription] = useState("");
const { data: workflows = [] } = useQuery<Array<WorkflowApiResponse>>({
queryKey: ["workflows", "scheduleDialogPicker", workflowSearch],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
params.append("page", "1");
params.append("page_size", "20");
params.append("only_workflows", "true");
if (workflowSearch) {
params.append("search_key", workflowSearch);
}
return client.get("/workflows", { params }).then((r) => r.data);
},
enabled: open,
});
const allTimezones = useMemo(() => getTimezones(), []);
const filteredTimezones = useMemo(() => {
if (timezoneFilter === null) return allTimezones;
if (!timezoneFilter) return allTimezones;
const lower = timezoneFilter.toLowerCase();
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
}, [allTimezones, timezoneFilter]);
const valid = isValidCron(cronExpression);
const humanReadable = valid ? cronToHumanReadable(cronExpression) : null;
const nextRuns = valid ? getNextRuns(cronExpression, timezone, 5) : [];
function resetForm() {
setWorkflowSearch("");
setWorkflowPickerOpen(false);
setSelectedWorkflow(null);
setCronExpression("0 9 * * *");
setTimezone(getLocalTimezone());
setTimezoneFilter(null);
setScheduleName("");
setScheduleDescription("");
}
function handleSubmit() {
if (!valid || !selectedWorkflow) return;
createMutation.mutate(
{
workflowPermanentId: selectedWorkflow.workflow_permanent_id,
request: {
cron_expression: cronExpression,
timezone,
...(scheduleName && { name: scheduleName }),
...(scheduleDescription && { description: scheduleDescription }),
},
},
{
onSuccess: (data) => {
onOpenChange(false);
resetForm();
navigate(
`/schedules/${selectedWorkflow.workflow_permanent_id}/${data.schedule.workflow_schedule_id}`,
);
},
},
);
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Schedule</DialogTitle>
<DialogDescription>
Choose a workflow and configure when it should run automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Workflow Picker */}
<div className="space-y-2">
<Label>Workflow</Label>
{selectedWorkflow ? (
<div className="flex items-center justify-between rounded-md border border-slate-700 bg-slate-elevation3 px-3 py-2">
<span className="text-sm">{selectedWorkflow.title}</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSelectedWorkflow(null)}
>
Change
</Button>
</div>
) : (
<>
<Input
value={workflowSearch}
onChange={(e) => setWorkflowSearch(e.target.value)}
onFocus={() => setWorkflowPickerOpen(true)}
placeholder="Search workflows..."
/>
{workflowPickerOpen && (
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
{workflows.map((wf) => (
<button
key={wf.workflow_permanent_id}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-slate-700"
onClick={() => {
setSelectedWorkflow(wf);
setWorkflowSearch("");
}}
>
{wf.title}
</button>
))}
{workflows.length === 0 && (
<div className="px-3 py-2 text-sm text-slate-500">
No workflows found
</div>
)}
</div>
)}
</>
)}
</div>
{/* Schedule Name & Description */}
<div className="space-y-2">
<Label>Name (optional)</Label>
<Input
placeholder="Auto-generated if empty"
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input
placeholder="Add a description..."
value={scheduleDescription}
onChange={(e) => setScheduleDescription(e.target.value)}
/>
</div>
{/* Cron Presets */}
<div className="space-y-2">
<Label>Quick Presets</Label>
<div className="flex flex-wrap gap-2">
{CRON_PRESETS.map((preset) => (
<Button
key={preset.label}
variant={
cronExpression === preset.expression
? "default"
: "secondary"
}
size="sm"
onClick={() => setCronExpression(preset.expression)}
>
{preset.label}
</Button>
))}
</div>
</div>
{/* Custom Cron Input */}
<div className="space-y-2">
<Label>Cron Expression</Label>
<Input
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="* * * * *"
className={cn(!valid && cronExpression && "border-destructive")}
/>
{humanReadable && (
<p className="text-sm text-slate-400">{humanReadable}</p>
)}
{!valid && cronExpression && (
<p className="text-sm text-destructive">
Invalid cron expression
</p>
)}
</div>
{/* Timezone Selector */}
<div className="space-y-2">
<Label>Timezone</Label>
<Input
value={timezoneFilter ?? timezone}
onChange={(e) => setTimezoneFilter(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => {
if (
filteredTimezones.length === 1 &&
filteredTimezones[0] !== undefined
) {
setTimezone(filteredTimezones[0]);
}
setTimezoneFilter(null);
}}
placeholder="Search timezones..."
/>
{timezoneFilter !== null && (
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
{filteredTimezones.slice(0, 20).map((tz) => (
<button
key={tz}
type="button"
className={cn(
"w-full px-3 py-1.5 text-left text-sm hover:bg-slate-700",
tz === timezone && "bg-slate-700 text-slate-50",
)}
onMouseDown={(e) => {
e.preventDefault();
setTimezone(tz);
setTimezoneFilter(null);
}}
>
{tz}
</button>
))}
{filteredTimezones.length === 0 && (
<div className="px-3 py-2 text-sm text-slate-500">
No timezones found
</div>
)}
</div>
)}
<p className="text-xs text-slate-500">Current: {timezone}</p>
</div>
{/* Next Runs Preview */}
{nextRuns.length > 0 && (
<div className="space-y-2">
<Label>Next Scheduled Runs</Label>
<div className="space-y-1 rounded-md border border-slate-700 bg-slate-elevation3 p-3">
{nextRuns.map((run) => (
<div
key={run.toISOString()}
className="text-xs text-slate-400"
>
{formatNextRun(run, timezone)}
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
disabled={!valid || !selectedWorkflow || createMutation.isPending}
onClick={handleSubmit}
>
{createMutation.isPending ? "Creating..." : "Create Schedule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { CreateOrgScheduleDialog };

View file

@ -0,0 +1,503 @@
import { useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeftIcon,
Pencil1Icon,
ReloadIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import { useScheduleDetailQuery } from "./useScheduleDetailQuery";
import {
useDeleteOrgScheduleMutation,
useDisableScheduleMutation,
useEnableScheduleMutation,
useUpdateScheduleMutation,
} from "./useScheduleActions";
import {
CRON_PRESETS,
cronToHumanReadable,
formatNextRun,
getNextRuns,
getTimezones,
isValidCron,
} from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
import { cn } from "@/util/utils";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
function ScheduleDetailPage() {
const navigate = useNavigate();
const location = useLocation();
const { workflowPermanentId, scheduleId } = useParams();
const credentialGetter = useCredentialGetter();
const { data, isLoading, isError } = useScheduleDetailQuery(
workflowPermanentId,
scheduleId,
);
const titleFromState = (location.state as { workflowTitle?: string })
?.workflowTitle;
const { data: workflow } = useQuery<WorkflowApiResponse>({
queryKey: ["workflow", workflowPermanentId],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/${workflowPermanentId}`)
.then((r) => r.data);
},
enabled: !!workflowPermanentId && !titleFromState,
});
const workflowTitle =
titleFromState || workflow?.title || workflowPermanentId || "Schedule";
const enableMutation = useEnableScheduleMutation();
const disableMutation = useDisableScheduleMutation();
const deleteMutation = useDeleteOrgScheduleMutation();
const updateMutation = useUpdateScheduleMutation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// Edit mode state
const [editing, setEditing] = useState(false);
const [editCron, setEditCron] = useState("");
const [editTimezone, setEditTimezone] = useState("");
// TODO - Create shared util
const [timezoneFilter, setTimezoneFilter] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const allTimezones = useMemo(() => getTimezones(), []);
const filteredTimezones = useMemo(() => {
if (timezoneFilter === null) return allTimezones;
if (!timezoneFilter) return allTimezones;
const lower = timezoneFilter.toLowerCase();
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
}, [allTimezones, timezoneFilter]);
// ! end TODO
const editValid = isValidCron(editCron);
const editHumanReadable = editValid ? cronToHumanReadable(editCron) : null;
const editNextRuns = editValid ? getNextRuns(editCron, editTimezone, 5) : [];
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<ReloadIcon className="size-6 animate-spin text-slate-400" />
</div>
);
}
if (isError || !data) {
return (
<div className="py-20 text-center text-sm text-red-400">
Failed to load schedule details.
</div>
);
}
const { schedule, next_runs } = data;
const humanReadable = cronToHumanReadable(schedule.cron_expression);
function startEditing() {
setEditCron(schedule.cron_expression);
setEditTimezone(schedule.timezone);
setTimezoneFilter(null);
setEditName(schedule.name ?? "");
setEditDescription(schedule.description ?? "");
setEditing(true);
}
function cancelEditing() {
setEditing(false);
setTimezoneFilter(null);
}
function handleSave() {
if (!editValid || !workflowPermanentId || !scheduleId) return;
updateMutation.mutate(
{
workflowPermanentId,
scheduleId,
request: {
cron_expression: editCron,
timezone: editTimezone,
enabled: schedule.enabled,
parameters: schedule.parameters,
...(editName && { name: editName }),
description: editDescription || undefined,
},
},
{
onSuccess: () => setEditing(false),
},
);
}
function handleToggle(checked: boolean) {
const item = {
workflow_schedule_id: schedule.workflow_schedule_id,
organization_id: schedule.organization_id,
workflow_permanent_id: schedule.workflow_permanent_id,
workflow_title: "",
cron_expression: schedule.cron_expression,
timezone: schedule.timezone,
enabled: schedule.enabled,
parameters: schedule.parameters,
name: schedule.name ?? null,
description: schedule.description ?? null,
next_run: null,
created_at: schedule.created_at,
modified_at: schedule.modified_at,
};
if (checked) {
enableMutation.mutate(item);
} else {
disableMutation.mutate(item);
}
}
function handleDelete() {
const item = {
workflow_schedule_id: schedule.workflow_schedule_id,
organization_id: schedule.organization_id,
workflow_permanent_id: schedule.workflow_permanent_id,
workflow_title: "",
cron_expression: schedule.cron_expression,
timezone: schedule.timezone,
enabled: schedule.enabled,
parameters: schedule.parameters,
name: schedule.name ?? null,
description: schedule.description ?? null,
next_run: null,
created_at: schedule.created_at,
modified_at: schedule.modified_at,
};
deleteMutation.mutate(item, {
onSuccess: () => navigate("/schedules"),
});
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="size-9"
onClick={() => navigate("/schedules")}
>
<ArrowLeftIcon className="size-4" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-normal text-slate-50">
{schedule.name ?? workflowTitle}
</h1>
{schedule.description && (
<p className="text-sm text-slate-400">{schedule.description}</p>
)}
<p className="text-xs text-slate-500">
{humanReadable} · {schedule.timezone}
</p>
</div>
<Switch checked={schedule.enabled} onCheckedChange={handleToggle} />
<Button
variant="destructive"
size="icon"
className="size-9"
onClick={() => setDeleteDialogOpen(true)}
>
<TrashIcon className="size-4" />
</Button>
</div>
{/* Content grid */}
<div className="grid grid-cols-2 gap-6">
{/* Schedule Configuration */}
<div className="rounded-lg border border-slate-700 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm text-slate-400">Schedule Configuration</h3>
{!editing && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs"
onClick={startEditing}
>
<Pencil1Icon className="size-3" />
Edit
</Button>
)}
</div>
{editing ? (
<div className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Schedule name"
className="h-8 text-sm"
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Input
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Add a description..."
className="h-8 text-sm"
/>
</div>
{/* Cron Presets */}
<div className="space-y-2">
<Label className="text-xs">Quick Presets</Label>
<div className="flex flex-wrap gap-1.5">
{CRON_PRESETS.map((preset) => (
<Button
key={preset.label}
variant={
editCron === preset.expression ? "default" : "secondary"
}
size="sm"
className="h-6 text-xs"
onClick={() => setEditCron(preset.expression)}
>
{preset.label}
</Button>
))}
</div>
</div>
{/* Cron Expression */}
<div className="space-y-1.5">
<Label className="text-xs">Cron Expression</Label>
<Input
value={editCron}
onChange={(e) => setEditCron(e.target.value)}
placeholder="* * * * *"
className={cn(
"h-8 text-sm",
!editValid && editCron && "border-destructive",
)}
/>
{editHumanReadable && (
<p className="text-xs text-slate-400">{editHumanReadable}</p>
)}
{!editValid && editCron && (
<p className="text-xs text-destructive">
Invalid cron expression
</p>
)}
</div>
{/* Timezone */}
<div className="space-y-1.5">
<Label className="text-xs">Timezone</Label>
<Input
value={timezoneFilter ?? editTimezone}
onChange={(e) => setTimezoneFilter(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => {
if (
filteredTimezones.length === 1 &&
filteredTimezones[0] !== undefined
) {
setEditTimezone(filteredTimezones[0]);
}
setTimezoneFilter(null);
}}
placeholder="Search timezones..."
className="h-8 text-sm"
/>
{timezoneFilter !== null && (
<div className="max-h-32 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
{filteredTimezones.slice(0, 15).map((tz) => (
<button
key={tz}
type="button"
className={cn(
"w-full px-3 py-1 text-left text-xs hover:bg-slate-700",
tz === editTimezone && "bg-slate-700 text-slate-50",
)}
onMouseDown={(e) => {
e.preventDefault();
setEditTimezone(tz);
setTimezoneFilter(null);
}}
>
{tz}
</button>
))}
{filteredTimezones.length === 0 && (
<div className="px-3 py-1.5 text-xs text-slate-500">
No timezones found
</div>
)}
</div>
)}
<p className="text-xs text-slate-500">
Current: {editTimezone}
</p>
</div>
{/* Preview next runs */}
{editNextRuns.length > 0 && (
<div className="space-y-1">
<Label className="text-xs">Next Runs Preview</Label>
<div className="space-y-0.5">
{editNextRuns.map((run) => (
<p
key={run.toISOString()}
className="text-xs text-slate-500"
>
{formatNextRun(run, editTimezone)}
</p>
))}
</div>
</div>
)}
{/* Save / Cancel */}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="h-7 text-xs"
disabled={!editValid || updateMutation.isPending}
onClick={handleSave}
>
{updateMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={cancelEditing}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-start justify-between">
<span className="text-sm text-slate-400">Frequency</span>
<span className="text-sm text-slate-50">{humanReadable}</span>
</div>
<div className="flex items-start justify-between">
<span className="text-sm text-slate-400">Timezone</span>
<span className="text-sm text-slate-50">
{schedule.timezone}
</span>
</div>
<div className="flex items-start justify-between">
<span className="text-sm text-slate-400">Cron</span>
<code className="font-mono text-xs text-slate-50">
{schedule.cron_expression}
</code>
</div>
</div>
)}
</div>
{/* Details + Upcoming Runs */}
<div className="space-y-6">
<div className="rounded-lg border border-slate-700 p-4">
<h3 className="mb-4 text-sm text-slate-400">Details</h3>
<div className="space-y-2">
<div className="flex items-start justify-between">
<span className="text-sm text-slate-400">Created</span>
<span
className="text-sm text-slate-50"
title={basicTimeFormat(schedule.created_at)}
>
{basicLocalTimeFormat(schedule.created_at)}
</span>
</div>
<div className="flex items-start justify-between">
<span className="text-sm text-slate-400">Last Modified</span>
<span
className="text-sm text-slate-50"
title={basicTimeFormat(schedule.modified_at)}
>
{basicLocalTimeFormat(schedule.modified_at)}
</span>
</div>
</div>
</div>
<div className="rounded-lg border border-slate-700 p-4">
<h3 className="mb-4 text-sm text-slate-400">Upcoming Runs</h3>
<p className="mb-2 text-xs text-slate-400">
Next {next_runs.length} runs
</p>
<div className="space-y-0.5">
{next_runs.map((run) => (
<p key={run} className="text-xs text-slate-500">
{formatNextRun(new Date(run), schedule.timezone)}
</p>
))}
</div>
</div>
</div>
</div>
<Dialog
open={deleteDialogOpen}
onOpenChange={(open) => {
if (!open && !deleteMutation.isPending) {
setDeleteDialogOpen(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Schedule</DialogTitle>
<DialogDescription>
Are you sure you want to delete this schedule? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="secondary"
disabled={deleteMutation.isPending}
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={handleDelete}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export { ScheduleDetailPage };

View file

@ -0,0 +1,9 @@
import { Navigate } from "react-router-dom";
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
import { ScheduleDetailPage } from "@/routes/schedules/ScheduleDetailPage";
export function ScheduleDetailRoute() {
const enabled = useFeatureFlag("WORKFLOW_SCHEDULES");
if (enabled) return <ScheduleDetailPage />;
return <Navigate to="/workflows" replace />;
}

View file

@ -0,0 +1,606 @@
import { useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useDebounce } from "use-debounce";
import {
ChevronDownIcon,
CopyIcon,
DotsHorizontalIcon,
PauseIcon,
PlayIcon,
PlusIcon,
ReloadIcon,
TrashIcon,
} from "@radix-ui/react-icons";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TableSearchInput } from "@/components/TableSearchInput";
import { useOrganizationSchedulesQuery } from "./useOrganizationSchedulesQuery";
import {
useDeleteOrgScheduleMutation,
useDisableScheduleMutation,
useDuplicateScheduleMutation,
useEnableScheduleMutation,
} from "./useScheduleActions";
import { cronToHumanReadable } from "@/routes/workflows/editor/panels/schedulePanel/cronUtils";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
import type { OrganizationScheduleItem } from "@/routes/workflows/types/scheduleTypes";
import { CreateOrgScheduleDialog } from "./CreateOrgScheduleDialog";
type ScheduleStatus = "active" | "paused";
const STATUS_OPTIONS: Array<{ label: string; value: ScheduleStatus }> = [
{ label: "Active", value: "active" },
{ label: "Paused", value: "paused" },
];
function StatusDisplay({ enabled }: Readonly<{ enabled: boolean }>) {
if (enabled) {
return (
<div className="flex items-center gap-1.5">
<span className="size-4 text-green-400">
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="8" cy="8" r="4" fill="currentColor" />
</svg>
</span>
<span className="text-sm capitalize text-slate-300">active</span>
</div>
);
}
return (
<div className="flex items-center gap-1.5">
<span className="size-4 text-amber-400">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="4" fill="currentColor" />
</svg>
</span>
<span className="text-sm capitalize text-slate-300">paused</span>
</div>
);
}
const PAGE_SIZE_OPTIONS = ["10", "25", "50"];
function SchedulesPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("page_size") || "10");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const [statusFilters, setStatusFilters] = useState<ScheduleStatus[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const lastSelectedIndex = useRef<number | null>(null);
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
schedule: OrganizationScheduleItem | null;
}>({ open: false, schedule: null });
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const statusFilter =
statusFilters.length === 1 ? statusFilters[0] : undefined;
const { data, isLoading, isError, error } = useOrganizationSchedulesQuery({
page,
pageSize,
statusFilter,
search: debouncedSearch || undefined,
});
const enableMutation = useEnableScheduleMutation();
const disableMutation = useDisableScheduleMutation();
const deleteMutation = useDeleteOrgScheduleMutation();
const duplicateMutation = useDuplicateScheduleMutation();
const schedules = data?.schedules ?? [];
const totalCount = data?.total_count ?? 0;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const allSelected =
schedules.length > 0 &&
schedules.every((s) => selected.has(s.workflow_schedule_id));
function toggleSelectAll() {
if (allSelected) {
setSelected(new Set());
} else {
setSelected(new Set(schedules.map((s) => s.workflow_schedule_id)));
}
}
function handleSelect(index: number, shiftKey: boolean) {
const id = schedules[index]!.workflow_schedule_id;
if (shiftKey && lastSelectedIndex.current !== null) {
const start = Math.min(lastSelectedIndex.current, index);
const end = Math.max(lastSelectedIndex.current, index);
setSelected((prev) => {
const next = new Set(prev);
for (let i = start; i <= end; i++) {
next.add(schedules[i]!.workflow_schedule_id);
}
return next;
});
} else {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
lastSelectedIndex.current = index;
}
function setPage(p: number) {
const params = new URLSearchParams(searchParams);
params.set("page", String(p));
setSearchParams(params);
setSelected(new Set());
}
function setPageSize(size: string) {
const params = new URLSearchParams(searchParams);
params.set("page_size", size);
params.set("page", "1");
setSearchParams(params);
setSelected(new Set());
}
function handleDeleteConfirm() {
if (!deleteDialog.schedule) return;
deleteMutation.mutate(deleteDialog.schedule, {
onSettled: () => {
setDeleteDialog({ open: false, schedule: null });
setSelected((prev) => {
const next = new Set(prev);
next.delete(deleteDialog.schedule!.workflow_schedule_id);
return next;
});
},
});
}
const selectedSchedules = schedules.filter((s) =>
selected.has(s.workflow_schedule_id),
);
function handleBulkActivate() {
selectedSchedules
.filter((s) => !s.enabled)
.forEach((s) => enableMutation.mutate(s));
setSelected(new Set());
}
function handleBulkPause() {
selectedSchedules
.filter((s) => s.enabled)
.forEach((s) => disableMutation.mutate(s));
setSelected(new Set());
}
function handleBulkDuplicate() {
selectedSchedules.forEach((s) => duplicateMutation.mutate(s));
setSelected(new Set());
}
function handleBulkDelete() {
selectedSchedules.forEach((s) => deleteMutation.mutate(s));
setSelected(new Set());
}
const showCheckbox = schedules.length > 1;
const columnCount = showCheckbox ? 7 : 6;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl">Schedules</h1>
</div>
{/* Search + Filters + Create */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<TableSearchInput
value={search}
onChange={setSearch}
placeholder="Search by workflow or schedule name..."
className="w-64"
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Filter by Status
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{STATUS_OPTIONS.map((opt) => (
<div
key={opt.value}
className="flex items-center gap-2 p-2 text-sm"
>
<Checkbox
id={`schedule-status-${opt.value}`}
checked={statusFilters.includes(opt.value)}
onCheckedChange={(checked) => {
setStatusFilters((prev) =>
checked
? [...prev, opt.value]
: prev.filter((f) => f !== opt.value),
);
setPage(1);
}}
/>
<label htmlFor={`schedule-status-${opt.value}`}>
{opt.label}
</label>
</div>
))}
{statusFilters.length > 0 && (
<button
type="button"
className="w-full cursor-pointer p-2 text-left text-sm text-slate-400 hover:text-slate-200"
onClick={() => {
setStatusFilters([]);
setPage(1);
}}
>
Clear all
</button>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<PlusIcon className="mr-1.5 size-4" />
Create Schedule
</Button>
</div>
{/* Table */}
<div className="space-y-4">
<div className="overflow-hidden rounded-lg border border-slate-700">
<Table className="table-fixed">
<TableHeader className="bg-slate-elevation2 text-slate-400 [&_tr]:border-b-0">
<TableRow>
{showCheckbox && (
<TableHead className="w-[3%]">
<Checkbox
checked={allSelected}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
)}
<TableHead className={showCheckbox ? "w-[28%]" : "w-[31%]"}>
Workflow
</TableHead>
<TableHead className="w-[20%]">Name</TableHead>
<TableHead className="w-[20%]">Schedule</TableHead>
<TableHead className="w-[17%]">Next Run</TableHead>
<TableHead className="w-[7%]">Status</TableHead>
<TableHead className="w-[5%]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={columnCount} className="py-8 text-center">
<ReloadIcon className="mx-auto size-5 animate-spin text-slate-400" />
</TableCell>
</TableRow>
)}
{isError && (
<TableRow>
<TableCell
colSpan={columnCount}
className="py-8 text-center text-sm text-red-400"
>
Failed to load schedules.
{error?.message && (
<span className="block text-xs text-slate-500">
{error.message}
</span>
)}
</TableCell>
</TableRow>
)}
{!isLoading && !isError && schedules.length === 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="py-8 text-center text-sm text-slate-500"
>
No schedules found.
</TableCell>
</TableRow>
)}
{schedules.map((schedule, index) => (
<TableRow
key={schedule.workflow_schedule_id}
className="cursor-pointer select-none hover:bg-muted/50"
onClick={() =>
navigate(
`/schedules/${schedule.workflow_permanent_id}/${schedule.workflow_schedule_id}`,
{ state: { workflowTitle: schedule.workflow_title } },
)
}
>
{showCheckbox && (
<TableCell
onClick={(e) => {
e.stopPropagation();
handleSelect(index, e.shiftKey);
}}
>
<Checkbox
checked={selected.has(schedule.workflow_schedule_id)}
className="pointer-events-none"
/>
</TableCell>
)}
<TableCell className="truncate font-medium">
{schedule.workflow_title}
</TableCell>
<TableCell className="truncate text-slate-400">
{schedule.name ?? "\u2014"}
</TableCell>
<TableCell className="text-slate-400">
{cronToHumanReadable(schedule.cron_expression)}
</TableCell>
<TableCell className="text-slate-400">
{schedule.next_run ? (
<span title={basicTimeFormat(schedule.next_run)}>
{basicLocalTimeFormat(schedule.next_run)}
</span>
) : (
"\u2014"
)}
</TableCell>
<TableCell>
<StatusDisplay enabled={schedule.enabled} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
>
<DotsHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{schedule.enabled ? (
<DropdownMenuItem
onSelect={() => disableMutation.mutate(schedule)}
>
<PauseIcon className="mr-2 size-4" />
Pause
</DropdownMenuItem>
) : (
<DropdownMenuItem
onSelect={() => enableMutation.mutate(schedule)}
>
<PlayIcon className="mr-2 size-4" />
Activate
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => duplicateMutation.mutate(schedule)}
>
<CopyIcon className="mr-2 size-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
setDeleteDialog({ open: true, schedule })
}
className="text-destructive"
>
<TrashIcon className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination with Items per page */}
<div className="grid grid-cols-3 items-center px-4 py-2">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-sm text-slate-400">
Items per page
</span>
<Select value={String(pageSize)} onValueChange={setPageSize}>
<SelectTrigger className="w-[65px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={size}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="justify-self-center">
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage(Math.max(1, page - 1))}
className={
page <= 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(p) => (
<PaginationItem key={p}>
<PaginationLink
onClick={() => setPage(p)}
isActive={p === page}
className="cursor-pointer"
>
{p}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
onClick={() => setPage(Math.min(totalPages, page + 1))}
className={
page >= totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</div>
</div>
</div>
{/* Multi-select bulk action bar */}
{selected.size > 0 && (
<div className="fixed inset-x-0 bottom-6 mx-auto flex w-fit items-center gap-3 rounded-lg border border-slate-700 bg-slate-900 px-6 py-3 shadow-xl">
<span className="text-sm text-slate-300">
{selected.size} selected
</span>
<div className="h-6 w-px bg-slate-700" />
<Button
size="sm"
className="bg-green-900 text-green-50 hover:bg-green-800"
onClick={handleBulkActivate}
>
<PlayIcon className="mr-1.5 size-3.5" />
Activate
</Button>
<Button
size="sm"
className="bg-amber-800 text-amber-50 hover:bg-amber-700"
onClick={handleBulkPause}
>
<PauseIcon className="mr-1.5 size-3.5" />
Pause
</Button>
<Button
size="sm"
className="bg-blue-800 text-blue-50 hover:bg-blue-700"
onClick={handleBulkDuplicate}
>
<CopyIcon className="mr-1.5 size-3.5" />
Duplicate
</Button>
<Button
size="sm"
className="bg-red-900 text-red-50 hover:bg-red-800"
onClick={handleBulkDelete}
>
<TrashIcon className="mr-1.5 size-3.5" />
Delete
</Button>
</div>
)}
{/* Delete confirmation dialog */}
<Dialog
open={deleteDialog.open}
onOpenChange={(open) => {
if (!open && !deleteMutation.isPending) {
setDeleteDialog({ open: false, schedule: null });
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Schedule</DialogTitle>
<DialogDescription>
Are you sure you want to delete this schedule? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="secondary"
disabled={deleteMutation.isPending}
onClick={() => setDeleteDialog({ open: false, schedule: null })}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={handleDeleteConfirm}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Schedule Dialog */}
<CreateOrgScheduleDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
);
}
export { SchedulesPage };

View file

@ -0,0 +1,9 @@
import { Navigate } from "react-router-dom";
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
import { SchedulesPage } from "@/routes/schedules/SchedulesPage";
export function SchedulesRoute() {
const enabled = useFeatureFlag("WORKFLOW_SCHEDULES");
if (enabled) return <SchedulesPage />;
return <Navigate to="/workflows" replace />;
}

View file

@ -0,0 +1,47 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import type {
CreateScheduleRequest,
WorkflowScheduleResponse,
} from "@/routes/workflows/types/scheduleTypes";
type CreateOrgScheduleParams = {
workflowPermanentId: string;
request: CreateScheduleRequest;
};
function useCreateOrgScheduleMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
workflowPermanentId,
request,
}: CreateOrgScheduleParams) => {
const client = await getClient(credentialGetter);
const response = await client.post<WorkflowScheduleResponse>(
`/workflows/${workflowPermanentId}/schedules`,
request,
);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
toast({ title: "Schedule created", variant: "success" });
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to create schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
export { useCreateOrgScheduleMutation };

View file

@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type { OrganizationScheduleListResponse } from "@/routes/workflows/types/scheduleTypes";
function useOrganizationSchedulesQuery(opts: {
page: number;
pageSize: number;
statusFilter?: string;
search?: string;
}) {
const credentialGetter = useCredentialGetter();
const params = new URLSearchParams();
params.set("page", String(opts.page));
params.set("page_size", String(opts.pageSize));
if (opts.statusFilter) {
params.set("status", opts.statusFilter);
}
if (opts.search) {
params.set("search", opts.search);
}
return useQuery<OrganizationScheduleListResponse>({
queryKey: [
"organizationSchedules",
opts.page,
opts.pageSize,
opts.statusFilter,
opts.search,
],
queryFn: async () => {
const client = await getClient(credentialGetter);
const response = await client.get<OrganizationScheduleListResponse>(
`/schedules?${params.toString()}`,
);
return response.data;
},
staleTime: 30_000,
});
}
export { useOrganizationSchedulesQuery };

View file

@ -0,0 +1,169 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import type {
OrganizationScheduleItem,
UpdateScheduleRequest,
WorkflowScheduleResponse,
} from "@/routes/workflows/types/scheduleTypes";
function useEnableScheduleMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (schedule: OrganizationScheduleItem) => {
const client = await getClient(credentialGetter);
await client.post(
`/workflows/${schedule.workflow_permanent_id}/schedules/${schedule.workflow_schedule_id}/enable`,
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
toast({ title: "Schedule activated", variant: "success" });
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to activate schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
function useDisableScheduleMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (schedule: OrganizationScheduleItem) => {
const client = await getClient(credentialGetter);
await client.post(
`/workflows/${schedule.workflow_permanent_id}/schedules/${schedule.workflow_schedule_id}/disable`,
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
toast({ title: "Schedule paused", variant: "success" });
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to pause schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
function useDeleteOrgScheduleMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (schedule: OrganizationScheduleItem) => {
const client = await getClient(credentialGetter);
await client.delete(
`/workflows/${schedule.workflow_permanent_id}/schedules/${schedule.workflow_schedule_id}`,
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
toast({ title: "Schedule deleted", variant: "success" });
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to delete schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
function useDuplicateScheduleMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (schedule: OrganizationScheduleItem) => {
const client = await getClient(credentialGetter);
await client.post(
`/workflows/${schedule.workflow_permanent_id}/schedules`,
{
cron_expression: schedule.cron_expression,
timezone: schedule.timezone,
enabled: schedule.enabled,
parameters: schedule.parameters,
name: `${schedule.name ?? schedule.workflow_title} (copy)`,
},
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
toast({ title: "Schedule duplicated", variant: "success" });
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to duplicate schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
function useUpdateScheduleMutation() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
workflowPermanentId,
scheduleId,
request,
}: {
workflowPermanentId: string;
scheduleId: string;
request: UpdateScheduleRequest;
}) => {
const client = await getClient(credentialGetter);
const response = await client.put<WorkflowScheduleResponse>(
`/workflows/${workflowPermanentId}/schedules/${scheduleId}`,
request,
);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["organizationSchedules"] });
queryClient.invalidateQueries({ queryKey: ["scheduleDetail"] });
toast({ title: "Schedule updated", variant: "success" });
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to update schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
export {
useEnableScheduleMutation,
useDisableScheduleMutation,
useDeleteOrgScheduleMutation,
useDuplicateScheduleMutation,
useUpdateScheduleMutation,
};

View file

@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type { WorkflowScheduleResponse } from "@/routes/workflows/types/scheduleTypes";
function useScheduleDetailQuery(
workflowPermanentId: string | undefined,
scheduleId: string | undefined,
) {
const credentialGetter = useCredentialGetter();
return useQuery<WorkflowScheduleResponse>({
queryKey: ["scheduleDetail", workflowPermanentId, scheduleId],
queryFn: async () => {
const client = await getClient(credentialGetter);
const response = await client.get<WorkflowScheduleResponse>(
`/workflows/${workflowPermanentId}/schedules/${scheduleId}`,
);
return response.data;
},
enabled: !!workflowPermanentId && !!scheduleId,
staleTime: 30_000,
});
}
export { useScheduleDetailQuery };

View file

@ -13,6 +13,13 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@ -66,7 +73,8 @@ function WorkflowPage() {
const [statusFilters, setStatusFilters] = useState<Array<Status>>([]);
const navigate = useNavigate();
const PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = ["10", "25", "50"];
const pageSize = Number(searchParams.get("page_size") || "10");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
const [openRunParams, setOpenRunParams] = useState<string | null>(null);
@ -82,6 +90,7 @@ function WorkflowPage() {
workflowPermanentId,
statusFilters,
page,
pageSize,
search: debouncedSearch,
refetchOnMount: "always",
});
@ -310,43 +319,68 @@ function WorkflowPage() {
workflowPermanentId={workflowPermanentId}
workflowRunId={openRunParams}
/>
<Pagination className="pt-2">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
className={cn({ "cursor-not-allowed": page === 1 })}
onClick={() => {
if (page === 1) {
return;
}
const params = new URLSearchParams();
params.set("page", String(Math.max(1, page - 1)));
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink>{page}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
className={cn({
"cursor-not-allowed":
workflowRuns !== undefined &&
workflowRuns.length < PAGE_SIZE,
})}
onClick={() => {
if (workflowRuns && workflowRuns.length < PAGE_SIZE) {
return;
}
const params = new URLSearchParams();
params.set("page", String(page + 1));
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">Items per page</span>
<Select
value={String(pageSize)}
onValueChange={(size) => {
const params = new URLSearchParams(searchParams);
params.set("page_size", size);
params.set("page", "1");
setSearchParams(params, { replace: true });
}}
>
<SelectTrigger className="w-[65px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={size}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
className={cn({ "cursor-not-allowed": page === 1 })}
onClick={() => {
if (page === 1) {
return;
}
const params = new URLSearchParams(searchParams);
params.set("page", String(Math.max(1, page - 1)));
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink>{page}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
className={cn({
"cursor-not-allowed":
workflowRuns !== undefined &&
workflowRuns.length < pageSize,
})}
onClick={() => {
if (workflowRuns && workflowRuns.length < pageSize) {
return;
}
const params = new URLSearchParams(searchParams);
params.set("page", String(page + 1));
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</div>
</div>

View file

@ -3,6 +3,7 @@ import {
BookmarkIcon,
ChevronDownIcon,
ChevronUpIcon,
ClockIcon,
CodeIcon,
CopyIcon,
PlayIcon,
@ -36,6 +37,7 @@ import { useDebugStore } from "@/store/useDebugStore";
import { useRecordingStore } from "@/store/useRecordingStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
import { cn } from "@/util/utils";
import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes";
@ -50,6 +52,7 @@ type Props = {
isGeneratingCode?: boolean;
isTemplate?: boolean;
parametersPanelOpen: boolean;
schedulesPanelOpen: boolean;
saving: boolean;
showAllCode: boolean;
onCacheKeyValueAccept: (cacheKeyValue: string | null) => void;
@ -57,6 +60,7 @@ type Props = {
onCacheKeyValuesFilter: (cacheKeyValue: string) => void;
onCacheKeyValuesKeydown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onParametersClick: () => void;
onScheduleClick: () => void;
onShowAllCodeClick?: () => void;
onCacheKeyValuesClick: () => void;
onSave: () => void;
@ -71,6 +75,7 @@ function WorkflowHeader({
isGeneratingCode,
isTemplate,
parametersPanelOpen,
schedulesPanelOpen,
saving,
showAllCode,
onCacheKeyValueAccept,
@ -78,12 +83,13 @@ function WorkflowHeader({
onCacheKeyValuesFilter,
onCacheKeyValuesKeydown,
onParametersClick,
onScheduleClick,
onShowAllCodeClick,
onCacheKeyValuesClick,
onSave,
onRun,
onHistory,
}: Props) {
}: Readonly<Props>) {
const { title, setTitle } = useWorkflowTitleStore();
const workflowChangesStore = useWorkflowHasChangesStore();
const { workflowPermanentId } = useParams();
@ -93,6 +99,7 @@ function WorkflowHeader({
const { data: workflowRun } = useWorkflowRunQuery();
const debugStore = useDebugStore();
const recordingStore = useRecordingStore();
const schedulesEnabled = useFeatureFlag("WORKFLOW_SCHEDULES");
const workflowRunIsRunningOrQueued =
workflowRun && statusIsRunningOrQueued(workflowRun);
const [chosenCacheKeyValue, setChosenCacheKeyValue] = useState<string | null>(
@ -393,6 +400,22 @@ function WorkflowHeader({
</Tooltip>
</TooltipProvider>
)}
{schedulesEnabled && (
<Button
disabled={isRecording}
variant="tertiary"
size="lg"
onClick={onScheduleClick}
>
<ClockIcon className="mr-2 h-5 w-5" />
<span className="mr-2">Schedule</span>
{schedulesPanelOpen ? (
<ChevronUpIcon className="h-6 w-6" />
) : (
<ChevronDownIcon className="h-6 w-6" />
)}
</Button>
)}
<Button
disabled={isRecording}
variant="tertiary"

View file

@ -94,6 +94,7 @@ import {
} from "./workflowEditorUtils";
import { WorkflowHeader } from "./WorkflowHeader";
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
import { WorkflowSchedulePanel } from "./panels/schedulePanel/WorkflowSchedulePanel";
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
import { WorkflowSettings } from "../types/workflowTypes";
import { ProxyLocation } from "@/api/types";
@ -1222,6 +1223,10 @@ function Workspace({
workflowPanelState.active &&
workflowPanelState.content === "parameters"
}
schedulesPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "schedules"
}
showAllCode={showAllCode}
onCacheKeyValueAccept={(v) => {
setExplicitCacheKeyValue(v ?? "");
@ -1260,6 +1265,19 @@ function Workspace({
});
}
}}
onScheduleClick={() => {
if (
workflowPanelState.active &&
workflowPanelState.content === "schedules"
) {
closeWorkflowPanel();
} else {
setWorkflowPanelState({
active: true,
content: "schedules",
});
}
}}
onSave={async () => await handleOnSave()}
onRun={() => {
closeWorkflowPanel();
@ -1333,6 +1351,11 @@ function Workspace({
<WorkflowParametersPanel />
</div>
)}
{workflowPanelState.content === "schedules" && (
<div className="z-30">
<WorkflowSchedulePanel />
</div>
)}
{workflowPanelState.content === "history" && (
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
<WorkflowHistoryPanel
@ -1408,6 +1431,11 @@ function Workspace({
<WorkflowParametersPanel />
</div>
)}
{workflowPanelState.content === "schedules" && (
<div className="z-30">
<WorkflowSchedulePanel />
</div>
)}
{workflowPanelState.content === "history" && (
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
<WorkflowHistoryPanel
@ -1462,6 +1490,9 @@ function Workspace({
{workflowPanelState.content === "parameters" && (
<WorkflowParametersPanel />
)}
{workflowPanelState.content === "schedules" && (
<WorkflowSchedulePanel />
)}
{workflowPanelState.content === "history" && (
<div className="h-[calc(100vh-14rem)]">
<WorkflowHistoryPanel

View file

@ -0,0 +1,226 @@
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PlusIcon } from "@radix-ui/react-icons";
import {
CRON_PRESETS,
cronToHumanReadable,
isValidCron,
getNextRuns,
formatNextRun,
getTimezones,
getLocalTimezone,
} from "./cronUtils";
import { cn } from "@/util/utils";
type Props = {
onSubmit: (
cronExpression: string,
timezone: string,
name: string,
description: string,
callbacks: { onSuccess: () => void },
) => void;
isPending?: boolean;
};
function CreateScheduleDialog({ onSubmit, isPending }: Props) {
const [open, setOpen] = useState(false);
const [cronExpression, setCronExpression] = useState("0 9 * * *");
const [timezone, setTimezone] = useState(getLocalTimezone);
// TODO - Create shared util
const [timezoneFilter, setTimezoneFilter] = useState<string | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const allTimezones = useMemo(() => getTimezones(), []);
const filteredTimezones = useMemo(() => {
if (timezoneFilter === null) return allTimezones;
if (!timezoneFilter) return allTimezones;
const lower = timezoneFilter.toLowerCase();
return allTimezones.filter((tz) => tz.toLowerCase().includes(lower));
}, [allTimezones, timezoneFilter]);
// ! end TODO
const valid = isValidCron(cronExpression);
const humanReadable = valid ? cronToHumanReadable(cronExpression) : null;
const nextRuns = valid ? getNextRuns(cronExpression, timezone, 5) : [];
const handleSubmit = () => {
if (!valid) return;
onSubmit(cronExpression, timezone, name, description, {
onSuccess: () => {
setOpen(false);
setCronExpression("0 9 * * *");
setTimezone(getLocalTimezone());
setTimezoneFilter(null);
setName("");
setDescription("");
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5">
<PlusIcon className="size-3" />
Add
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Schedule</DialogTitle>
<DialogDescription>
Configure when this workflow should run automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Schedule Name & Description */}
<div className="space-y-2">
<Label>Name (optional)</Label>
<Input
placeholder="Auto-generated if empty"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input
placeholder="Add a description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* Cron Presets */}
<div className="space-y-2">
<Label>Quick Presets</Label>
<div className="flex flex-wrap gap-2">
{CRON_PRESETS.map((preset) => (
<Button
key={preset.label}
variant={
cronExpression === preset.expression
? "default"
: "secondary"
}
size="sm"
onClick={() => setCronExpression(preset.expression)}
>
{preset.label}
</Button>
))}
</div>
</div>
{/* Custom Cron Input */}
<div className="space-y-2">
<Label>Cron Expression</Label>
<Input
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="* * * * *"
className={cn(!valid && cronExpression && "border-destructive")}
/>
{humanReadable && (
<p className="text-sm text-slate-400">{humanReadable}</p>
)}
{!valid && cronExpression && (
<p className="text-sm text-destructive">
Invalid cron expression
</p>
)}
</div>
{/* Timezone Selector */}
<div className="space-y-2">
<Label>Timezone</Label>
<Input
value={timezoneFilter ?? timezone}
onChange={(e) => setTimezoneFilter(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => {
if (
filteredTimezones.length === 1 &&
filteredTimezones[0] !== undefined
) {
setTimezone(filteredTimezones[0]);
}
setTimezoneFilter(null);
}}
placeholder="Search timezones..."
/>
{timezoneFilter !== null && (
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-700 bg-slate-elevation3">
{filteredTimezones.slice(0, 20).map((tz) => (
<button
key={tz}
type="button"
className={cn(
"w-full px-3 py-1.5 text-left text-sm hover:bg-slate-700",
tz === timezone && "bg-slate-700 text-slate-50",
)}
onMouseDown={(e) => {
e.preventDefault();
setTimezone(tz);
setTimezoneFilter(null);
}}
>
{tz}
</button>
))}
{filteredTimezones.length === 0 && (
<div className="px-3 py-2 text-sm text-slate-500">
No timezones found
</div>
)}
</div>
)}
<p className="text-xs text-slate-500">Current: {timezone}</p>
</div>
{/* Next Runs Preview */}
{nextRuns.length > 0 && (
<div className="space-y-2">
<Label>Next Scheduled Runs</Label>
<div className="space-y-1 rounded-md border border-slate-700 bg-slate-elevation3 p-3">
{nextRuns.map((run) => (
<div
key={run.toISOString()}
className="text-xs text-slate-400"
>
{formatNextRun(run, timezone)}
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button disabled={!valid || isPending} onClick={handleSubmit}>
{isPending ? "Creating..." : "Create Schedule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { CreateScheduleDialog };

View file

@ -0,0 +1,68 @@
import { TrashIcon } from "@radix-ui/react-icons";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import type { WorkflowSchedule } from "@/routes/workflows/types/scheduleTypes";
import { cronToHumanReadable, formatNextRun, getNextRuns } from "./cronUtils";
import { cn } from "@/util/utils";
type Props = {
schedule: WorkflowSchedule;
isToggling?: boolean;
onToggle: (scheduleId: string, enabled: boolean) => void;
onDelete: (scheduleId: string) => void;
};
function ScheduleCard({ schedule, isToggling, onToggle, onDelete }: Props) {
const humanReadable = cronToHumanReadable(schedule.cron_expression);
const nextRuns = getNextRuns(schedule.cron_expression, schedule.timezone, 1);
const nextRun = nextRuns[0];
return (
<div className="flex flex-col gap-2 rounded-md border border-slate-700 px-3.5 pb-0.5 pt-3.5">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-0.5">
{schedule.name && (
<span className="text-sm font-medium text-slate-50">
{schedule.name}
</span>
)}
<span
className={cn(
"text-sm",
schedule.name ? "text-slate-400" : "text-slate-50",
)}
>
{humanReadable}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">{schedule.timezone}</span>
<div className="flex items-center gap-2">
<Switch
checked={schedule.enabled}
disabled={isToggling}
onCheckedChange={(checked) =>
onToggle(schedule.workflow_schedule_id, checked)
}
/>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => onDelete(schedule.workflow_schedule_id)}
>
<TrashIcon className="size-4 text-destructive" />
</Button>
</div>
</div>
{nextRun && (
<div className="text-xs text-slate-500">
Next: {formatNextRun(nextRun, schedule.timezone)}
</div>
)}
</div>
);
}
export { ScheduleCard };

View file

@ -0,0 +1,165 @@
import { ScrollArea, ScrollAreaViewport } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useWorkflowSchedulesQuery } from "@/routes/workflows/hooks/useWorkflowSchedulesQuery";
import {
useCreateScheduleMutation,
useToggleScheduleMutation,
useDeleteScheduleMutation,
} from "@/routes/workflows/hooks/useScheduleMutations";
import { ScheduleCard } from "./ScheduleCard";
import { CreateScheduleDialog } from "./CreateScheduleDialog";
import { ReloadIcon } from "@radix-ui/react-icons";
import { useState } from "react";
function WorkflowSchedulePanel() {
const {
data: schedules,
isLoading,
isError,
error,
} = useWorkflowSchedulesQuery();
const createSchedule = useCreateScheduleMutation();
const toggleSchedule = useToggleScheduleMutation();
const deleteSchedule = useDeleteScheduleMutation();
const [deleteDialogState, setDeleteDialogState] = useState<{
open: boolean;
scheduleId: string | null;
}>({ open: false, scheduleId: null });
const handleCreate = (
cronExpression: string,
timezone: string,
name: string,
description: string,
callbacks: { onSuccess: () => void },
) => {
createSchedule.mutate(
{
cron_expression: cronExpression,
timezone,
enabled: true,
...(name && { name }),
...(description && { description }),
},
{ onSuccess: callbacks.onSuccess },
);
};
const handleToggle = (scheduleId: string, enabled: boolean) => {
toggleSchedule.mutate({ scheduleId, enabled });
};
const handleDeleteConfirm = () => {
if (deleteDialogState.scheduleId) {
deleteSchedule.mutate(deleteDialogState.scheduleId, {
onSettled: () => {
setDeleteDialogState({ open: false, scheduleId: null });
},
});
}
};
return (
<div className="flex h-full w-[22rem] flex-col rounded-lg border border-slate-700 bg-slate-elevation3">
<div className="flex items-center justify-between border-b border-slate-700 px-4 py-4">
<h3 className="text-sm font-normal text-slate-50">
Schedules
{schedules && schedules.length > 0 ? ` (${schedules.length})` : ""}
</h3>
<CreateScheduleDialog
onSubmit={handleCreate}
isPending={createSchedule.isPending}
/>
</div>
<ScrollArea>
<ScrollAreaViewport className="max-h-[calc(100vh-16rem)]">
<div className="flex flex-col gap-3 px-4 py-2">
{isLoading && (
<div className="flex items-center justify-center py-8">
<ReloadIcon className="size-5 animate-spin text-slate-400" />
</div>
)}
{isError && (
<div className="py-8 text-center text-sm text-red-400">
Failed to load schedules.
{error?.message && (
<span className="block text-xs text-slate-500">
{error.message}
</span>
)}
</div>
)}
{!isLoading &&
!isError &&
(!schedules || schedules.length === 0) && (
<div className="py-8 text-center text-sm text-slate-500">
No schedules configured.
<br />
Click &quot;Add&quot; to create one.
</div>
)}
{schedules?.map((schedule) => (
<ScheduleCard
key={schedule.workflow_schedule_id}
schedule={schedule}
isToggling={toggleSchedule.isPending}
onToggle={handleToggle}
onDelete={(id) =>
setDeleteDialogState({ open: true, scheduleId: id })
}
/>
))}
</div>
</ScrollAreaViewport>
</ScrollArea>
<Dialog
open={deleteDialogState.open}
onOpenChange={(open) => {
if (!open && !deleteSchedule.isPending) {
setDeleteDialogState({ open: false, scheduleId: null });
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Schedule</DialogTitle>
<DialogDescription>
Are you sure you want to delete this schedule? This action cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="secondary"
disabled={deleteSchedule.isPending}
onClick={() =>
setDeleteDialogState({ open: false, scheduleId: null })
}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={deleteSchedule.isPending}
onClick={handleDeleteConfirm}
>
{deleteSchedule.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export { WorkflowSchedulePanel };

View file

@ -0,0 +1,97 @@
import cronstrue from "cronstrue";
import { CronExpressionParser } from "cron-parser";
export const CRON_PRESETS = [
{ label: "Hourly", expression: "0 * * * *" },
{ label: "Daily", expression: "0 9 * * *" },
{ label: "Weekdays", expression: "0 9 * * 1-5" },
{ label: "Weekly", expression: "0 9 * * 1" },
{ label: "Monthly", expression: "0 9 1 * *" },
] as const;
export function cronToHumanReadable(expression: string): string {
try {
return cronstrue.toString(expression, {
use24HourTimeFormat: false,
verbose: false,
});
} catch {
return "Invalid expression";
}
}
export function isValidCron(expression: string): boolean {
try {
CronExpressionParser.parse(expression);
return true;
} catch {
return false;
}
}
export function getNextRuns(
expression: string,
timezone: string,
count: number = 5,
): Date[] {
try {
const interval = CronExpressionParser.parse(expression, {
tz: timezone,
});
const runs: Date[] = [];
for (let i = 0; i < count; i++) {
runs.push(interval.next().toDate());
}
return runs;
} catch {
return [];
}
}
export function formatNextRun(date: Date, timezone: string): string {
return new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
timeZone: timezone,
}).format(date);
}
export function getTimezones(): string[] {
try {
// Intl.supportedValuesOf is available in modern browsers
return (
Intl as unknown as { supportedValuesOf: (key: string) => string[] }
).supportedValuesOf("timeZone");
} catch {
// Fallback for older browsers
return [
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Kolkata",
"Australia/Sydney",
"Pacific/Auckland",
"UTC",
];
}
}
export function getLocalTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}

View file

@ -0,0 +1,131 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { AxiosError } from "axios";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { toast } from "@/components/ui/use-toast";
import type {
CreateScheduleRequest,
WorkflowScheduleResponse,
} from "@/routes/workflows/types/scheduleTypes";
function useCreateScheduleMutation() {
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (request: CreateScheduleRequest) => {
if (!workflowPermanentId) {
throw new Error("Missing workflowPermanentId");
}
const client = await getClient(credentialGetter);
const response = await client.post<WorkflowScheduleResponse>(
`/workflows/${workflowPermanentId}/schedules`,
request,
);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["workflowSchedules", workflowPermanentId],
});
toast({
title: "Schedule created",
variant: "success",
});
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to create schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
function useToggleScheduleMutation() {
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
scheduleId,
enabled,
}: {
scheduleId: string;
enabled: boolean;
}) => {
if (!workflowPermanentId) {
throw new Error("Missing workflowPermanentId");
}
const client = await getClient(credentialGetter);
const action = enabled ? "enable" : "disable";
const response = await client.post<WorkflowScheduleResponse>(
`/workflows/${workflowPermanentId}/schedules/${scheduleId}/${action}`,
);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["workflowSchedules", workflowPermanentId],
});
toast({
title: "Schedule updated",
variant: "success",
});
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to update schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
function useDeleteScheduleMutation() {
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (scheduleId: string) => {
if (!workflowPermanentId) {
throw new Error("Missing workflowPermanentId");
}
const client = await getClient(credentialGetter);
await client.delete(
`/workflows/${workflowPermanentId}/schedules/${scheduleId}`,
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["workflowSchedules", workflowPermanentId],
});
toast({
title: "Schedule deleted",
variant: "success",
});
},
onError: (error: AxiosError) => {
const detail = (error.response?.data as { detail?: string })?.detail;
toast({
title: "Failed to delete schedule",
description: detail || error.message,
variant: "destructive",
});
},
});
}
export {
useCreateScheduleMutation,
useToggleScheduleMutation,
useDeleteScheduleMutation,
};

View file

@ -14,6 +14,7 @@ type Props = {
workflowPermanentId?: string;
statusFilters?: Array<Status>;
page: number;
pageSize?: number;
search?: string;
} & UseQueryOptions;
@ -21,6 +22,7 @@ function useWorkflowRunsQuery({
workflowPermanentId,
statusFilters,
page,
pageSize,
search,
...queryOptions
}: Props) {
@ -33,6 +35,7 @@ function useWorkflowRunsQuery({
{ statusFilters },
workflowPermanentId,
page,
pageSize,
search,
],
queryFn: async () => {
@ -42,6 +45,9 @@ function useWorkflowRunsQuery({
(workflow) => workflow.workflow_permanent_id === workflowPermanentId,
);
params.append("page", String(page));
if (pageSize) {
params.append("page_size", String(pageSize));
}
if (isGlobalWorkflow) {
params.append("template", "true");
}

View file

@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import type {
WorkflowSchedule,
WorkflowScheduleListResponse,
} from "@/routes/workflows/types/scheduleTypes";
function useWorkflowSchedulesQuery() {
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
return useQuery<Array<WorkflowSchedule>>({
queryKey: ["workflowSchedules", workflowPermanentId],
queryFn: async () => {
const client = await getClient(credentialGetter);
const response = await client.get<WorkflowScheduleListResponse>(
`/workflows/${workflowPermanentId}/schedules`,
);
return response.data.schedules;
},
enabled: !!workflowPermanentId,
staleTime: 30_000,
});
}
export { useWorkflowSchedulesQuery };

View file

@ -0,0 +1,65 @@
export type WorkflowSchedule = {
workflow_schedule_id: string;
organization_id: string;
workflow_permanent_id: string;
cron_expression: string;
timezone: string;
enabled: boolean;
parameters: Record<string, unknown> | null;
temporal_schedule_id: string | null;
name: string | null;
description: string | null;
created_at: string;
modified_at: string;
deleted_at: string | null;
};
export type WorkflowScheduleResponse = {
schedule: WorkflowSchedule;
next_runs: Array<string>;
};
export type WorkflowScheduleListResponse = {
schedules: Array<WorkflowSchedule>;
};
export type CreateScheduleRequest = {
cron_expression: string;
timezone: string;
enabled?: boolean;
parameters?: Record<string, unknown> | null;
name?: string;
description?: string;
};
export type UpdateScheduleRequest = {
cron_expression: string;
timezone: string;
enabled: boolean;
parameters?: Record<string, unknown> | null;
name?: string;
description?: string;
};
export type OrganizationScheduleItem = {
workflow_schedule_id: string;
organization_id: string;
workflow_permanent_id: string;
workflow_title: string;
cron_expression: string;
timezone: string;
enabled: boolean;
parameters: Record<string, unknown> | null;
name: string | null;
description: string | null;
next_run: string | null;
created_at: string;
modified_at: string;
};
export type OrganizationScheduleListResponse = {
schedules: OrganizationScheduleItem[];
total_count: number;
page: number;
page_size: number;
};

View file

@ -16,7 +16,8 @@ type WorkflowPanelState = {
| "parameters"
| "nodeLibrary"
| "history"
| "comparison";
| "comparison"
| "schedules";
data?: {
previous?: string | null;
next?: string | null;

View file

@ -2915,23 +2915,92 @@ class AgentDB(BaseAlchemyDB):
LOG.error("UnexpectedError", exc_info=True)
raise
async def soft_delete_workflow_by_permanent_id(
async def soft_delete_workflow_and_schedules_by_permanent_id(
self,
workflow_permanent_id: str,
organization_id: str | None = None,
) -> None:
async with self.Session() as session:
# soft delete the workflow by setting the deleted_at field
update_deleted_at_query = (
update(WorkflowModel)
.where(WorkflowModel.workflow_permanent_id == workflow_permanent_id)
.where(WorkflowModel.deleted_at.is_(None))
)
if organization_id:
update_deleted_at_query = update_deleted_at_query.filter_by(organization_id=organization_id)
update_deleted_at_query = update_deleted_at_query.values(deleted_at=datetime.utcnow())
await session.execute(update_deleted_at_query)
await session.commit()
) -> list[str]:
"""Soft-delete a workflow and its active schedules in a single DB transaction."""
try:
async with self.Session() as session:
select_query = (
select(WorkflowScheduleModel.workflow_schedule_id)
.where(WorkflowScheduleModel.workflow_permanent_id == workflow_permanent_id)
.where(WorkflowScheduleModel.deleted_at.is_(None))
)
if organization_id is not None:
select_query = select_query.where(WorkflowScheduleModel.organization_id == organization_id)
result = await session.execute(select_query)
schedule_ids = list(result.scalars().all())
deleted_at = datetime.utcnow()
if schedule_ids:
update_schedules_query = (
update(WorkflowScheduleModel)
.where(WorkflowScheduleModel.workflow_schedule_id.in_(schedule_ids))
.values(deleted_at=deleted_at)
)
await session.execute(update_schedules_query)
update_workflow_query = (
update(WorkflowModel)
.where(WorkflowModel.workflow_permanent_id == workflow_permanent_id)
.where(WorkflowModel.deleted_at.is_(None))
)
if organization_id is not None:
update_workflow_query = update_workflow_query.filter_by(organization_id=organization_id)
await session.execute(update_workflow_query.values(deleted_at=deleted_at))
await session.commit()
return schedule_ids
except SQLAlchemyError:
LOG.error("SQLAlchemyError in soft_delete_workflow_and_schedules_by_permanent_id", exc_info=True)
raise
async def soft_delete_orphaned_schedules(self, limit: int = 500) -> list[tuple[str, str]]:
"""Soft-delete orphaned schedules and return their identities.
Uses a single UPDATE ... RETURNING statement so orphan detection and
soft-deletion happen atomically in one DB round-trip.
"""
try:
async with self.Session() as session:
active_workflow_exists = (
select(WorkflowModel.workflow_permanent_id)
.where(WorkflowModel.workflow_permanent_id == WorkflowScheduleModel.workflow_permanent_id)
.where(WorkflowModel.deleted_at.is_(None))
.correlate(WorkflowScheduleModel)
.exists()
)
orphaned_schedules = (
select(
WorkflowScheduleModel.workflow_schedule_id.label("workflow_schedule_id"),
WorkflowScheduleModel.workflow_permanent_id.label("workflow_permanent_id"),
)
.where(WorkflowScheduleModel.deleted_at.is_(None))
.where(~active_workflow_exists)
.limit(limit)
.cte("orphaned_schedules")
)
update_query = (
update(WorkflowScheduleModel)
.where(
WorkflowScheduleModel.workflow_schedule_id.in_(
select(orphaned_schedules.c.workflow_schedule_id)
)
)
.where(WorkflowScheduleModel.deleted_at.is_(None))
.values(deleted_at=datetime.utcnow())
.returning(
WorkflowScheduleModel.workflow_schedule_id,
WorkflowScheduleModel.workflow_permanent_id,
)
)
result = await session.execute(update_query)
await session.commit()
return [(row[0], row[1]) for row in result.all()]
except SQLAlchemyError:
LOG.error("SQLAlchemyError in soft_delete_orphaned_schedules", exc_info=True)
raise
async def add_workflow_template(
self,

View file

@ -3178,16 +3178,30 @@ class WorkflowService:
workflow_permanent_id: str,
organization_id: str | None = None,
) -> None:
await app.DATABASE.soft_delete_workflow_by_permanent_id(
# Delete workflow and schedules in one DB transaction so we do not leave
# the workflow active if a process exits between separate commits.
deleted_schedule_ids = await app.DATABASE.soft_delete_workflow_and_schedules_by_permanent_id(
workflow_permanent_id=workflow_permanent_id,
organization_id=organization_id,
)
if deleted_schedule_ids:
LOG.info(
"Cascade-deleted schedules during workflow deletion",
workflow_permanent_id=workflow_permanent_id,
organization_id=organization_id,
deleted_schedule_ids=deleted_schedule_ids,
count=len(deleted_schedule_ids),
)
async def delete_workflow_by_id(
self,
workflow_id: str,
organization_id: str,
) -> None:
# This path is rollback-only for a single workflow version created during
# save/update flows. It must stay version-scoped and non-cascading because
# schedules belong to the permanent workflow and should remain attached to
# the previously valid version if the new version creation fails.
await app.DATABASE.soft_delete_workflow_by_id(
workflow_id=workflow_id,
organization_id=organization_id,

View file

@ -0,0 +1,217 @@
"""Tests for orphan prevention and schedule cascade deletion (SKY-8186)."""
from unittest.mock import AsyncMock
import pytest
from sqlalchemy.dialects import postgresql
from skyvern.forge.sdk.db.agent_db import AgentDB
from skyvern.forge.sdk.workflow.service import WorkflowService
class _FakeResult:
def __init__(self, values: list[object]) -> None:
self._values = values
def scalars(self) -> "_FakeResult":
return self
def all(self) -> list[object]:
return self._values
class _FakeSession:
def __init__(self, execute_side_effect: list[object]) -> None:
self.execute = AsyncMock(side_effect=execute_side_effect)
self.commit = AsyncMock()
async def __aenter__(self) -> "_FakeSession":
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
return False
@pytest.fixture
def workflow_service() -> WorkflowService:
return WorkflowService()
@pytest.mark.asyncio
async def test_soft_delete_workflow_and_schedules_commits_once() -> None:
db = object.__new__(AgentDB)
fake_session = _FakeSession(
execute_side_effect=[
_FakeResult(["wfs_123", "wfs_456"]),
None,
None,
]
)
db.Session = lambda: fake_session # type: ignore[method-assign]
deleted_schedule_ids = await db.soft_delete_workflow_and_schedules_by_permanent_id(
workflow_permanent_id="wpid_abc",
organization_id="org_xyz",
)
assert deleted_schedule_ids == ["wfs_123", "wfs_456"]
assert fake_session.execute.await_count == 3
fake_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_workflow_uses_atomic_schedule_delete_path(
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
) -> None:
"""Deleting a workflow should use a single DB call for workflow + schedule deletion."""
from skyvern.forge import app
mock_atomic_delete = AsyncMock(return_value=["wfs_123", "wfs_456"])
monkeypatch.setattr(
app.DATABASE,
"soft_delete_workflow_and_schedules_by_permanent_id",
mock_atomic_delete,
raising=False,
)
await workflow_service.delete_workflow_by_permanent_id(
workflow_permanent_id="wpid_abc",
organization_id="org_xyz",
)
mock_atomic_delete.assert_awaited_once_with(
workflow_permanent_id="wpid_abc",
organization_id="org_xyz",
)
@pytest.mark.asyncio
async def test_delete_workflow_no_schedules(monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService) -> None:
"""Deleting a workflow with no schedules should still delete the workflow."""
from skyvern.forge import app
mock_atomic_delete = AsyncMock(return_value=[])
monkeypatch.setattr(
app.DATABASE,
"soft_delete_workflow_and_schedules_by_permanent_id",
mock_atomic_delete,
raising=False,
)
await workflow_service.delete_workflow_by_permanent_id(
workflow_permanent_id="wpid_no_schedules",
organization_id="org_xyz",
)
mock_atomic_delete.assert_awaited_once_with(
workflow_permanent_id="wpid_no_schedules",
organization_id="org_xyz",
)
@pytest.mark.asyncio
async def test_delete_workflow_atomic_delete_receives_same_inputs(
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
) -> None:
"""The service should pass the workflow identity through unchanged."""
from skyvern.forge import app
mock_atomic_delete = AsyncMock(return_value=["wfs_1"])
monkeypatch.setattr(
app.DATABASE,
"soft_delete_workflow_and_schedules_by_permanent_id",
mock_atomic_delete,
raising=False,
)
await workflow_service.delete_workflow_by_permanent_id(
workflow_permanent_id="wpid_order_test",
organization_id="org_xyz",
)
mock_atomic_delete.assert_awaited_once_with(
workflow_permanent_id="wpid_order_test",
organization_id="org_xyz",
)
@pytest.mark.asyncio
async def test_delete_workflow_without_organization_id(
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
) -> None:
"""Atomic deletion should work without organization_id."""
from skyvern.forge import app
mock_atomic_delete = AsyncMock(return_value=[])
monkeypatch.setattr(
app.DATABASE,
"soft_delete_workflow_and_schedules_by_permanent_id",
mock_atomic_delete,
raising=False,
)
await workflow_service.delete_workflow_by_permanent_id(
workflow_permanent_id="wpid_no_org",
)
mock_atomic_delete.assert_awaited_once_with(
workflow_permanent_id="wpid_no_org",
organization_id=None,
)
@pytest.mark.asyncio
async def test_delete_workflow_by_id_remains_version_scoped(
monkeypatch: pytest.MonkeyPatch, workflow_service: WorkflowService
) -> None:
"""delete_workflow_by_id is rollback-only and should not cascade schedules."""
from skyvern.forge import app
mock_delete_by_id = AsyncMock()
mock_atomic_delete = AsyncMock()
monkeypatch.setattr(app.DATABASE, "soft_delete_workflow_by_id", mock_delete_by_id)
monkeypatch.setattr(
app.DATABASE,
"soft_delete_workflow_and_schedules_by_permanent_id",
mock_atomic_delete,
raising=False,
)
await workflow_service.delete_workflow_by_id(
workflow_id="wf_rollback_only",
organization_id="org_xyz",
)
mock_delete_by_id.assert_awaited_once_with(
workflow_id="wf_rollback_only",
organization_id="org_xyz",
)
mock_atomic_delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_soft_delete_orphaned_schedules_uses_single_returning_update() -> None:
db = object.__new__(AgentDB)
fake_session = _FakeSession(
execute_side_effect=[
_FakeResult([("wfs_123", "wpid_abc")]),
]
)
db.Session = lambda: fake_session # type: ignore[method-assign]
orphaned = await db.soft_delete_orphaned_schedules()
assert orphaned == [("wfs_123", "wpid_abc")]
assert fake_session.execute.await_count == 1
fake_session.commit.assert_awaited_once()
query = fake_session.execute.await_args.args[0]
compiled_query = str(
query.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
)
assert "LIMIT 500" in compiled_query
assert (
"RETURNING workflow_schedules.workflow_schedule_id, workflow_schedules.workflow_permanent_id" in compiled_query
)

View file

@ -0,0 +1,86 @@
import importlib
import sys
from types import ModuleType, SimpleNamespace
import pytest
def _install_temporal_activity_stubs(monkeypatch: pytest.MonkeyPatch) -> None:
temporalio_package = ModuleType("temporalio")
temporalio_package.__path__ = []
temporalio_activity = ModuleType("temporalio.activity")
temporalio_activity.defn = lambda fn: fn
temporalio_package.activity = temporalio_activity
monkeypatch.setitem(sys.modules, "temporalio", temporalio_package)
monkeypatch.setitem(sys.modules, "temporalio.activity", temporalio_activity)
def import_cron_worker_activities(monkeypatch: pytest.MonkeyPatch):
cloud_package = ModuleType("cloud")
cloud_package.__path__ = []
cloud_clients = ModuleType("cloud.clients")
cloud_yescaptcha = ModuleType("cloud.clients.yescaptcha")
cloud_yescaptcha_client = ModuleType("cloud.clients.yescaptcha.client")
cloud_config = ModuleType("cloud.config")
cloud_db = ModuleType("cloud.db")
cloud_agent_db = ModuleType("cloud.db.cloud_agent_db")
cloud_tasks = ModuleType("cloud.tasks")
class YescaptchaClient:
def __init__(self, client_key: str) -> None:
self.client_key = client_key
async def update_stuck_tasks_to_timed_out() -> None:
return None
async def update_stuck_workflow_runs_to_timed_out() -> None:
return None
cloud_yescaptcha_client.YescaptchaClient = YescaptchaClient
cloud_config.settings = SimpleNamespace(
ENABLE_YESCAPTCHA_BALANCE_ALERT=False,
YESCAPTCHA_API_KEY="",
)
cloud_agent_db.cloud_db = SimpleNamespace()
cloud_tasks.update_stuck_tasks_to_timed_out = update_stuck_tasks_to_timed_out
cloud_tasks.update_stuck_workflow_runs_to_timed_out = update_stuck_workflow_runs_to_timed_out
monkeypatch.setitem(sys.modules, "cloud", cloud_package)
monkeypatch.setitem(sys.modules, "cloud.clients", cloud_clients)
monkeypatch.setitem(sys.modules, "cloud.clients.yescaptcha", cloud_yescaptcha)
monkeypatch.setitem(sys.modules, "cloud.clients.yescaptcha.client", cloud_yescaptcha_client)
monkeypatch.setitem(sys.modules, "cloud.config", cloud_config)
monkeypatch.setitem(sys.modules, "cloud.db", cloud_db)
monkeypatch.setitem(sys.modules, "cloud.db.cloud_agent_db", cloud_agent_db)
monkeypatch.setitem(sys.modules, "cloud.tasks", cloud_tasks)
_install_temporal_activity_stubs(monkeypatch)
sys.modules.pop("workers.cron_worker.activities", None)
return importlib.import_module("workers.cron_worker.activities")
def import_temporal_v2_worker_activities(monkeypatch: pytest.MonkeyPatch):
cloud_package = ModuleType("cloud")
cloud_package.__path__ = []
cloud_services = ModuleType("cloud.services")
data_scrubber_module = ModuleType("cloud.services.data_scrubber_service")
worker_utils_module = ModuleType("workers.worker_utils")
class DataScrubber:
pass
async def activity_teardown() -> None:
return None
data_scrubber_module.DataScrubber = DataScrubber
worker_utils_module.activity_teardown = activity_teardown
monkeypatch.setitem(sys.modules, "cloud", cloud_package)
monkeypatch.setitem(sys.modules, "cloud.services", cloud_services)
monkeypatch.setitem(sys.modules, "cloud.services.data_scrubber_service", data_scrubber_module)
monkeypatch.setitem(sys.modules, "workers.worker_utils", worker_utils_module)
_install_temporal_activity_stubs(monkeypatch)
sys.modules.pop("workers.temporal_v2_worker.activities", None)
return importlib.import_module("workers.temporal_v2_worker.activities")