diff --git a/skyvern-frontend/package-lock.json b/skyvern-frontend/package-lock.json index 92b4437bd..29aa240cb 100644 --- a/skyvern-frontend/package-lock.json +++ b/skyvern-frontend/package-lock.json @@ -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", diff --git a/skyvern-frontend/package.json b/skyvern-frontend/package.json index cace27303..cd95ebf1d 100644 --- a/skyvern-frontend/package.json +++ b/skyvern-frontend/package.json @@ -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", diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 1171bbb85..bc3c1a279 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -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; diff --git a/skyvern-frontend/src/components/TriggerTypeBadge.tsx b/skyvern-frontend/src/components/TriggerTypeBadge.tsx new file mode 100644 index 000000000..bdd8c4eda --- /dev/null +++ b/skyvern-frontend/src/components/TriggerTypeBadge.tsx @@ -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: , + label: "Manual", + }, + [TriggerType.Api]: { + icon: , + label: "API", + }, + [TriggerType.Scheduled]: { + icon: , + label: "Scheduled", + }, +}; + +function TriggerTypeBadge({ triggerType }: Props) { + if (!triggerType) { + return null; + } + + const config = triggerConfig[triggerType]; + if (!config) { + return null; + } + + return ( + + {config.icon} + + ); +} + +export { TriggerTypeBadge }; diff --git a/skyvern-frontend/src/components/TriggerTypeFilterDropdown.tsx b/skyvern-frontend/src/components/TriggerTypeFilterDropdown.tsx new file mode 100644 index 000000000..5c9ec2d0d --- /dev/null +++ b/skyvern-frontend/src/components/TriggerTypeFilterDropdown.tsx @@ -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 = [ + { + label: "Manual", + value: TriggerType.Manual, + }, + { + label: "API", + value: TriggerType.Api, + }, + { + label: "Scheduled", + value: TriggerType.Scheduled, + }, +]; + +type Props = { + values: Array; + onChange: (values: Array) => 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 ( + + + + + + {triggerTypeDropdownItems.map((item) => { + return ( +
+ { + if (checked) { + onChange([...values, item.value]); + } else { + onChange(values.filter((value) => value !== item.value)); + } + }} + /> + +
+ ); + })} +
+
+ ); +} + +export { TriggerTypeFilterDropdown }; diff --git a/skyvern-frontend/src/hooks/useRunsQuery.ts b/skyvern-frontend/src/hooks/useRunsQuery.ts index 9aa06fbc7..2ea9cb500 100644 --- a/skyvern-frontend/src/hooks/useRunsQuery.ts +++ b/skyvern-frontend/src/hooks/useRunsQuery.ts @@ -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; type UseQueryOptions = Omit< @@ -13,6 +13,7 @@ type Props = { page?: number; pageSize?: number; statusFilters?: Array; + triggerTypeFilters?: Array; search?: string; } & UseQueryOptions; @@ -20,11 +21,18 @@ function useRunsQuery({ page = 1, pageSize = 10, statusFilters, + triggerTypeFilters, search, }: Props) { const credentialGetter = useCredentialGetter(); return useQuery>({ - 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); } diff --git a/skyvern-frontend/src/router.tsx b/skyvern-frontend/src/router.tsx index c217f28ea..9303527ff 100644 --- a/skyvern-frontend/src/router.tsx +++ b/skyvern-frontend/src/router.tsx @@ -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: , }, + { + path: "schedules", + element: , + children: [ + { + index: true, + element: , + }, + { + path: ":workflowPermanentId/:scheduleId", + element: , + }, + ], + }, { path: "browser-sessions", element: , diff --git a/skyvern-frontend/src/routes/history/RunHistory.tsx b/skyvern-frontend/src/routes/history/RunHistory.tsx index 798427f55..00c809a93 100644 --- a/skyvern-frontend/src/routes/history/RunHistory.tsx +++ b/skyvern-frontend/src/routes/history/RunHistory.tsx @@ -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>([]); + const [triggerTypeFilters, setTriggerTypeFilters] = useState< + Array + >([]); 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>( { - 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() {

Run History

-
+
{ @@ -166,9 +187,23 @@ function RunHistory() { placeholder="Search by run ID or parameter..." className="w-48 lg:w-72" /> + { + setTriggerTypeFilters(values); + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + }} + /> { + setStatusFilters(values); + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + setSearchParams(params, { replace: true }); + }} />
@@ -241,17 +276,17 @@ function RunHistory() { ); } - const workflowTitle = - run.script_run === true ? ( -
+ const workflowTitle = ( +
+ {run.workflow_title ?? ""} + {run.script_run === true && ( - {run.workflow_title ?? ""} -
- ) : ( - run.workflow_title ?? "" - ); + )} + +
+ ); const isExpanded = expandedRows.has(run.workflow_run_id); const workflowExecutionTime = formatExecutionTime( @@ -292,9 +327,25 @@ function RunHistory() { - {basicLocalTimeFormat(run.created_at)} +
+ {basicLocalTimeFormat(run.created_at)} + {run.trigger_type === TriggerType.Scheduled && + run.schedule_name && ( + + {run.schedule_name} + {run.schedule_cron + ? ` (${run.schedule_cron})` + : ""} + + )} +
{workflowExecutionTime ?? "-"} diff --git a/skyvern-frontend/src/routes/root/SideNav.tsx b/skyvern-frontend/src/routes/root/SideNav.tsx index eaa636fc5..e351c10a9 100644 --- a/skyvern-frontend/src/routes/root/SideNav.tsx +++ b/skyvern-frontend/src/routes/root/SideNav.tsx @@ -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 (
diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index f6867b5e8..b9714a6d6 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -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) => 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) { 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( @@ -393,6 +400,22 @@ function WorkflowHeader({ )} + {schedulesEnabled && ( + + )} + + + + Create Schedule + + Configure when this workflow should run automatically. + + + +
+ {/* Schedule Name & Description */} +
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+ + {/* Cron Presets */} +
+ +
+ {CRON_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Custom Cron Input */} +
+ + setCronExpression(e.target.value)} + placeholder="* * * * *" + className={cn(!valid && cronExpression && "border-destructive")} + /> + {humanReadable && ( +

{humanReadable}

+ )} + {!valid && cronExpression && ( +

+ Invalid cron expression +

+ )} +
+ + {/* Timezone Selector */} +
+ + 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 && ( +
+ {filteredTimezones.slice(0, 20).map((tz) => ( + + ))} + {filteredTimezones.length === 0 && ( +
+ No timezones found +
+ )} +
+ )} +

Current: {timezone}

+
+ + {/* Next Runs Preview */} + {nextRuns.length > 0 && ( +
+ +
+ {nextRuns.map((run) => ( +
+ {formatNextRun(run, timezone)} +
+ ))} +
+
+ )} +
+ + + + + +
+ + ); +} + +export { CreateScheduleDialog }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/ScheduleCard.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/ScheduleCard.tsx new file mode 100644 index 000000000..7d0c24b78 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/ScheduleCard.tsx @@ -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 ( +
+
+
+ {schedule.name && ( + + {schedule.name} + + )} + + {humanReadable} + +
+
+
+ {schedule.timezone} +
+ + onToggle(schedule.workflow_schedule_id, checked) + } + /> + +
+
+ {nextRun && ( +
+ Next: {formatNextRun(nextRun, schedule.timezone)} +
+ )} +
+ ); +} + +export { ScheduleCard }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx new file mode 100644 index 000000000..6edc1354f --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx @@ -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 ( +
+
+

+ Schedules + {schedules && schedules.length > 0 ? ` (${schedules.length})` : ""} +

+ +
+ + + +
+ {isLoading && ( +
+ +
+ )} + {isError && ( +
+ Failed to load schedules. + {error?.message && ( + + {error.message} + + )} +
+ )} + {!isLoading && + !isError && + (!schedules || schedules.length === 0) && ( +
+ No schedules configured. +
+ Click "Add" to create one. +
+ )} + {schedules?.map((schedule) => ( + + setDeleteDialogState({ open: true, scheduleId: id }) + } + /> + ))} +
+
+
+ + { + if (!open && !deleteSchedule.isPending) { + setDeleteDialogState({ open: false, scheduleId: null }); + } + }} + > + + + Delete Schedule + + Are you sure you want to delete this schedule? This action cannot + be undone. + + + + + + + + +
+ ); +} + +export { WorkflowSchedulePanel }; diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/cronUtils.ts b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/cronUtils.ts new file mode 100644 index 000000000..2387bfe70 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/cronUtils.ts @@ -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; +} diff --git a/skyvern-frontend/src/routes/workflows/hooks/useScheduleMutations.ts b/skyvern-frontend/src/routes/workflows/hooks/useScheduleMutations.ts new file mode 100644 index 000000000..e5173e053 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useScheduleMutations.ts @@ -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( + `/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( + `/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, +}; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts index d6d274e9f..5d2fd68ad 100644 --- a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts +++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunsQuery.ts @@ -14,6 +14,7 @@ type Props = { workflowPermanentId?: string; statusFilters?: Array; 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"); } diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowSchedulesQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowSchedulesQuery.ts new file mode 100644 index 000000000..0ab6f208c --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowSchedulesQuery.ts @@ -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>({ + queryKey: ["workflowSchedules", workflowPermanentId], + queryFn: async () => { + const client = await getClient(credentialGetter); + const response = await client.get( + `/workflows/${workflowPermanentId}/schedules`, + ); + return response.data.schedules; + }, + enabled: !!workflowPermanentId, + staleTime: 30_000, + }); +} + +export { useWorkflowSchedulesQuery }; diff --git a/skyvern-frontend/src/routes/workflows/types/scheduleTypes.ts b/skyvern-frontend/src/routes/workflows/types/scheduleTypes.ts new file mode 100644 index 000000000..b836d9106 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/types/scheduleTypes.ts @@ -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 | 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; +}; + +export type WorkflowScheduleListResponse = { + schedules: Array; +}; + +export type CreateScheduleRequest = { + cron_expression: string; + timezone: string; + enabled?: boolean; + parameters?: Record | null; + name?: string; + description?: string; +}; + +export type UpdateScheduleRequest = { + cron_expression: string; + timezone: string; + enabled: boolean; + parameters?: Record | 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 | 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; +}; diff --git a/skyvern-frontend/src/store/WorkflowPanelStore.ts b/skyvern-frontend/src/store/WorkflowPanelStore.ts index 1b1da5847..6f4576371 100644 --- a/skyvern-frontend/src/store/WorkflowPanelStore.ts +++ b/skyvern-frontend/src/store/WorkflowPanelStore.ts @@ -16,7 +16,8 @@ type WorkflowPanelState = { | "parameters" | "nodeLibrary" | "history" - | "comparison"; + | "comparison" + | "schedules"; data?: { previous?: string | null; next?: string | null; diff --git a/skyvern/forge/sdk/db/agent_db.py b/skyvern/forge/sdk/db/agent_db.py index bf3db909d..80752317b 100644 --- a/skyvern/forge/sdk/db/agent_db.py +++ b/skyvern/forge/sdk/db/agent_db.py @@ -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, diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index 039ba80c9..d8b8e96d5 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -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, diff --git a/tests/unit/test_schedule_orphan_cleanup.py b/tests/unit/test_schedule_orphan_cleanup.py new file mode 100644 index 000000000..048cd3f12 --- /dev/null +++ b/tests/unit/test_schedule_orphan_cleanup.py @@ -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 + ) diff --git a/tests/unit/worker_activity_import_helpers.py b/tests/unit/worker_activity_import_helpers.py new file mode 100644 index 000000000..f66762ba5 --- /dev/null +++ b/tests/unit/worker_activity_import_helpers.py @@ -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")