Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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) }),
])
},
})
}