mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-24 01:23:02 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
+3
-3
@@ -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[]>(() => {
|
||||
|
||||
+12
-53
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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) }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
}
|
||||
@@ -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) }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ export function useCreateSkill() {
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
)
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user