diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index 7df7244849e..f1d7270f0ca 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -137,3 +137,7 @@ const handler = useCallback(() => { - **Query hooks**: `useEntity`, `useEntityList` - **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`, `useDeleteEntity` - **Fetch functions**: `fetchEntity`, `fetchEntities` (private) + +## Enforcement + +`scripts/check-react-query-patterns.ts` (`bun run check:react-query`, run in CI) statically enforces these conventions: every `useQuery`/`useInfiniteQuery`/`useSuspenseQuery` declares an explicit `staleTime`, inline `queryFn`s destructure `signal`, `queryKey`s reference a colocated factory rather than an inline literal, and every `*Keys` factory in `hooks/queries/**` exposes an `all` root key. `hooks/queries/**` is a zero-tolerance zone; the rest of `apps/sim/**` is ratcheted against `scripts/check-react-query-patterns.baseline.json`. For a genuine exception, put `// rq-lint-allow: ` on the line directly above the flagged construct. diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e59102ebd58..2f1df75a380 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -112,6 +112,9 @@ jobs: - name: Zustand v5 selector audit run: bun run check:zustand-v5 + - name: React Query pattern audit + run: bun run check:react-query + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx index 3c254742348..f274e39c614 100644 --- a/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx +++ b/apps/sim/app/(landing)/components/demo-request/demo-request-modal.tsx @@ -1,6 +1,7 @@ 'use client' import { useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' import { useMutation } from '@tanstack/react-query' import { ChipCombobox, @@ -128,9 +129,7 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP } const submitError = demoMutation.isError - ? demoMutation.error instanceof Error - ? demoMutation.error.message - : 'Failed to submit demo request. Please try again.' + ? getErrorMessage(demoMutation.error, 'Failed to submit demo request. Please try again.') : null return ( diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 47f2f381168..cfc05b5a1e9 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -2,7 +2,10 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' -import { resumeWorkflowExecutionContextContract } from '@/lib/api/contracts/workflows' +import { + getPauseContextDetailContract, + resumeWorkflowExecutionContextContract, +} from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { AuthType } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' @@ -310,7 +313,7 @@ export const GET = withRouteHandler( params: Promise<{ workflowId: string; executionId: string; contextId: string }> } ) => { - const parsed = await parseRequest(resumeWorkflowExecutionContextContract, request, context) + const parsed = await parseRequest(getPauseContextDetailContract, request, context) if (!parsed.success) return parsed.response const { workflowId, executionId, contextId } = parsed.data.params diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx index 4f962ccce10..a802d6ebe3b 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1,12 +1,9 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { RefreshCw } from 'lucide-react' import { useRouter } from 'next/navigation' - -const logger = createLogger('ResumePage') - import { Badge, Button, @@ -29,19 +26,17 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { requestJson } from '@/lib/api/client/request' -import { resumeWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import Navbar from '@/app/(landing)/components/navbar/navbar' import { useBrandConfig } from '@/ee/whitelabeling' -import type { ResumeStatus } from '@/executor/types' - -interface ResumeLinks { - apiUrl: string - uiUrl: string - contextId: string - executionId: string - workflowId: string -} +import { + type PauseContextDetail, + type PausedExecutionDetail, + type PausePointWithQueue, + resumeKeys, + usePauseContextDetail, + useResumeContext, + useResumeExecutionDetail, +} from '@/hooks/queries/resume-execution' interface NormalizedInputField { id: string @@ -63,62 +58,6 @@ interface ResponseStructureRow { value: any } -interface ResumeQueueEntrySummary { - id: string - contextId: string - status: string - queuedAt: string | null - claimedAt: string | null - completedAt: string | null - failureReason: string | null - newExecutionId: string - resumeInput: any -} - -interface PausePointWithQueue { - contextId: string - triggerBlockId?: string - blockId?: string - response: any - registeredAt: string - resumeStatus: ResumeStatus - snapshotReady: boolean - resumeLinks?: ResumeLinks - queuePosition?: number | null - latestResumeEntry?: ResumeQueueEntrySummary | null - parallelScope?: any - loopScope?: any - pauseKind?: 'human' | 'time' - resumeAt?: string -} - -interface PausedExecutionSummary { - id: string - workflowId: string - executionId: string - status: string - totalPauseCount: number - resumedCount: number - pausedAt: string | null - updatedAt: string | null - expiresAt: string | null - metadata: Record | null - triggerIds: string[] - pausePoints: PausePointWithQueue[] -} - -interface PauseContextDetail { - execution: PausedExecutionSummary - pausePoint: PausePointWithQueue - queue: ResumeQueueEntrySummary[] - activeResumeEntry?: ResumeQueueEntrySummary | null -} - -interface PausedExecutionDetail extends PausedExecutionSummary { - executionSnapshot: any - queue: ResumeQueueEntrySummary[] -} - interface ResumeExecutionPageProps { params: { workflowId: string; executionId: string } initialExecutionDetail: PausedExecutionDetail | null @@ -205,10 +144,13 @@ export default function ResumeExecutionPage({ const { workflowId, executionId } = params const router = useRouter() const brandConfig = useBrandConfig() + const queryClient = useQueryClient() - const [executionDetail, setExecutionDetail] = useState( - initialExecutionDetail - ) + const { + data: executionDetail, + isFetching: refreshingExecution, + refetch: refetchExecutionDetail, + } = useResumeExecutionDetail(workflowId, executionId, initialExecutionDetail ?? undefined) const pausePoints = executionDetail?.pausePoints ?? [] const defaultContextId = useMemo(() => { @@ -222,7 +164,11 @@ export default function ResumeExecutionPage({ const [selectedContextId, setSelectedContextId] = useState( defaultContextId ?? null ) - const [selectedDetail, setSelectedDetail] = useState(null) + const { data: selectedDetail, isLoading: loadingDetail } = usePauseContextDetail( + workflowId, + executionId, + selectedContextId ?? undefined + ) const [selectedStatus, setSelectedStatus] = useState('paused') const [queuePosition, setQueuePosition] = useState(undefined) @@ -233,12 +179,12 @@ export default function ResumeExecutionPage({ >({}) const [formValues, setFormValues] = useState>({}) const [formErrors, setFormErrors] = useState>({}) - const [loadingDetail, setLoadingDetail] = useState(false) const [loadingAction, setLoadingAction] = useState(false) - const [refreshingExecution, setRefreshingExecution] = useState(false) const [error, setError] = useState(null) const [message, setMessage] = useState(null) + const resumeMutation = useResumeContext() + const normalizeInputFormatFields = useCallback((raw: any): NormalizedInputField[] => { if (!Array.isArray(raw)) return [] return raw @@ -513,296 +459,190 @@ export default function ResumeExecutionPage({ .filter((row): row is ResponseStructureRow => row !== null) }, [selectedDetail]) + const seedFormFromDetail = useCallback( + (detail: PauseContextDetail) => { + const responseData = detail.pausePoint.response?.data ?? {} + const operation = responseData.operation || 'human' + const fetchedInputFields = normalizeInputFormatFields(responseData.inputFormat) + const submission = + responseData && + typeof responseData.submission === 'object' && + !Array.isArray(responseData.submission) + ? (responseData.submission as Record) + : undefined + if (operation === 'human' && fetchedInputFields.length > 0) { + const baseValues = buildInitialFormValues(fetchedInputFields, submission) + let mergedValues = baseValues + setFormValuesByContext((prev) => { + const existingValues = prev[detail.pausePoint.contextId] + if (existingValues) mergedValues = { ...baseValues, ...existingValues } + return { ...prev, [detail.pausePoint.contextId]: mergedValues } + }) + setFormValues(mergedValues) + setFormErrors({}) + if (resumeInputsRef.current[detail.pausePoint.contextId] !== undefined) { + delete resumeInputsRef.current[detail.pausePoint.contextId] + } + setResumeInput('') + } else { + const initialValue = + typeof responseData === 'string' + ? responseData + : JSON.stringify(responseData ?? {}, null, 2) + if (resumeInputsRef.current[detail.pausePoint.contextId] !== undefined) { + setResumeInput(resumeInputsRef.current[detail.pausePoint.contextId]) + } else { + setResumeInput(initialValue) + resumeInputsRef.current = { + ...resumeInputsRef.current, + [detail.pausePoint.contextId]: initialValue, + } + } + setFormValues({}) + setFormErrors({}) + } + }, + [normalizeInputFormatFields, buildInitialFormValues] + ) + useEffect(() => { + if (!selectedDetail) return + setSelectedStatus(selectedDetail.pausePoint.resumeStatus) + setQueuePosition(selectedDetail.pausePoint.queuePosition) + seedFormFromDetail(selectedDetail) + }, [selectedDetail, seedFormFromDetail]) + + const handleRefreshExecution = useCallback(async () => { + const { data } = await refetchExecutionDetail() if (!selectedContextId) { - setSelectedDetail(null) - return + const firstPaused = + data?.pausePoints.find((point) => point.resumeStatus === 'paused')?.contextId ?? null + setSelectedContextId(firstPaused) } - const controller = new AbortController() - const loadDetail = async () => { - setLoadingDetail(true) - try { - // boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission) - const response = await fetch( - `/api/resume/${workflowId}/${executionId}/${selectedContextId}`, - { - method: 'GET', - credentials: 'include', - cache: 'no-store', - signal: controller.signal, + }, [refetchExecutionDetail, selectedContextId]) + + const handleResume = useCallback( + async () => { + if (!selectedContextId || !selectedDetail) return + setLoadingAction(true) + setError(null) + setMessage(null) + let resumePayload: any + if (isHumanMode && hasInputFormat) { + const errors: Record = {} + const submission: Record = {} + for (const field of inputFormatFields) { + const rawValue = formValues[field.name] ?? '' + const hasValue = + field.type === 'boolean' + ? rawValue === 'true' || rawValue === 'false' + : rawValue.trim().length > 0 && rawValue !== '__unset__' + if (!hasValue || rawValue === '__unset__') { + if (field.required) errors[field.name] = 'This field is required.' + continue } - ) - if (!response.ok) { - setSelectedDetail(null) + const { value, error: parseError } = parseFormValue(field, rawValue) + if (parseError) { + errors[field.name] = parseError + continue + } + if (value !== undefined) submission[field.name] = value + } + if (Object.keys(errors).length > 0) { + setFormErrors(errors) + setLoadingAction(false) return } - const data: PauseContextDetail = await response.json() - setSelectedDetail(data) - setSelectedStatus(data.pausePoint.resumeStatus) - setQueuePosition(data.pausePoint.queuePosition) - const responseData = data.pausePoint.response?.data ?? {} - const operation = responseData.operation || 'human' - const fetchedInputFields = normalizeInputFormatFields(responseData.inputFormat) - const submission = - responseData && - typeof responseData.submission === 'object' && - !Array.isArray(responseData.submission) - ? (responseData.submission as Record) - : undefined - if (operation === 'human' && fetchedInputFields.length > 0) { - const baseValues = buildInitialFormValues(fetchedInputFields, submission) - let mergedValues = baseValues - setFormValuesByContext((prev) => { - const existingValues = prev[data.pausePoint.contextId] - if (existingValues) mergedValues = { ...baseValues, ...existingValues } - return { ...prev, [data.pausePoint.contextId]: mergedValues } - }) - setFormValues(mergedValues) - setFormErrors({}) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - delete resumeInputsRef.current[data.pausePoint.contextId] - } - setResumeInput('') - } else { - const initialValue = - typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData ?? {}, null, 2) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - setResumeInput(resumeInputsRef.current[data.pausePoint.contextId]) - } else { - setResumeInput(initialValue) - resumeInputsRef.current = { - ...resumeInputsRef.current, - [data.pausePoint.contextId]: initialValue, - } + setFormErrors({}) + resumePayload = { submission } + } else { + let parsedInput: any + if (resumeInput && resumeInput.trim().length > 0) { + try { + parsedInput = JSON.parse(resumeInput) + } catch { + setError('Resume input must be valid JSON.') + setLoadingAction(false) + return } - setFormValues({}) - setFormErrors({}) } - } catch (err) { - if ((err as any)?.name !== 'AbortError') { - logger.error('Failed to load pause context detail', err) - } - } finally { - setLoadingDetail(false) + resumePayload = parsedInput } - } - loadDetail() - return () => controller.abort() - }, [ - workflowId, - executionId, - selectedContextId, - normalizeInputFormatFields, - buildInitialFormValues, - ]) - - const refreshExecutionDetail = useCallback(async () => { - setRefreshingExecution(true) - try { - const raw = await requestJson(resumeWorkflowExecutionContract, { - params: { workflowId, executionId }, - }) - // double-cast-allowed: contract pause-point shape is z.record(z.string(), z.unknown()) but the page works against the more specific local PausedExecutionDetail / PausePointWithQueue interfaces - const data = raw as unknown as PausedExecutionDetail - setExecutionDetail(data) - if (!selectedContextId) { - const first = - data.pausePoints?.find((point: PausePointWithQueue) => point.resumeStatus === 'paused') - ?.contextId ?? null - setSelectedContextId(first) - } - } catch (err) { - logger.error('Failed to refresh execution detail', err) - } finally { - setRefreshingExecution(false) - } - }, [workflowId, executionId, selectedContextId]) - - const refreshSelectedDetail = useCallback( - async (contextId: string, showLoader = true) => { try { - if (showLoader) setLoadingDetail(true) - // boundary-raw-fetch: GET /api/resume/[workflowId]/[executionId]/[contextId] has no contract (server route only models POST resume submission) - const response = await fetch(`/api/resume/${workflowId}/${executionId}/${contextId}`, { - method: 'GET', - credentials: 'include', - cache: 'no-store', + const { ok, payload } = await resumeMutation.mutateAsync({ + workflowId, + executionId, + contextId: selectedContextId, + input: resumePayload, }) - if (!response.ok) return - const data: PauseContextDetail = await response.json() - setSelectedDetail(data) - setSelectedStatus(data.pausePoint.resumeStatus) - setQueuePosition(data.pausePoint.queuePosition) - const responseData = data.pausePoint.response?.data ?? {} - const operation = responseData.operation || 'human' - const fetchedInputFields = normalizeInputFormatFields(responseData.inputFormat) - const submission = - responseData && - typeof responseData.submission === 'object' && - !Array.isArray(responseData.submission) - ? (responseData.submission as Record) - : undefined - if (operation === 'human' && fetchedInputFields.length > 0) { - const baseValues = buildInitialFormValues(fetchedInputFields, submission) - let mergedValues = baseValues - setFormValuesByContext((prev) => { - const existingValues = prev[data.pausePoint.contextId] - if (existingValues) mergedValues = { ...baseValues, ...existingValues } - return { ...prev, [data.pausePoint.contextId]: mergedValues } - }) - setFormValues(mergedValues) - setFormErrors({}) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - delete resumeInputsRef.current[data.pausePoint.contextId] + if (!ok) { + setError(payload.error || 'Failed to resume execution.') + setSelectedStatus(selectedDetail.pausePoint.resumeStatus) + return + } + const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming' + const nextQueuePosition = payload.queuePosition ?? null + const fallbackContextId = + executionDetail?.pausePoints.find( + (point) => point.contextId !== selectedContextId && point.resumeStatus === 'paused' + )?.contextId ?? null + queryClient.setQueryData( + resumeKeys.execution(workflowId, executionId), + (prev) => { + if (!prev) return prev + return { + ...prev, + pausePoints: prev.pausePoints.map((point) => + point.contextId === selectedContextId + ? { ...point, resumeStatus: nextStatus, queuePosition: nextQueuePosition } + : point + ), + } } - setResumeInput('') - } else { - const initialValue = - typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData ?? {}, null, 2) - if (resumeInputsRef.current[data.pausePoint.contextId] !== undefined) { - setResumeInput(resumeInputsRef.current[data.pausePoint.contextId]) - } else { - setResumeInput(initialValue) - resumeInputsRef.current = { - ...resumeInputsRef.current, - [data.pausePoint.contextId]: initialValue, + ) + queryClient.setQueryData( + resumeKeys.context(workflowId, executionId, selectedContextId), + (prev) => { + if (!prev || prev.pausePoint.contextId !== selectedContextId) return prev + return { + ...prev, + pausePoint: { + ...prev.pausePoint, + resumeStatus: nextStatus, + queuePosition: nextQueuePosition, + }, } } - setFormValues({}) - setFormErrors({}) - } - } catch (err) { - logger.error('Failed to refresh pause context detail', err) + ) + setSelectedStatus(nextStatus) + setQueuePosition(nextQueuePosition) + setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId)) + setMessage( + payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.' + ) + } catch (err: any) { + setError(err.message || 'Unexpected error while resuming execution.') } finally { - if (showLoader) setLoadingDetail(false) + setLoadingAction(false) } }, - [workflowId, executionId, normalizeInputFormatFields, buildInitialFormValues] + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + workflowId, + executionId, + selectedContextId, + isHumanMode, + hasInputFormat, + inputFormatFields, + formValues, + parseFormValue, + resumeInput, + selectedDetail, + executionDetail, + queryClient, + ] ) - const handleResume = useCallback(async () => { - if (!selectedContextId || !selectedDetail) return - setLoadingAction(true) - setError(null) - setMessage(null) - let resumePayload: any - if (isHumanMode && hasInputFormat) { - const errors: Record = {} - const submission: Record = {} - for (const field of inputFormatFields) { - const rawValue = formValues[field.name] ?? '' - const hasValue = - field.type === 'boolean' - ? rawValue === 'true' || rawValue === 'false' - : rawValue.trim().length > 0 && rawValue !== '__unset__' - if (!hasValue || rawValue === '__unset__') { - if (field.required) errors[field.name] = 'This field is required.' - continue - } - const { value, error: parseError } = parseFormValue(field, rawValue) - if (parseError) { - errors[field.name] = parseError - continue - } - if (value !== undefined) submission[field.name] = value - } - if (Object.keys(errors).length > 0) { - setFormErrors(errors) - setLoadingAction(false) - return - } - setFormErrors({}) - resumePayload = { submission } - } else { - let parsedInput: any - if (resumeInput && resumeInput.trim().length > 0) { - try { - parsedInput = JSON.parse(resumeInput) - } catch { - setError('Resume input must be valid JSON.') - setLoadingAction(false) - return - } - } - resumePayload = parsedInput - } - try { - // boundary-raw-fetch: resume-context POST contract has no body schema (route uses tolerant raw JSON parse for resume input forwarded to PauseResumeManager) - const response = await fetch( - `/api/resume/${workflowId}/${executionId}/${selectedContextId}`, - { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(resumePayload ? { input: resumePayload } : {}), - } - ) - const payload = await response.json() - if (!response.ok) { - setError(payload.error || 'Failed to resume execution.') - setSelectedStatus(selectedDetail.pausePoint.resumeStatus) - return - } - const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming' - const nextQueuePosition = payload.queuePosition ?? null - const fallbackContextId = - executionDetail?.pausePoints.find( - (point) => point.contextId !== selectedContextId && point.resumeStatus === 'paused' - )?.contextId ?? null - setExecutionDetail((prev) => { - if (!prev) return prev - return { - ...prev, - pausePoints: prev.pausePoints.map((point) => - point.contextId === selectedContextId - ? { ...point, resumeStatus: nextStatus, queuePosition: nextQueuePosition } - : point - ), - } - }) - setSelectedDetail((prev) => { - if (!prev || prev.pausePoint.contextId !== selectedContextId) return prev - return { - ...prev, - pausePoint: { - ...prev.pausePoint, - resumeStatus: nextStatus, - queuePosition: nextQueuePosition, - }, - } - }) - setSelectedStatus(nextStatus) - setQueuePosition(nextQueuePosition) - setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId)) - setMessage( - payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.' - ) - await Promise.all([refreshExecutionDetail(), refreshSelectedDetail(selectedContextId, false)]) - } catch (err: any) { - setError(err.message || 'Unexpected error while resuming execution.') - } finally { - setLoadingAction(false) - } - }, [ - workflowId, - executionId, - selectedContextId, - isHumanMode, - hasInputFormat, - inputFormatFields, - formValues, - parseFormValue, - resumeInput, - selectedDetail, - executionDetail, - refreshExecutionDetail, - refreshSelectedDetail, - ]) - const isFormComplete = useMemo(() => { if (!isHumanMode || !hasInputFormat) return true return inputFormatFields.every((field) => { @@ -902,7 +742,7 @@ export default function ResumeExecutionPage({