d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
475 lines
13 KiB
TypeScript
475 lines
13 KiB
TypeScript
import { toast } from '@sim/emcn'
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { isApiClientError } from '@/lib/api/client/errors'
|
|
import { requestJson } from '@/lib/api/client/request'
|
|
import { deployWorkflowContract } from '@/lib/api/contracts/deployments'
|
|
import {
|
|
type CreateScheduleBody,
|
|
createScheduleContract,
|
|
deleteScheduleContract,
|
|
disableScheduleContract,
|
|
excludeOccurrenceContract,
|
|
getScheduleByIdContract,
|
|
getScheduleContract,
|
|
listWorkspaceSchedulesContract,
|
|
reactivateScheduleContract,
|
|
type UpdateScheduleBody,
|
|
updateScheduleContract,
|
|
type WorkflowScheduleRow,
|
|
type WorkspaceScheduleRow,
|
|
} from '@/lib/api/contracts/schedules'
|
|
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
|
import { deploymentKeys } from '@/hooks/queries/deployments'
|
|
|
|
const logger = createLogger('ScheduleQueries')
|
|
|
|
export const SCHEDULE_LIST_STALE_TIME = 30 * 1000
|
|
export const SCHEDULE_DETAIL_STALE_TIME = 30 * 1000
|
|
export const SCHEDULE_BLOCK_STALE_TIME = 30 * 1000
|
|
|
|
export const scheduleKeys = {
|
|
all: ['schedules'] as const,
|
|
lists: () => [...scheduleKeys.all, 'list'] as const,
|
|
list: (workspaceId: string) => [...scheduleKeys.lists(), workspaceId] as const,
|
|
details: () => [...scheduleKeys.all, 'detail'] as const,
|
|
schedule: (workflowId: string, blockId: string) =>
|
|
[...scheduleKeys.details(), workflowId, blockId] as const,
|
|
byId: (scheduleId: string) => [...scheduleKeys.details(), scheduleId] as const,
|
|
}
|
|
|
|
export type ScheduleData = WorkflowScheduleRow
|
|
export type WorkspaceScheduleData = WorkspaceScheduleRow
|
|
|
|
export interface ScheduleInfo {
|
|
id: string
|
|
status: ScheduleData['status']
|
|
scheduleTiming: string
|
|
nextRunAt: string | null
|
|
lastRanAt: string | null
|
|
timezone: string
|
|
isDisabled: boolean
|
|
failedCount: number
|
|
}
|
|
|
|
/**
|
|
* Fetches schedule data for a specific workflow block
|
|
*/
|
|
async function fetchSchedule(
|
|
workflowId: string,
|
|
blockId: string,
|
|
signal?: AbortSignal
|
|
): Promise<ScheduleData | null> {
|
|
try {
|
|
const data = await requestJson(getScheduleContract, {
|
|
query: { workflowId, blockId },
|
|
signal,
|
|
})
|
|
return data.schedule || null
|
|
} catch (error) {
|
|
if (isApiClientError(error) && error.status === 404) return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch all schedules for a workspace.
|
|
*/
|
|
export function useWorkspaceSchedules(workspaceId?: string) {
|
|
return useQuery({
|
|
queryKey: scheduleKeys.list(workspaceId ?? ''),
|
|
queryFn: async ({ signal }) => {
|
|
if (!workspaceId) throw new Error('Workspace ID required')
|
|
|
|
const data = await requestJson(listWorkspaceSchedulesContract, {
|
|
query: { workspaceId },
|
|
signal,
|
|
})
|
|
return data.schedules || []
|
|
},
|
|
enabled: Boolean(workspaceId),
|
|
staleTime: SCHEDULE_LIST_STALE_TIME,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetch a single schedule (job) by id. Used by the mothership resource viewer so
|
|
* opening a scheduled-task artifact does a lightweight by-id read instead of the
|
|
* whole-workspace `useWorkspaceSchedules` fetch (which contended with the chat
|
|
* stream connection and stalled start/resume).
|
|
*/
|
|
export function useScheduleById(scheduleId?: string) {
|
|
return useQuery({
|
|
queryKey: scheduleKeys.byId(scheduleId ?? ''),
|
|
queryFn: async ({ signal }) => {
|
|
if (!scheduleId) throw new Error('Schedule ID required')
|
|
|
|
const data = await requestJson(getScheduleByIdContract, {
|
|
params: { id: scheduleId },
|
|
signal,
|
|
})
|
|
return data.schedule
|
|
},
|
|
enabled: Boolean(scheduleId),
|
|
staleTime: SCHEDULE_DETAIL_STALE_TIME,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch schedule data for a workflow block
|
|
*/
|
|
export function useScheduleQuery(
|
|
workflowId: string | undefined,
|
|
blockId: string | undefined,
|
|
options?: { enabled?: boolean }
|
|
) {
|
|
return useQuery({
|
|
queryKey: scheduleKeys.schedule(workflowId ?? '', blockId ?? ''),
|
|
queryFn: ({ signal }) => fetchSchedule(workflowId!, blockId!, signal),
|
|
enabled: !!workflowId && !!blockId && (options?.enabled ?? true),
|
|
staleTime: SCHEDULE_BLOCK_STALE_TIME,
|
|
retry: false,
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Hook to get processed schedule info with human-readable timing
|
|
*/
|
|
export function useScheduleInfo(
|
|
workflowId: string | undefined,
|
|
blockId: string | undefined,
|
|
blockType: string,
|
|
options?: { timezone?: string }
|
|
): {
|
|
scheduleInfo: ScheduleInfo | null
|
|
isLoading: boolean
|
|
refetch: () => void
|
|
} {
|
|
const isScheduleBlock = blockType === 'schedule'
|
|
|
|
const { data, isLoading, refetch } = useScheduleQuery(workflowId, blockId, {
|
|
enabled: isScheduleBlock,
|
|
})
|
|
|
|
if (!data) {
|
|
return { scheduleInfo: null, isLoading, refetch }
|
|
}
|
|
|
|
const timezone = options?.timezone || data.timezone || 'UTC'
|
|
const scheduleTiming = data.cronExpression
|
|
? parseCronToHumanReadable(data.cronExpression, timezone)
|
|
: 'Unknown schedule'
|
|
|
|
return {
|
|
scheduleInfo: {
|
|
id: data.id,
|
|
status: data.status,
|
|
scheduleTiming,
|
|
nextRunAt: data.nextRunAt,
|
|
lastRanAt: data.lastRanAt,
|
|
timezone,
|
|
isDisabled: data.status === 'disabled',
|
|
failedCount: data.failedCount || 0,
|
|
},
|
|
isLoading,
|
|
refetch,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mutation to reactivate a disabled schedule
|
|
*/
|
|
export function useReactivateSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
scheduleId,
|
|
workflowId,
|
|
blockId,
|
|
workspaceId,
|
|
}: {
|
|
scheduleId: string
|
|
workflowId: string
|
|
blockId: string
|
|
workspaceId?: string
|
|
}) => {
|
|
await requestJson(reactivateScheduleContract, {
|
|
params: { id: scheduleId },
|
|
body: { action: 'reactivate' },
|
|
})
|
|
|
|
return { workflowId, blockId, workspaceId }
|
|
},
|
|
onSuccess: ({ workflowId, blockId }) => {
|
|
logger.info('Schedule reactivated', { workflowId, blockId })
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to reactivate schedule', { error })
|
|
},
|
|
onSettled: async (data) => {
|
|
if (!data) return
|
|
const { workflowId, blockId, workspaceId } = data
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.schedule(workflowId, blockId) }),
|
|
workspaceId
|
|
? queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
|
|
: Promise.resolve(),
|
|
])
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mutation to disable an active schedule or job
|
|
*/
|
|
export function useDisableSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
scheduleId,
|
|
workspaceId,
|
|
}: {
|
|
scheduleId: string
|
|
workspaceId: string
|
|
}) => {
|
|
await requestJson(disableScheduleContract, {
|
|
params: { id: scheduleId },
|
|
body: { action: 'disable' },
|
|
})
|
|
|
|
return { workspaceId }
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Task paused')
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to disable schedule', { error })
|
|
toast.error("Couldn't pause task", { description: getErrorMessage(error) })
|
|
},
|
|
onSettled: async (data) => {
|
|
if (!data) return
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
|
])
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mutation to resume (reactivate) a paused standalone job schedule. Keyed by
|
|
* `workspaceId` so it invalidates the workspace list; the workflow-block variant
|
|
* {@link useReactivateSchedule} keys by `workflowId`/`blockId` instead. Resuming
|
|
* recomputes `nextRunAt` from the schedule's cron, so it applies to recurring
|
|
* tasks only — one-time tasks carry no cadence to resume.
|
|
*/
|
|
export function useResumeSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
scheduleId,
|
|
workspaceId,
|
|
}: {
|
|
scheduleId: string
|
|
workspaceId: string
|
|
}) => {
|
|
await requestJson(reactivateScheduleContract, {
|
|
params: { id: scheduleId },
|
|
body: { action: 'reactivate' },
|
|
})
|
|
|
|
return { workspaceId }
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Task resumed')
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to resume schedule', { error })
|
|
toast.error("Couldn't resume task", { description: getErrorMessage(error) })
|
|
},
|
|
onSettled: async (data) => {
|
|
if (!data) return
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
|
])
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mutation to delete a schedule or job
|
|
*/
|
|
export function useDeleteSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
scheduleId,
|
|
workspaceId,
|
|
}: {
|
|
scheduleId: string
|
|
workspaceId: string
|
|
}) => {
|
|
await requestJson(deleteScheduleContract, {
|
|
params: { id: scheduleId },
|
|
})
|
|
|
|
return { workspaceId }
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Task deleted')
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to delete schedule', { error })
|
|
toast.error("Couldn't delete task", { description: getErrorMessage(error) })
|
|
},
|
|
onSettled: async (data) => {
|
|
if (!data) return
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
|
])
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mutation to delete a single occurrence of a recurring task (gcal "this
|
|
* event"). The whole series is deleted via {@link useDeleteSchedule} instead.
|
|
*/
|
|
export function useExcludeOccurrence() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
scheduleId,
|
|
occurrence,
|
|
workspaceId,
|
|
}: {
|
|
scheduleId: string
|
|
occurrence: string
|
|
workspaceId: string
|
|
}) => {
|
|
await requestJson(excludeOccurrenceContract, {
|
|
params: { id: scheduleId },
|
|
body: { action: 'exclude_occurrence', occurrence },
|
|
})
|
|
|
|
return { workspaceId }
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Occurrence removed')
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to delete occurrence', { error })
|
|
toast.error("Couldn't remove occurrence", { description: getErrorMessage(error) })
|
|
},
|
|
onSettled: async (data) => {
|
|
if (!data) return
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
|
])
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mutation to update fields on a standalone job schedule
|
|
*/
|
|
export function useUpdateSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
scheduleId,
|
|
workspaceId,
|
|
...updates
|
|
}: {
|
|
scheduleId: string
|
|
workspaceId: string
|
|
} & Omit<UpdateScheduleBody, 'action'>) => {
|
|
await requestJson(updateScheduleContract, {
|
|
params: { id: scheduleId },
|
|
body: { action: 'update', ...updates },
|
|
})
|
|
|
|
return { workspaceId }
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Task updated')
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to update schedule', { error })
|
|
toast.error("Couldn't update task", { description: getErrorMessage(error) })
|
|
},
|
|
onSettled: async (data) => {
|
|
if (!data) return
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
|
|
])
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mutation to create a standalone scheduled job
|
|
*/
|
|
export function useCreateSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (body: CreateScheduleBody) => requestJson(createScheduleContract, { body }),
|
|
onSuccess: () => {
|
|
toast.success('Task scheduled')
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to create schedule', { error })
|
|
toast.error("Couldn't schedule task", { description: getErrorMessage(error) })
|
|
},
|
|
onSettled: (_data, _error, variables) =>
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(variables.workspaceId) }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mutation to redeploy a workflow (which recreates the schedule)
|
|
*/
|
|
export function useRedeployWorkflowSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ workflowId, blockId }: { workflowId: string; blockId: string }) => {
|
|
await requestJson(deployWorkflowContract, {
|
|
params: { id: workflowId },
|
|
})
|
|
|
|
return { workflowId, blockId }
|
|
},
|
|
onSuccess: ({ workflowId, blockId }) => {
|
|
logger.info('Workflow redeployed for schedule reset', { workflowId, blockId })
|
|
},
|
|
onError: (error) => {
|
|
logger.error('Failed to redeploy workflow', { error })
|
|
},
|
|
onSettled: async (data) => {
|
|
if (!data) return
|
|
const { workflowId, blockId } = data
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: scheduleKeys.schedule(workflowId, blockId) }),
|
|
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }),
|
|
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }),
|
|
])
|
|
},
|
|
})
|
|
}
|