improvement(react-query): codebase-wide audit — server-state hooks, webhook coherence, resume migration (#5024)

* improvement(react-query): codebase-wide audit — server-state hooks, webhook coherence, resume migration

* chore(react-query): add static pattern linter + address review feedback

- add scripts/check-react-query-patterns.ts (staleTime/signal/key-factory/inline-key
  enforcement) wired into CI as check:react-query; strict zone hooks/queries/**, ratchet elsewhere
- fix(resume): use same-origin relative path for resume POST (getBaseUrl could cross origin
  on whitelabel/preview hosts and drop session cookies) — Cursor Bugbot
- remove explanatory inline comments in favor of TSDoc per repo convention
This commit is contained in:
Waleed
2026-06-13 10:14:07 -07:00
committed by GitHub
parent 51733b8d85
commit eb1009da1c
46 changed files with 1053 additions and 647 deletions
+4
View File
@@ -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: <reason>` on the line directly above the flagged construct.
+3
View File
@@ -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
@@ -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 (
@@ -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
@@ -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<string, any> | 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<PausedExecutionDetail | null>(
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<string | null>(
defaultContextId ?? null
)
const [selectedDetail, setSelectedDetail] = useState<PauseContextDetail | null>(null)
const { data: selectedDetail, isLoading: loadingDetail } = usePauseContextDetail(
workflowId,
executionId,
selectedContextId ?? undefined
)
const [selectedStatus, setSelectedStatus] =
useState<PausePointWithQueue['resumeStatus']>('paused')
const [queuePosition, setQueuePosition] = useState<number | null | undefined>(undefined)
@@ -233,12 +179,12 @@ export default function ResumeExecutionPage({
>({})
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [loadingDetail, setLoadingDetail] = useState(false)
const [loadingAction, setLoadingAction] = useState(false)
const [refreshingExecution, setRefreshingExecution] = useState(false)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const resumeMutation = useResumeContext()
const normalizeInputFormatFields = useCallback((raw: any): NormalizedInputField[] => {
if (!Array.isArray(raw)) return []
return raw
@@ -513,295 +459,189 @@ export default function ResumeExecutionPage({
.filter((row): row is ResponseStructureRow => row !== null)
}, [selectedDetail])
useEffect(() => {
if (!selectedContextId) {
setSelectedDetail(null)
return
}
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,
}
)
if (!response.ok) {
setSelectedDetail(null)
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<string, any>)
: 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,
}
}
setFormValues({})
setFormErrors({})
}
} catch (err) {
if ((err as any)?.name !== 'AbortError') {
logger.error('Failed to load pause context detail', err)
}
} finally {
setLoadingDetail(false)
}
}
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 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<string, any>)
: 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 }
})
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<string, any>)
: 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,
}
}
setFormValues({})
setFormErrors({})
setFormValues(mergedValues)
setFormErrors({})
if (resumeInputsRef.current[detail.pausePoint.contextId] !== undefined) {
delete resumeInputsRef.current[detail.pausePoint.contextId]
}
} catch (err) {
logger.error('Failed to refresh pause context detail', err)
} finally {
if (showLoader) setLoadingDetail(false)
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({})
}
},
[workflowId, executionId, normalizeInputFormatFields, buildInitialFormValues]
[normalizeInputFormatFields, buildInitialFormValues]
)
const handleResume = useCallback(async () => {
if (!selectedContextId || !selectedDetail) return
setLoadingAction(true)
setError(null)
setMessage(null)
let resumePayload: any
if (isHumanMode && hasInputFormat) {
const errors: Record<string, string> = {}
const submission: Record<string, any> = {}
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
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) {
const firstPaused =
data?.pausePoints.find((point) => point.resumeStatus === 'paused')?.contextId ?? null
setSelectedContextId(firstPaused)
}
}, [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<string, string> = {}
const submission: Record<string, any> = {}
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
}
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.')
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
}
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 } : {}),
try {
const { ok, payload } = await resumeMutation.mutateAsync({
workflowId,
executionId,
contextId: selectedContextId,
input: resumePayload,
})
if (!ok) {
setError(payload.error || 'Failed to resume execution.')
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
return
}
)
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
queryClient.setQueryData<PausedExecutionDetail>(
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
),
}
}
)
queryClient.setQueryData<PauseContextDetail | null>(
resumeKeys.context(workflowId, executionId, selectedContextId),
(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.'
)
} catch (err: any) {
setError(err.message || 'Unexpected error while resuming execution.')
} finally {
setLoadingAction(false)
}
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,
])
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
workflowId,
executionId,
selectedContextId,
isHumanMode,
hasInputFormat,
inputFormatFields,
formValues,
parseFormValue,
resumeInput,
selectedDetail,
executionDetail,
queryClient,
]
)
const isFormComplete = useMemo(() => {
if (!isHumanMode || !hasInputFormat) return true
@@ -902,7 +742,7 @@ export default function ResumeExecutionPage({
<Button
variant='outline'
size='sm'
onClick={refreshExecutionDetail}
onClick={handleRefreshExecution}
disabled={refreshingExecution}
className='gap-1.5 px-2.5'
aria-label='Refresh execution details'
@@ -443,12 +443,12 @@ export function WorkflowSidebarBody({
const body = rawBody as unknown as WorkflowStateContractInput
await requestJson(putWorkflowNormalizedStateContract, { params: { id: wfId }, body })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) })
},
onError: (err) => {
toast.error(toError(err).message)
},
onSettled: () => {
return queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) })
},
})
const blockOutputGroups = useMemo<BlockOutputGroup[]>(() => {
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { requestJson } from '@/lib/api/client/request'
import { listWebhooksByBlockContract, updateWebhookContract } from '@/lib/api/contracts/webhooks'
import { useReactivateWebhook, useWebhookQuery } from '@/hooks/queries/webhooks'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -34,13 +33,6 @@ export interface UseWebhookInfoReturn {
*/
export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookInfoReturn {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const [webhookStatus, setWebhookStatus] = useState<{
isDisabled: boolean
webhookId: string | undefined
}>({
isDisabled: false,
webhookId: undefined,
})
const isWebhookConfigured = useSubBlockStore(
useCallback(
@@ -76,62 +68,29 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
)
)
const fetchWebhookStatus = useCallback(async () => {
if (!workflowId || !blockId || !isWebhookConfigured) {
setWebhookStatus({ isDisabled: false, webhookId: undefined })
return
}
try {
const data = await requestJson(listWebhooksByBlockContract, {
query: { workflowId, blockId },
})
const webhooks = data.webhooks
if (webhooks.length > 0) {
const webhook = webhooks[0].webhook
const isActive = webhook.isActive !== false
setWebhookStatus({
isDisabled: !isActive,
webhookId: webhook.id,
})
} else {
setWebhookStatus({ isDisabled: false, webhookId: undefined })
}
} catch (error) {
logger.error('Error fetching webhook status:', error)
setWebhookStatus({ isDisabled: false, webhookId: undefined })
}
}, [workflowId, blockId, isWebhookConfigured])
useEffect(() => {
fetchWebhookStatus()
}, [fetchWebhookStatus])
const { data: webhook } = useWebhookQuery(workflowId, blockId, isWebhookConfigured)
const isDisabled = isWebhookConfigured && webhook?.isActive === false
const webhookId = isWebhookConfigured ? webhook?.id : undefined
const reactivateMutation = useReactivateWebhook()
const reactivateWebhook = useCallback(
async (webhookId: string) => {
async (id: string) => {
try {
await requestJson(updateWebhookContract, {
params: { id: webhookId },
body: {
isActive: true,
failedCount: 0,
},
})
await fetchWebhookStatus()
await reactivateMutation.mutateAsync({ webhookId: id, workflowId, blockId })
} catch (error) {
logger.error('Error reactivating webhook:', error)
}
},
[fetchWebhookStatus]
// eslint-disable-next-line react-hooks/exhaustive-deps
[workflowId, blockId]
)
return {
isWebhookConfigured,
webhookProvider,
webhookPath,
isDisabled: webhookStatus.isDisabled,
webhookId: webhookStatus.webhookId,
isDisabled,
webhookId,
reactivateWebhook,
}
}
+13 -12
View File
@@ -85,9 +85,8 @@ export function useCreateDataDrain() {
logger.info('Created data drain', { drainId: drain.id, organizationId })
return drain
},
onSuccess: (_drain, variables) => {
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) })
},
onSettled: (_drain, _error, variables) =>
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }),
})
}
@@ -108,9 +107,8 @@ export function useUpdateDataDrain() {
logger.info('Updated data drain', { drainId, organizationId })
return drain
},
onSuccess: (_drain, variables) => {
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) })
},
onSettled: (_drain, _error, variables) =>
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }),
})
}
@@ -128,9 +126,11 @@ export function useDeleteDataDrain() {
})
logger.info('Deleted data drain', { drainId, organizationId })
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) })
onSettled: (_data, _error, variables) => {
queryClient.removeQueries({ queryKey: dataDrainKeys.runs(variables.drainId) })
return queryClient.invalidateQueries({
queryKey: dataDrainKeys.list(variables.organizationId),
})
},
})
}
@@ -150,10 +150,11 @@ export function useRunDataDrainNow() {
logger.info('Enqueued data drain run', { drainId, jobId: data.jobId })
return data
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: dataDrainKeys.runs(variables.drainId) })
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) })
},
onSettled: (_data, _error, variables) =>
Promise.all([
queryClient.invalidateQueries({ queryKey: dataDrainKeys.runs(variables.drainId) }),
queryClient.invalidateQueries({ queryKey: dataDrainKeys.list(variables.organizationId) }),
]),
})
}
+3 -1
View File
@@ -15,6 +15,8 @@ import { organizationKeys } from '@/hooks/queries/organization'
export const ssoKeys = {
all: ['sso'] as const,
providers: () => [...ssoKeys.all, 'providers'] as const,
providerList: (organizationId?: string) =>
[...ssoKeys.providers(), organizationId ?? ''] as const,
}
/**
@@ -37,7 +39,7 @@ interface UseSSOProvidersOptions {
export function useSSOProviders({ enabled = true, organizationId }: UseSSOProvidersOptions = {}) {
return useQuery({
queryKey: [...ssoKeys.providers(), organizationId ?? ''],
queryKey: ssoKeys.providerList(organizationId),
queryFn: ({ signal }) => fetchSSOProviders(signal, organizationId),
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
@@ -23,7 +23,8 @@ export type WhitelabelSettingsPayload = {
*/
export const whitelabelKeys = {
all: ['whitelabel'] as const,
settings: (orgId: string) => [...whitelabelKeys.all, 'settings', orgId] as const,
settingsList: () => [...whitelabelKeys.all, 'settings'] as const,
settings: (orgId: string) => [...whitelabelKeys.settingsList(), orgId] as const,
}
async function fetchWhitelabelSettings(
+4 -4
View File
@@ -101,7 +101,7 @@ export function useCreateA2AAgent() {
return useMutation({
mutationFn: createA2AAgent,
onSuccess: () => {
onSettled: () => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.lists(),
})
@@ -136,7 +136,7 @@ export function useUpdateA2AAgent() {
return useMutation({
mutationFn: updateA2AAgent,
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.lists(),
})
@@ -167,7 +167,7 @@ export function useDeleteA2AAgent() {
return useMutation({
mutationFn: deleteA2AAgent,
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.lists(),
})
@@ -211,7 +211,7 @@ export function usePublishA2AAgent() {
return useMutation({
mutationFn: publishA2AAgent,
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: a2aAgentKeys.lists(),
})
+2 -3
View File
@@ -34,8 +34,7 @@ export function useIssueCertificate() {
return useMutation({
mutationFn: (variables: { courseId: string; completedLessonIds: string[] }) =>
requestJson(issueAcademyCertificateContract, { body: variables }).then((d) => d.certificate),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: academyKeys.certificate(variables.courseId) })
},
onSettled: (_data, _error, variables) =>
queryClient.invalidateQueries({ queryKey: academyKeys.certificate(variables.courseId) }),
})
}
+21 -20
View File
@@ -47,24 +47,31 @@ function mapUser(u: {
async function fetchAdminUsers(
offset: number,
limit: number,
searchQuery: string
searchQuery: string,
signal?: AbortSignal
): Promise<AdminUserListData> {
if (isValidUuid(searchQuery.trim())) {
const { data, error } = await client.admin.getUser({ query: { id: searchQuery.trim() } })
const { data, error } = await client.admin.getUser(
{ query: { id: searchQuery.trim() } },
{ signal }
)
if (error) throw new Error(error.message ?? 'Failed to fetch user')
if (!data) return { users: [], total: 0 }
return { users: [mapUser(data)], total: 1 }
}
const { data, error } = await client.admin.listUsers({
query: {
limit,
offset,
searchField: 'email',
searchValue: searchQuery,
searchOperator: 'contains',
const { data, error } = await client.admin.listUsers(
{
query: {
limit,
offset,
searchField: 'email',
searchValue: searchQuery,
searchOperator: 'contains',
},
},
})
{ signal }
)
if (error) throw new Error(error.message ?? 'Failed to fetch users')
return {
users: (data?.users ?? []).map(mapUser),
@@ -75,7 +82,7 @@ async function fetchAdminUsers(
export function useAdminUsers(offset: number, limit: number, searchQuery: string) {
return useQuery({
queryKey: adminUserKeys.list(offset, limit, searchQuery),
queryFn: () => fetchAdminUsers(offset, limit, searchQuery),
queryFn: ({ signal }) => fetchAdminUsers(offset, limit, searchQuery, signal),
enabled: searchQuery.length > 0,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
@@ -89,9 +96,7 @@ export function useSetUserRole() {
const result = await client.admin.setRole({ userId, role })
return result
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() })
},
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
onError: (err) => {
logger.error('Failed to set user role', err)
},
@@ -108,9 +113,7 @@ export function useBanUser() {
})
return result
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() })
},
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
onError: (err) => {
logger.error('Failed to ban user', err)
},
@@ -124,9 +127,7 @@ export function useUnbanUser() {
const result = await client.admin.unbanUser({ userId })
return result
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() })
},
onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }),
onError: (err) => {
logger.error('Failed to unban user', err)
},
+6 -6
View File
@@ -97,8 +97,8 @@ export function useCreateApiKey() {
return requestJson(createPersonalApiKeyContract, { body: { name } })
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
onSettled: (_data, _error, variables) => {
return queryClient.invalidateQueries({
queryKey: apiKeysKeys.combined(variables.workspaceId),
})
},
@@ -130,8 +130,8 @@ export function useDeleteApiKey() {
return requestJson(deletePersonalApiKeyContract, { params: { id: keyId } })
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
onSettled: (_data, _error, variables) => {
return queryClient.invalidateQueries({
queryKey: apiKeysKeys.combined(variables.workspaceId),
})
},
@@ -162,8 +162,8 @@ export function useUpdateWorkspaceApiKeySettings() {
body: { allowPersonalApiKeys },
})
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
onSettled: (_data, _error, variables) => {
return queryClient.invalidateQueries({
queryKey: workspaceKeys.settings(variables.workspaceId),
})
},
+9 -10
View File
@@ -16,7 +16,8 @@ export type { BYOKKey, BYOKKeysResponse }
export const byokKeysKeys = {
all: ['byok-keys'] as const,
workspace: (workspaceId: string) => [...byokKeysKeys.all, 'workspace', workspaceId] as const,
lists: () => [...byokKeysKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...byokKeysKeys.lists(), workspaceId ?? ''] as const,
}
async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise<BYOKKeysResponse> {
@@ -31,7 +32,7 @@ async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise
export function useBYOKKeys(workspaceId: string) {
return useQuery({
queryKey: byokKeysKeys.workspace(workspaceId),
queryKey: byokKeysKeys.list(workspaceId),
queryFn: ({ signal }) => fetchBYOKKeys(workspaceId, signal),
enabled: !!workspaceId,
staleTime: 60 * 1000,
@@ -55,11 +56,10 @@ export function useUpsertBYOKKey() {
logger.info(`Saved BYOK key for ${body.providerId} in workspace ${workspaceId}`)
return data
},
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) =>
queryClient.invalidateQueries({
queryKey: byokKeysKeys.workspace(variables.workspaceId),
})
},
queryKey: byokKeysKeys.list(variables.workspaceId),
}),
})
}
@@ -79,10 +79,9 @@ export function useDeleteBYOKKey() {
logger.info(`Deleted BYOK key for ${body.providerId} from workspace ${workspaceId}`)
return data
},
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) =>
queryClient.invalidateQueries({
queryKey: byokKeysKeys.workspace(variables.workspaceId),
})
},
queryKey: byokKeysKeys.list(variables.workspaceId),
}),
})
}
+4 -4
View File
@@ -62,14 +62,14 @@ export function useGenerateCopilotKey() {
mutationFn: async ({ name }: GenerateKeyParams): Promise<GenerateCopilotApiKeyResult> => {
return requestJson(generateCopilotApiKeyContract, { body: { name } })
},
onSuccess: () => {
onError: (error) => {
logger.error('Failed to generate Copilot API key:', error)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: copilotKeysKeys.keys(),
})
},
onError: (error) => {
logger.error('Failed to generate Copilot API key:', error)
},
})
}
+31 -21
View File
@@ -130,8 +130,10 @@ export function useAcceptCredentialSetInvitation() {
})
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() })
return Promise.all([
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }),
queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() }),
])
},
})
}
@@ -144,7 +146,9 @@ export function useCreateCredentialSet() {
return requestJson(createCredentialSetContract, { body: data })
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId) })
return queryClient.invalidateQueries({
queryKey: credentialSetKeys.list(variables.organizationId),
})
},
})
}
@@ -164,10 +168,12 @@ export function useCreateCredentialSetInvitation() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId),
})
queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() })
return Promise.all([
queryClient.invalidateQueries({
queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId),
}),
queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() }),
])
},
})
}
@@ -203,10 +209,12 @@ export function useRemoveCredentialSetMember() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: credentialSetKeys.detailMembers(variables.credentialSetId),
})
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
return Promise.all([
queryClient.invalidateQueries({
queryKey: credentialSetKeys.detailMembers(variables.credentialSetId),
}),
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }),
])
},
})
}
@@ -221,7 +229,7 @@ export function useLeaveCredentialSet() {
})
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
return queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
},
})
}
@@ -241,13 +249,15 @@ export function useDeleteCredentialSet() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: credentialSetKeys.list(variables.organizationId),
})
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() })
queryClient.invalidateQueries({
queryKey: credentialSetKeys.detail(variables.credentialSetId),
})
return Promise.all([
queryClient.invalidateQueries({
queryKey: credentialSetKeys.list(variables.organizationId),
}),
queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }),
queryClient.invalidateQueries({
queryKey: credentialSetKeys.detail(variables.credentialSetId),
}),
])
},
})
}
@@ -283,7 +293,7 @@ export function useCancelCredentialSetInvitation() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId),
})
},
@@ -305,7 +315,7 @@ export function useResendCredentialSetInvitation() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId),
})
},
+1 -2
View File
@@ -217,8 +217,7 @@ export function useCreateCustomTool() {
logger.info(`Created custom tool: ${tool.title}`)
return data.data as CustomToolDefinition[]
},
onSuccess: (_data, variables) => {
// Invalidate tools list for the workspace
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
},
})
+2 -2
View File
@@ -388,7 +388,7 @@ export function useUpdateDeploymentVersion() {
logger.error('Failed to update deployment version', { error })
}
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
},
@@ -599,7 +599,7 @@ export function useUpdatePublicApi() {
logger.error('Failed to update public API setting', { error })
}
queryClient.invalidateQueries({
return queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
},
+9 -9
View File
@@ -64,9 +64,11 @@ export function useSavePersonalEnvironment() {
logger.info('Saved personal environment variables')
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
queryClient.invalidateQueries({ queryKey: environmentKeys.workspaces() })
onSettled: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() }),
queryClient.invalidateQueries({ queryKey: environmentKeys.workspaces() }),
])
},
})
}
@@ -90,11 +92,10 @@ export function useUpsertWorkspaceEnvironment() {
logger.info(`Upserted workspace environment variables for workspace: ${workspaceId}`)
return data
},
onSettled: (_data, _error, variables) => {
onSettled: (_data, _error, variables) =>
queryClient.invalidateQueries({
queryKey: environmentKeys.workspace(variables.workspaceId),
})
},
}),
})
}
@@ -117,10 +118,9 @@ export function useRemoveWorkspaceEnvironment() {
logger.info(`Removed ${keys.length} workspace environment keys for workspace: ${workspaceId}`)
return data
},
onSettled: (_data, _error, variables) => {
onSettled: (_data, _error, variables) =>
queryClient.invalidateQueries({
queryKey: environmentKeys.workspace(variables.workspaceId),
})
},
}),
})
}
+1 -1
View File
@@ -177,7 +177,7 @@ export function useUpdateGeneralSetting() {
logger.error('Failed to update setting:', err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
return queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
},
})
}
+8 -4
View File
@@ -115,7 +115,7 @@ export function useToggleInbox() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) })
return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) })
},
})
}
@@ -131,7 +131,7 @@ export function useUpdateInboxAddress() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) })
return queryClient.invalidateQueries({ queryKey: inboxKeys.config(variables.workspaceId) })
},
})
}
@@ -181,7 +181,9 @@ export function useAddInboxSender() {
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: inboxKeys.senderList(variables.workspaceId) })
return queryClient.invalidateQueries({
queryKey: inboxKeys.senderList(variables.workspaceId),
})
},
})
}
@@ -215,7 +217,9 @@ export function useRemoveInboxSender() {
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: inboxKeys.senderList(variables.workspaceId) })
return queryClient.invalidateQueries({
queryKey: inboxKeys.senderList(variables.workspaceId),
})
},
})
}
+4 -1
View File
@@ -234,7 +234,10 @@ export function useLeaveWorkspace() {
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workspaceKeys.all,
queryKey: workspaceKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: workspaceKeys.detail(variables.workspaceId),
})
},
})
+10 -5
View File
@@ -22,12 +22,13 @@ const logger = createLogger('KnowledgeConnectorQueries')
export type { ConnectorData, ConnectorDetailData, SyncLogData }
export const connectorKeys = {
all: (knowledgeBaseId: string) =>
all: (knowledgeBaseId?: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'connectors'] as const,
list: (knowledgeBaseId?: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'connectors', 'list'] as const,
lists: (knowledgeBaseId?: string) => [...connectorKeys.all(knowledgeBaseId), 'list'] as const,
list: (knowledgeBaseId?: string) => connectorKeys.lists(knowledgeBaseId),
details: (knowledgeBaseId?: string) => [...connectorKeys.all(knowledgeBaseId), 'detail'] as const,
detail: (knowledgeBaseId?: string, connectorId?: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'connectors', 'detail', connectorId ?? ''] as const,
[...connectorKeys.details(knowledgeBaseId), connectorId ?? ''] as const,
}
async function fetchConnectors(
@@ -221,8 +222,12 @@ export function useTriggerSync() {
}
export const connectorDocumentKeys = {
list: (knowledgeBaseId?: string, connectorId?: string) =>
all: (knowledgeBaseId?: string, connectorId?: string) =>
[...connectorKeys.detail(knowledgeBaseId, connectorId), 'documents'] as const,
lists: (knowledgeBaseId?: string, connectorId?: string) =>
[...connectorDocumentKeys.all(knowledgeBaseId, connectorId), 'list'] as const,
list: (knowledgeBaseId?: string, connectorId?: string) =>
connectorDocumentKeys.lists(knowledgeBaseId, connectorId),
}
async function fetchConnectorDocuments(
+5 -6
View File
@@ -66,8 +66,9 @@ export const knowledgeKeys = {
lists: () => [...knowledgeKeys.all, 'list'] as const,
list: (workspaceId?: string, scope: KnowledgeQueryScope = 'active') =>
[...knowledgeKeys.lists(), workspaceId ?? 'all', scope] as const,
details: () => [...knowledgeKeys.all, 'detail'] as const,
detail: (knowledgeBaseId?: string) =>
[...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const,
[...knowledgeKeys.details(), knowledgeBaseId ?? ''] as const,
tagDefinitions: (knowledgeBaseId: string) =>
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
tagUsage: (knowledgeBaseId: string) =>
@@ -80,6 +81,8 @@ export const knowledgeKeys = {
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'tagDefinitions'] as const,
chunks: (knowledgeBaseId: string, documentId: string, paramsKey: string) =>
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'chunks', paramsKey] as const,
chunkSearch: (knowledgeBaseId: string, documentId: string, searchKey: string) =>
[...knowledgeKeys.document(knowledgeBaseId, documentId), 'search', searchKey] as const,
}
export async function fetchKnowledgeBases(
@@ -363,11 +366,7 @@ export function useDocumentChunkSearchQuery(
) {
const searchKey = serializeSearchParams(params)
return useQuery({
queryKey: [
...knowledgeKeys.document(params.knowledgeBaseId, params.documentId),
'search',
searchKey,
],
queryKey: knowledgeKeys.chunkSearch(params.knowledgeBaseId, params.documentId, searchKey),
queryFn: ({ signal }) => fetchAllDocumentChunks(params, signal),
enabled:
(options?.enabled ?? true) &&
+2 -2
View File
@@ -111,7 +111,7 @@ export function useUpsertMothershipByok() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
}),
onSuccess: (_data, params) =>
onSettled: (_data, _error, params) =>
queryClient.invalidateQueries({ queryKey: mothershipKeys.byok(params.workspaceId) }),
})
}
@@ -128,7 +128,7 @@ export function useDeleteMothershipByok() {
}).toString()}`,
{ method: 'DELETE' }
),
onSuccess: (_data, params) =>
onSettled: (_data, _error, params) =>
queryClient.invalidateQueries({ queryKey: mothershipKeys.byok(params.workspaceId) }),
})
}
@@ -21,7 +21,8 @@ const logger = createLogger('OAuthConnectionsQuery')
export const oauthConnectionsKeys = {
all: ['oauthConnections'] as const,
connections: () => [...oauthConnectionsKeys.all, 'connections'] as const,
accounts: (provider: string) => [...oauthConnectionsKeys.all, 'accounts', provider] as const,
accounts: () => [...oauthConnectionsKeys.all, 'accounts'] as const,
account: (provider: string) => [...oauthConnectionsKeys.accounts(), provider] as const,
}
/** OAuth service with connection status and linked accounts. */
@@ -152,12 +153,12 @@ export function useConnectOAuthService() {
return { success: true }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
},
onError: (error) => {
logger.error('OAuth connection error:', error)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
},
})
}
@@ -245,7 +246,7 @@ async function fetchConnectedAccounts(
*/
export function useConnectedAccounts(provider: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: oauthConnectionsKeys.accounts(provider),
queryKey: oauthConnectionsKeys.account(provider),
queryFn: ({ signal }) => fetchConnectedAccounts(provider, signal),
enabled: options?.enabled ?? true,
staleTime: 60 * 1000,
@@ -8,16 +8,17 @@ import { useWorkspaceCredential } from '@/hooks/queries/credentials'
export const oauthCredentialKeys = {
all: ['oauthCredentials'] as const,
lists: () => [...oauthCredentialKeys.all, 'list'] as const,
list: (providerId?: string, workspaceId?: string, workflowId?: string) =>
[
...oauthCredentialKeys.all,
'list',
...oauthCredentialKeys.lists(),
providerId ?? 'none',
workspaceId ?? 'none',
workflowId ?? 'none',
] as const,
details: () => [...oauthCredentialKeys.all, 'detail'] as const,
detail: (credentialId?: string, workflowId?: string) =>
[...oauthCredentialKeys.all, 'detail', credentialId ?? 'none', workflowId ?? 'none'] as const,
[...oauthCredentialKeys.details(), credentialId ?? 'none', workflowId ?? 'none'] as const,
}
interface FetchOAuthCredentialsParams {
+4 -3
View File
@@ -20,8 +20,9 @@ const logger = createLogger('ProviderModelsQuery')
export const providerKeys = {
all: ['provider-models'] as const,
models: (provider: string, workspaceId?: string) =>
[...providerKeys.all, provider, workspaceId ?? ''] as const,
lists: () => [...providerKeys.all, 'list'] as const,
list: (provider: string, workspaceId?: string) =>
[...providerKeys.lists(), provider, workspaceId ?? ''] as const,
}
async function fetchProviderModels(
@@ -87,7 +88,7 @@ async function requestProviderModels(
export function useProviderModels(provider: ProviderName, workspaceId?: string) {
return useQuery({
queryKey: providerKeys.models(provider, workspaceId),
queryKey: providerKeys.list(provider, workspaceId),
queryFn: ({ signal }) => fetchProviderModels(provider, signal, workspaceId),
staleTime: 5 * 60 * 1000,
})
+193
View File
@@ -0,0 +1,193 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
getPauseContextDetailContract,
resumeWorkflowExecutionContract,
} from '@/lib/api/contracts/workflows'
import type { ResumeStatus } from '@/executor/types'
export const resumeKeys = {
all: ['resume-execution'] as const,
executions: () => [...resumeKeys.all, 'execution'] as const,
execution: (workflowId?: string, executionId?: string) =>
[...resumeKeys.executions(), workflowId ?? '', executionId ?? ''] as const,
contexts: () => [...resumeKeys.all, 'context'] as const,
context: (workflowId?: string, executionId?: string, contextId?: string) =>
[...resumeKeys.contexts(), workflowId ?? '', executionId ?? '', contextId ?? ''] as const,
}
export interface ResumeLinks {
apiUrl: string
uiUrl: string
contextId: string
executionId: string
workflowId: string
}
export interface ResumeQueueEntrySummary {
id: string
contextId: string
status: string
queuedAt: string | null
claimedAt: string | null
completedAt: string | null
failureReason: string | null
newExecutionId: string
resumeInput: any
}
export 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
}
export 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<string, any> | null
triggerIds: string[]
pausePoints: PausePointWithQueue[]
}
export interface PausedExecutionDetail extends PausedExecutionSummary {
executionSnapshot: any
queue: ResumeQueueEntrySummary[]
}
export interface PauseContextDetail {
execution: PausedExecutionSummary
pausePoint: PausePointWithQueue
queue: ResumeQueueEntrySummary[]
activeResumeEntry?: ResumeQueueEntrySummary | null
}
export interface ResumeContextResult {
ok: boolean
payload: {
status?: string
queuePosition?: number | null
error?: string
message?: string
[key: string]: unknown
}
}
interface ResumeContextVariables {
workflowId: string
executionId: string
contextId: string
input?: unknown
}
/**
* Loads the paused execution detail (all pause points for an execution). The
* contract models pause points loosely (`z.record`); the resume UI works against
* the richer `PausedExecutionDetail` interface, hence the bridging cast.
*/
export function useResumeExecutionDetail(
workflowId: string,
executionId: string,
initialData?: PausedExecutionDetail
) {
return useQuery({
queryKey: resumeKeys.execution(workflowId, executionId),
queryFn: async ({ signal }): Promise<PausedExecutionDetail> => {
const raw = await requestJson(resumeWorkflowExecutionContract, {
params: { workflowId, executionId },
signal,
})
// double-cast-allowed: contract models pause points as z.record; the resume UI uses the richer PausedExecutionDetail interface
return raw as unknown as PausedExecutionDetail
},
enabled: Boolean(workflowId && executionId),
staleTime: 30 * 1000,
initialData,
})
}
/**
* Loads the detail for a single pause context. Returns `null` when the context
* no longer exists (404). `staleTime: 0` because pause state is live.
*/
export function usePauseContextDetail(workflowId: string, executionId: string, contextId?: string) {
return useQuery({
queryKey: resumeKeys.context(workflowId, executionId, contextId),
queryFn: async ({ signal }): Promise<PauseContextDetail | null> => {
try {
const raw = await requestJson(getPauseContextDetailContract, {
params: { workflowId, executionId, contextId: contextId as string },
signal,
})
// double-cast-allowed: contract models the pause point as z.record; the resume UI uses the richer PauseContextDetail interface
return raw as unknown as PauseContextDetail
} catch (error) {
if (isApiClientError(error) && error.status === 404) return null
throw error
}
},
enabled: Boolean(workflowId && executionId && contextId),
staleTime: 0,
})
}
/**
* Submits a resume for a pause context and invalidates the execution + context
* queries so the cache reconciles with the server. The POST stays a raw fetch:
* the route reads the body with a tolerant JSON parse and the contract has no
* body schema, so it cannot go through `requestJson`.
*/
export function useResumeContext() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workflowId,
executionId,
contextId,
input,
}: ResumeContextVariables): Promise<ResumeContextResult> => {
// 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}/${contextId}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input ? { input } : {}),
})
const payload = await response.json().catch(() => ({}))
return { ok: response.ok, payload }
},
onSettled: (_data, _error, variables) =>
Promise.all([
queryClient.invalidateQueries({
queryKey: resumeKeys.execution(variables.workflowId, variables.executionId),
}),
queryClient.invalidateQueries({
queryKey: resumeKeys.context(
variables.workflowId,
variables.executionId,
variables.contextId
),
}),
]),
})
}
+43 -32
View File
@@ -173,18 +173,22 @@ export function useReactivateSchedule() {
return { workflowId, blockId, workspaceId }
},
onSuccess: ({ workflowId, blockId, workspaceId }) => {
onSuccess: ({ workflowId, blockId }) => {
logger.info('Schedule reactivated', { workflowId, blockId })
queryClient.invalidateQueries({
queryKey: scheduleKeys.schedule(workflowId, blockId),
})
if (workspaceId) {
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
}
},
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(),
])
},
})
}
@@ -209,13 +213,16 @@ export function useDisableSchedule() {
return { workspaceId }
},
onSuccess: ({ workspaceId }) => {
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() })
},
onError: (error) => {
logger.error('Failed to disable schedule', { error })
},
onSettled: async (data) => {
if (!data) return
await Promise.all([
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
])
},
})
}
@@ -239,13 +246,16 @@ export function useDeleteSchedule() {
return { workspaceId }
},
onSuccess: ({ workspaceId }) => {
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() })
},
onError: (error) => {
logger.error('Failed to delete schedule', { error })
},
onSettled: async (data) => {
if (!data) return
await Promise.all([
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
])
},
})
}
@@ -271,13 +281,16 @@ export function useUpdateSchedule() {
return { workspaceId }
},
onSuccess: ({ workspaceId }) => {
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() })
},
onError: (error) => {
logger.error('Failed to update schedule', { error })
},
onSettled: async (data) => {
if (!data) return
await Promise.all([
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(data.workspaceId) }),
queryClient.invalidateQueries({ queryKey: scheduleKeys.details() }),
])
},
})
}
@@ -314,12 +327,11 @@ export function useCreateSchedule() {
},
})
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(variables.workspaceId) })
},
onError: (error) => {
logger.error('Failed to create schedule', { error })
},
onSettled: (_data, _error, variables) =>
queryClient.invalidateQueries({ queryKey: scheduleKeys.list(variables.workspaceId) }),
})
}
@@ -339,19 +351,18 @@ export function useRedeployWorkflowSchedule() {
},
onSuccess: ({ workflowId, blockId }) => {
logger.info('Workflow redeployed for schedule reset', { workflowId, blockId })
queryClient.invalidateQueries({
queryKey: scheduleKeys.schedule(workflowId, blockId),
})
// Also invalidate deployment queries since we redeployed
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(workflowId),
})
},
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) }),
])
},
})
}
+2
View File
@@ -85,6 +85,8 @@ export function useCreateSkill() {
return Array.from(byId.values())
}
)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
},
})
+36 -20
View File
@@ -258,7 +258,10 @@ export function useUpdateUsageLimit() {
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
return Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
])
},
})
}
@@ -278,18 +281,23 @@ export function useUpgradeSubscription() {
mutationFn: async ({ plan }: UpgradeSubscriptionParams) => {
return { plan }
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.billing(variables.orgId),
})
queryClient.invalidateQueries({
queryKey: organizationKeys.subscription(variables.orgId),
})
}
onSettled: (_data, _error, variables) => {
return Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
queryClient.invalidateQueries({ queryKey: subscriptionKeys.invoicesAll() }),
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }),
...(variables.orgId
? [
queryClient.invalidateQueries({
queryKey: organizationKeys.billing(variables.orgId),
}),
queryClient.invalidateQueries({
queryKey: organizationKeys.subscription(variables.orgId),
}),
]
: []),
])
},
})
}
@@ -312,13 +320,21 @@ export function usePurchaseCredits() {
body: { amount, requestId },
})
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() })
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() })
if (variables.orgId) {
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
}
onSettled: (_data, _error, variables) => {
return Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
...(variables.orgId
? [
queryClient.invalidateQueries({
queryKey: organizationKeys.billing(variables.orgId),
}),
queryClient.invalidateQueries({
queryKey: organizationKeys.subscription(variables.orgId),
}),
]
: []),
])
},
})
}
+2 -1
View File
@@ -1214,7 +1214,8 @@ export function useUpdateTableMetadata({ workspaceId, tableId }: RowMutationCont
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
// exact: rowsRoot nests under detail, so a prefix match would needlessly refetch all rows
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true })
},
})
}
+1 -1
View File
@@ -91,7 +91,7 @@ export function useUpdateUserProfile() {
logger.error('Failed to update profile:', err)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: userProfileKeys.profile() })
return queryClient.invalidateQueries({ queryKey: userProfileKeys.profile() })
},
})
}
@@ -2,5 +2,6 @@ export const customToolsKeys = {
all: ['customTools'] as const,
lists: () => [...customToolsKeys.all, 'list'] as const,
list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const,
detail: (toolId: string) => [...customToolsKeys.all, 'detail', toolId] as const,
details: () => [...customToolsKeys.all, 'detail'] as const,
detail: (toolId: string) => [...customToolsKeys.details(), toolId] as const,
}
@@ -67,7 +67,7 @@ export function createOptimisticMutationHandlers<TData, TVariables, TItem>(
},
onSettled: (_data: TData | undefined, _error: Error | null, variables: TVariables) => {
queryClient.invalidateQueries({ queryKey: getQueryKey(variables) })
return queryClient.invalidateQueries({ queryKey: getQueryKey(variables) })
},
}
}
@@ -8,6 +8,6 @@ export const workflowKeys = {
deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const,
deploymentVersion: (workflowId: string | undefined, version: number | undefined) =>
[...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const,
state: (workflowId: string | undefined) =>
[...workflowKeys.all, 'state', workflowId ?? ''] as const,
states: () => [...workflowKeys.all, 'state'] as const,
state: (workflowId: string | undefined) => [...workflowKeys.states(), workflowId ?? ''] as const,
}
+32 -3
View File
@@ -1,16 +1,18 @@
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
type ListWebhooksByBlockResponse,
listWebhooksByBlockContract,
updateWebhookContract,
type WebhookData,
} from '@/lib/api/contracts/webhooks'
export const webhookKeys = {
all: ['webhooks'] as const,
byBlock: (workflowId: string, blockId: string) =>
[...webhookKeys.all, workflowId, blockId] as const,
details: () => [...webhookKeys.all, 'detail'] as const,
byBlock: (workflowId?: string, blockId?: string) =>
[...webhookKeys.details(), workflowId ?? '', blockId ?? ''] as const,
}
export type { WebhookData }
@@ -46,3 +48,30 @@ export function useWebhookQuery(workflowId: string, blockId: string, enabled = t
staleTime: 60 * 1000,
})
}
interface ReactivateWebhookVariables {
webhookId: string
workflowId: string
blockId: string
}
/**
* Reactivates a disabled webhook and invalidates the block's webhook query so
* the shared cache (read by useWebhookQuery / useWebhookManagement) reflects the
* new active state immediately instead of serving the stale value for staleTime.
*/
export function useReactivateWebhook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ webhookId }: ReactivateWebhookVariables) =>
requestJson(updateWebhookContract, {
params: { id: webhookId },
body: { isActive: true, failedCount: 0 },
}),
onSettled: (_data, _error, variables) =>
queryClient.invalidateQueries({
queryKey: webhookKeys.byBlock(variables.workflowId, variables.blockId),
}),
})
}
+39 -33
View File
@@ -172,8 +172,8 @@ export function useCreateWorkflowMcpServer() {
logger.info(`Created workflow MCP server: ${name}`)
return data.data.server
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
onSettled: (_data, _error, variables) => {
return queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
})
},
@@ -211,13 +211,15 @@ export function useUpdateWorkflowMcpServer() {
logger.info(`Updated workflow MCP server: ${serverId}`)
return data.data.server
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
})
onSettled: (_data, _error, variables) => {
return Promise.all([
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
}),
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
}),
])
},
})
}
@@ -243,8 +245,8 @@ export function useDeleteWorkflowMcpServer() {
logger.info(`Deleted workflow MCP server: ${serverId}`)
return data
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
onSettled: (_data, _error, variables) => {
return queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
})
},
@@ -284,16 +286,18 @@ export function useAddWorkflowMcpTool() {
logger.info(`Added tool to workflow MCP server: ${serverId}`)
return data.data.tool
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
})
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
})
onSettled: (_data, _error, variables) => {
return Promise.all([
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
}),
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
}),
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
}),
])
},
})
}
@@ -329,8 +333,8 @@ export function useUpdateWorkflowMcpTool() {
logger.info(`Updated tool ${toolId} in workflow MCP server: ${serverId}`)
return data.data.tool
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
onSettled: (_data, _error, variables) => {
return queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
})
},
@@ -359,16 +363,18 @@ export function useDeleteWorkflowMcpTool() {
logger.info(`Deleted tool ${toolId} from workflow MCP server: ${serverId}`)
return data
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
})
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
})
onSettled: (_data, _error, variables) => {
return Promise.all([
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
}),
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
}),
queryClient.invalidateQueries({
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
}),
])
},
})
}
+14 -12
View File
@@ -578,18 +578,20 @@ export function useRevertToVersion() {
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workflowKeys.state(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.deployedState(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
return Promise.all([
queryClient.invalidateQueries({
queryKey: workflowKeys.state(variables.workflowId),
}),
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
}),
queryClient.invalidateQueries({
queryKey: deploymentKeys.deployedState(variables.workflowId),
}),
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
}),
])
},
})
}
+6 -1
View File
@@ -30,9 +30,14 @@ interface AllowedIntegrationsResponse {
allowedIntegrations: string[] | null
}
const allowedIntegrationsKeys = {
all: ['allowedIntegrations'] as const,
env: () => [...allowedIntegrationsKeys.all, 'env'] as const,
}
function useAllowedIntegrationsFromEnv() {
return useQuery<AllowedIntegrationsResponse>({
queryKey: ['allowedIntegrations', 'env'],
queryKey: allowedIntegrationsKeys.env(),
queryFn: async ({ signal }) => {
try {
return await requestJson(getAllowedIntegrationsContract, { signal })
+25
View File
@@ -885,3 +885,28 @@ export const resumeWorkflowExecutionContextContract = defineRouteContract({
schema: resumeWorkflowExecutionContextResponseSchema,
},
})
/**
* Detail for a single pause context. The `pausePoint`/`queue`/`activeResumeEntry`
* shapes are modeled loosely (the pause point's `response.data` is block- and
* user-defined) — consistent with how the parent execution detail contract
* (`pausedWorkflowExecutionDetailSchema`) keeps its pause points as records.
*/
const pauseContextDetailResponseSchema = z
.object({
execution: pausedWorkflowExecutionSummarySchema,
pausePoint: z.record(z.string(), z.unknown()),
queue: z.array(z.record(z.string(), z.unknown())),
activeResumeEntry: z.record(z.string(), z.unknown()).nullable().optional(),
})
.passthrough()
export const getPauseContextDetailContract = defineRouteContract({
method: 'GET',
path: '/api/resume/[workflowId]/[executionId]/[contextId]',
params: resumeExecutionContextParamsSchema,
response: {
mode: 'json',
schema: pauseContextDetailResponseSchema,
},
})
+1
View File
@@ -26,6 +26,7 @@
"check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline",
"check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts",
"check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts",
"check:react-query": "bun run scripts/check-react-query-patterns.ts --check",
"check:utils": "bun run scripts/check-utils-enforcement.ts",
"mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts",
"mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check",
@@ -0,0 +1,4 @@
{
"generatedFrom": "apps/sim (non-strict zone)",
"counts": {}
}
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bun
/**
* Enforces the project's React Query (TanStack Query v5) conventions.
*
* Biome and `check-api-validation-contracts.ts` cover formatting and the
* raw-fetch / contract boundary. This script catches React-Query-specific
* anti-patterns that neither covers:
*
* 1. missing-stale-time — useQuery/useInfiniteQuery/useSuspenseQuery without an explicit `staleTime`
* 2. queryfn-no-signal — an inline `queryFn` that takes no args (cannot forward the AbortSignal)
* 3. inline-query-key — `queryKey: ['literal', ...]` instead of a colocated key factory
* 4. key-factory-no-root — a `*Keys` factory in hooks/queries/** without an `all` root key
*
* Enforcement model (mirrors check-api-validation-contracts.ts):
* - STRICT ZONE (apps/sim/hooks/queries/**): zero tolerance — any violation fails.
* - Elsewhere under apps/sim/**: ratcheted against scripts/check-react-query-patterns.baseline.json
* (fails only when a category's count rises above the recorded baseline).
*
* Escape hatch: put `// rq-lint-allow: <reason>` on the line directly above the
* flagged construct (up to 3 preceding comment lines tolerated). The reason must
* be non-empty.
*
* Usage:
* bun run scripts/check-react-query-patterns.ts # report
* bun run scripts/check-react-query-patterns.ts --check # CI gate (strict zone + ratchet)
* bun run scripts/check-react-query-patterns.ts --update-baseline
*/
import { readdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dir, '..')
const APP_DIR = path.join(ROOT, 'apps/sim')
const BASELINE_PATH = path.join(ROOT, 'scripts/check-react-query-patterns.baseline.json')
const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage', 'dist', 'build'])
const STRICT_PREFIX = 'apps/sim/hooks/queries/'
const ALLOW = 'rq-lint-allow:'
type Category =
| 'missing-stale-time'
| 'queryfn-no-signal'
| 'inline-query-key'
| 'key-factory-no-root'
interface Violation {
file: string
line: number
category: Category
message: string
snippet: string
}
const SUGGESTION: Record<Category, string> = {
'missing-stale-time':
'add an explicit staleTime (default 0 is rarely correct); e.g. staleTime: 60 * 1000',
'queryfn-no-signal':
'destructure the AbortSignal: queryFn: ({ signal }) => fetchX(..., signal) and forward it',
'inline-query-key':
'use a colocated hierarchical key factory (entityKeys.list(id)) instead of an inline literal key',
'key-factory-no-root':
'every *Keys factory in hooks/queries/** must expose an `all` root key for prefix invalidation',
}
async function walk(dir: string, out: string[] = []): Promise<string[]> {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) await walk(full, out)
else if (/\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.(ts|tsx)$/.test(entry.name))
out.push(full)
}
return out
}
/** Returns the substring of `src` for the balanced bracket group starting at `open` (index of the opening bracket). */
function matchBalanced(src: string, open: number, openCh: string, closeCh: string): string {
let depth = 0
let inStr: string | null = null
for (let i = open; i < src.length; i++) {
const ch = src[i]
const prev = src[i - 1]
if (inStr) {
if (ch === inStr && prev !== '\\') inStr = null
continue
}
if (ch === '"' || ch === "'" || ch === '`') {
inStr = ch
continue
}
if (ch === openCh) depth++
else if (ch === closeCh) {
depth--
if (depth === 0) return src.slice(open, i + 1)
}
}
return src.slice(open)
}
function lineAt(content: string, index: number): number {
let line = 1
for (let i = 0; i < index && i < content.length; i++) if (content[i] === '\n') line++
return line
}
/** True if a `// rq-lint-allow: <reason>` annotation sits within the 3 lines above `line` (1-based). */
function hasAllow(lines: string[], line: number): boolean {
for (let i = line - 2; i >= 0 && i >= line - 5; i--) {
const text = lines[i]?.trim() ?? ''
if (text.includes(ALLOW)) {
const reason = text.slice(text.indexOf(ALLOW) + ALLOW.length).trim()
return reason.length > 0
}
if (text.length > 0 && !text.startsWith('//') && !text.startsWith('*')) break
}
return false
}
const QUERY_CALL = /\b(useQuery|useInfiniteQuery|useSuspenseQuery|useSuspenseInfiniteQuery)\s*\(/g
const QUERYFN_NOARG = /queryFn\s*:\s*(?:async\s+)?\(\s*\)\s*=>/
const QUERYFN_PRESENT = /queryFn\s*:/
const INLINE_KEY = /queryKey\s*:\s*\[\s*[`'"]/
const KEYS_FACTORY = /\b(?:export\s+)?const\s+\w*[kK]eys\s*[:=][^=]*?=?\s*\{/g
function scanFile(rel: string, content: string): Violation[] {
const lines = content.split('\n')
const violations: Violation[] = []
const add = (index: number, category: Category, snippet: string) => {
const line = lineAt(content, index)
if (hasAllow(lines, line)) return
violations.push({ file: rel, line, category, message: SUGGESTION[category], snippet })
}
// 1 & 2: query call objects — staleTime + queryFn signal
QUERY_CALL.lastIndex = 0
let m: RegExpExecArray | null = QUERY_CALL.exec(content)
for (; m !== null; m = QUERY_CALL.exec(content)) {
const parenStart = m.index + m[0].length - 1
const arg = matchBalanced(content, parenStart, '(', ')')
// Only inspect the literal options object form `useQuery({ ... })`.
const braceRel = arg.indexOf('{')
if (braceRel === -1) continue
const obj = matchBalanced(arg, braceRel, '{', '}')
// Accept both `staleTime: ...` and the shorthand `staleTime,`; skip objects that spread options (...opts).
if (!/\bstaleTime\b/.test(obj) && !/\.\.\.\w/.test(obj)) {
add(m.index, 'missing-stale-time', `${m[1]}({ ... }) without staleTime`)
}
if (QUERYFN_PRESENT.test(obj) && QUERYFN_NOARG.test(obj)) {
add(m.index, 'queryfn-no-signal', `${m[1]} queryFn takes no args`)
}
}
// 3: inline query keys
for (let i = 0; i < lines.length; i++) {
if (INLINE_KEY.test(lines[i])) {
const line = i + 1
if (!hasAllow(lines, line)) {
violations.push({
file: rel,
line,
category: 'inline-query-key',
message: SUGGESTION['inline-query-key'],
snippet: lines[i].trim(),
})
}
}
}
// 4: key factory must have an `all` root (hooks/queries/** only, excluding util key files that compose others)
if (rel.startsWith(STRICT_PREFIX)) {
KEYS_FACTORY.lastIndex = 0
let k: RegExpExecArray | null = KEYS_FACTORY.exec(content)
for (; k !== null; k = KEYS_FACTORY.exec(content)) {
const braceIdx = content.indexOf('{', k.index)
if (braceIdx === -1) continue
const obj = matchBalanced(content, braceIdx, '{', '}')
if (!/\ball\s*:/.test(obj)) {
add(k.index, 'key-factory-no-root', 'key factory missing `all` root key')
}
}
}
return violations
}
interface Baseline {
generatedFrom: string
counts: Record<string, number>
}
async function loadBaseline(): Promise<Baseline> {
try {
return JSON.parse(await readFile(BASELINE_PATH, 'utf8'))
} catch {
return { generatedFrom: 'none', counts: {} }
}
}
async function main() {
const update = process.argv.includes('--update-baseline')
const check = process.argv.includes('--check')
const files = await walk(APP_DIR)
const all: Violation[] = []
for (const file of files) {
const rel = path.relative(ROOT, file)
const content = await readFile(file, 'utf8')
if (!/\buse(Query|InfiniteQuery|SuspenseQuery|Mutation)\b|[kK]eys\s*[:=]/.test(content))
continue
all.push(...scanFile(rel, content))
}
const strict = all.filter((v) => v.file.startsWith(STRICT_PREFIX))
const ratchet = all.filter((v) => !v.file.startsWith(STRICT_PREFIX))
const counts: Record<string, number> = {}
for (const v of ratchet) counts[v.category] = (counts[v.category] ?? 0) + 1
if (update) {
const baseline: Baseline = { generatedFrom: 'apps/sim (non-strict zone)', counts }
await writeFile(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`)
console.log(`✓ Baseline written: ${JSON.stringify(counts)}`)
process.exit(0)
}
console.log(`React Query pattern audit — scanned ${files.length} files`)
console.log(` strict zone (${STRICT_PREFIX}**) violations: ${strict.length}`)
console.log(` ratchet zone violations: ${ratchet.length} ${JSON.stringify(counts)}`)
let failed = false
if (strict.length > 0) {
failed = true
console.error(`\n✗ ${strict.length} violation(s) in the strict zone (${STRICT_PREFIX}**):\n`)
for (const v of strict) {
console.error(` ${v.file}:${v.line} [${v.category}]`)
console.error(` ${v.snippet}`)
console.error(` → ${v.message}\n`)
}
}
if (!check && ratchet.length > 0) {
console.error(`\nRatchet-zone occurrences (not failing without --check):`)
for (const v of ratchet) {
console.error(` ${v.file}:${v.line} [${v.category}] ${v.snippet}`)
}
}
if (check) {
const baseline = await loadBaseline()
for (const [category, count] of Object.entries(counts)) {
const base = baseline.counts[category] ?? 0
if (count > base) {
failed = true
console.error(
`\n✗ ratchet regression: ${category} rose to ${count} (baseline ${base}). ` +
`Fix the new occurrence(s) or annotate with // ${ALLOW} <reason>.`
)
for (const v of ratchet.filter((x) => x.category === category)) {
console.error(` ${v.file}:${v.line} ${v.snippet}`)
}
}
}
}
if (failed) process.exit(1)
console.log('\n✓ React Query pattern audit passed.')
process.exit(0)
}
main()