Skip to content

Commit f79c50f

Browse files
committed
fix merge conflict
2 parents 9c91cd8 + 11ad891 commit f79c50f

19 files changed

Lines changed: 601 additions & 603 deletions

File tree

apps/sim/app/api/mcp/servers/test-connection/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
validateMcpServerSsrf,
1313
} from '@/lib/mcp/domain-check'
1414
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
15+
import { detectMcpAuthType } from '@/lib/mcp/oauth'
1516
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
16-
import type { McpTransport } from '@/lib/mcp/types'
17+
import type { McpAuthType, McpTransport } from '@/lib/mcp/types'
1718
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
1819

1920
const logger = createLogger('McpServerTestAPI')
@@ -31,6 +32,8 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
3132
interface TestConnectionResult {
3233
success: boolean
3334
error?: string
35+
authRequired?: boolean
36+
authType?: McpAuthType
3437
serverInfo?: {
3538
name: string
3639
version: string
@@ -163,6 +166,18 @@ export const POST = withRouteHandler(
163166
}
164167

165168
const result: TestConnectionResult = { success: false }
169+
170+
// Skip unauth connect when the server returns an RFC 9728 OAuth challenge.
171+
if (testConfig.url) {
172+
const detectedAuthType = await detectMcpAuthType(testConfig.url)
173+
if (detectedAuthType === 'oauth') {
174+
result.authRequired = true
175+
result.authType = 'oauth'
176+
return createMcpSuccessResponse(result, 200)
177+
}
178+
result.authType = detectedAuthType
179+
}
180+
166181
let client: McpClient | null = null
167182

168183
try {

apps/sim/app/api/table/[tableId]/dispatches/route.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { countRunningCells, listActiveDispatches } from '@/lib/table/dispatcher'
8+
import { countActiveRunCells, listActiveDispatches } from '@/lib/table/dispatcher'
99
import { accessError, checkAccess } from '@/app/api/table/utils'
1010

1111
const logger = createLogger('TableDispatchesAPI')
@@ -37,10 +37,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
3737
const result = await checkAccess(tableId, authResult.userId, 'read')
3838
if (!result.ok) return accessError(result, requestId, tableId)
3939

40-
const [rows, running] = await Promise.all([
41-
listActiveDispatches(tableId),
42-
countRunningCells(tableId),
43-
])
40+
const rows = await listActiveDispatches(tableId)
41+
const running = await countActiveRunCells(tableId, rows)
4442
const dispatches: ActiveDispatch[] = rows.map((r) => ({
4543
id: r.id,
4644
status: r.status as 'pending' | 'dispatching',

apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
Textarea,
1818
} from '@/components/emcn'
1919
import { cn } from '@/lib/core/utils/cn'
20-
import type { McpTransport } from '@/lib/mcp/types'
20+
import type { McpAuthType, McpTransport } from '@/lib/mcp/types'
2121
import {
2222
checkEnvVarTrigger,
2323
EnvVarDropdown,
@@ -52,6 +52,7 @@ export interface McpServerFormConfig {
5252
timeout: number
5353
oauthClientId?: string
5454
oauthClientSecret?: string
55+
authType?: McpAuthType
5556
}
5657

5758
export interface McpServerFormModalProps {
@@ -109,11 +110,12 @@ interface EnvVarDropdownConfig {
109110
}
110111

111112
function getTestButtonLabel(
112-
testResult: { success: boolean; error?: string } | null,
113+
testResult: { success: boolean; error?: string; authRequired?: boolean } | null,
113114
isTestingConnection: boolean
114115
): string {
115116
if (isTestingConnection) return 'Testing...'
116117
if (testResult?.success) return 'Connection success'
118+
if (testResult?.authRequired) return 'Requires OAuth'
117119
if (testResult && !testResult.success) return 'No connection: retry'
118120
return 'Test Connection'
119121
}
@@ -517,19 +519,11 @@ export function McpServerFormModal({
517519
workspaceId,
518520
})
519521

520-
if (!connectionResult.success) {
521-
const errorText = (connectionResult.error || '').toLowerCase()
522-
const looksLikeAuthRequired =
523-
/\b401\b/.test(errorText) ||
524-
errorText.includes('unauthorized') ||
525-
errorText.includes('oauth') ||
526-
errorText.includes('authentication')
527-
if (!looksLikeAuthRequired) {
528-
setSubmitError(
529-
connectionResult.error || 'Connection test failed. Please check the URL and try again.'
530-
)
531-
return
532-
}
522+
if (!connectionResult.success && !connectionResult.authRequired) {
523+
setSubmitError(
524+
connectionResult.error || 'Connection test failed. Please check the URL and try again.'
525+
)
526+
return
533527
}
534528

535529
await onSubmit({
@@ -538,6 +532,7 @@ export function McpServerFormModal({
538532
url: formData.url!,
539533
headers,
540534
timeout: formData.timeout || 30000,
535+
authType: connectionResult.authType,
541536
oauthClientId:
542537
mode === 'edit'
543538
? oauthClientIdChanged
@@ -587,7 +582,7 @@ export function McpServerFormModal({
587582
workspaceId,
588583
})
589584

590-
if (!connectionResult.success) {
585+
if (!connectionResult.success && !connectionResult.authRequired) {
591586
setSubmitError(
592587
connectionResult.error || 'Connection test failed. Please check the URL and try again.'
593588
)
@@ -600,6 +595,7 @@ export function McpServerFormModal({
600595
url: config.url,
601596
headers: config.headers,
602597
timeout: 30000,
598+
authType: connectionResult.authType,
603599
})
604600

605601
onOpenChange(false)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -303,33 +303,35 @@ const TYPEWRITER_MS_PER_CHAR = 15
303303
*/
304304
function useTypewriter(text: string | null): string | null {
305305
const [revealed, setRevealed] = useState<string | null>(text)
306-
const isFirstRunRef = useRef(true)
307306
const prevTextRef = useRef<string | null>(text)
307+
const mountedRef = useRef(false)
308+
const animateRef = useRef(false)
308309

309-
useEffect(() => {
310-
if (isFirstRunRef.current) {
311-
isFirstRunRef.current = false
312-
prevTextRef.current = text
313-
setRevealed(text)
314-
return
315-
}
316-
if (prevTextRef.current === text) return
310+
// Reset synchronously during render when `text` changes (not on first mount)
311+
// so no frame ever shows the full new value before the animation begins —
312+
// an effect-based reset lands one frame late and flashes the whole text.
313+
if (prevTextRef.current !== text) {
317314
prevTextRef.current = text
315+
const animate = mountedRef.current && text !== null && text.length > 0
316+
animateRef.current = animate
317+
setRevealed(animate ? '' : text)
318+
}
318319

319-
if (text === null || text.length === 0) {
320-
setRevealed(text)
321-
return
322-
}
320+
useEffect(() => {
321+
mountedRef.current = true
322+
}, [])
323323

324-
const full = text
324+
useEffect(() => {
325+
if (!animateRef.current) return
326+
animateRef.current = false
327+
const full = text as string
325328
const start = performance.now()
326329
let raf = 0
327330
const tick = (now: number) => {
328331
const chars = Math.min(full.length, Math.floor((now - start) / TYPEWRITER_MS_PER_CHAR))
329332
setRevealed(full.slice(0, chars))
330333
if (chars < full.length) raf = requestAnimationFrame(tick)
331334
}
332-
setRevealed('')
333335
raf = requestAnimationFrame(tick)
334336
return () => cancelAnimationFrame(raf)
335337
}, [text])

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ export function useTableEventStream({
7373
let lastEventId = loadPointer(tableId)
7474
let reconnectAttempt = 0
7575

76-
const updateRunStateCounters = (
77-
rowId: string,
78-
wasInFlight: boolean,
79-
isInFlight: boolean
80-
): void => {
76+
// Keeps the per-row gutter (`runningByRowId`) live between dispatch events.
77+
// `runningCellCount` (the "X running" badge) is NOT touched here — it's the
78+
// server's dispatch-scope count, seeded optimistically on click and
79+
// re-synced by `applyDispatch` on every window, so live matches reload.
80+
const updateRunningByRow = (rowId: string, wasInFlight: boolean, isInFlight: boolean): void => {
8181
if (wasInFlight === isInFlight) return
8282
const delta = isInFlight ? 1 : -1
8383
queryClient.setQueryData<TableRunState>(tableKeys.activeDispatches(tableId), (prev) => {
@@ -87,11 +87,7 @@ export function useTableEventStream({
8787
const nextByRow = { ...prev.runningByRowId }
8888
if (nextForRow === 0) delete nextByRow[rowId]
8989
else nextByRow[rowId] = nextForRow
90-
return {
91-
...prev,
92-
runningCellCount: Math.max(0, prev.runningCellCount + delta),
93-
runningByRowId: nextByRow,
94-
}
90+
return { ...prev, runningByRowId: nextByRow }
9591
})
9692
}
9793

@@ -145,11 +141,7 @@ export function useTableEventStream({
145141
queryKey: tableKeys.activeDispatches(tableId),
146142
})
147143
} else {
148-
updateRunStateCounters(
149-
rowId,
150-
wasInFlight,
151-
isExecInFlight({ status } as RowExecutionMetadata)
152-
)
144+
updateRunningByRow(rowId, wasInFlight, isExecInFlight({ status } as RowExecutionMetadata))
153145
}
154146
}
155147

@@ -195,6 +187,11 @@ export function useTableEventStream({
195187
merged[idx] = next
196188
return { ...base, dispatches: merged }
197189
})
190+
// The dispatcher emits this once per window (after the window's cells
191+
// finish + the cursor advances) and on completion. Re-sync the
192+
// dispatch-scope `runningCellCount` from the server so the badge steps
193+
// down per window and matches a reload exactly.
194+
void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) })
198195
}
199196

200197
const handlePrune = (payload: PrunedEvent): void => {

apps/sim/background/cleanup-logs.ts

Lines changed: 33 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { jobExecutionLogs, pausedExecutions, workflowExecutionLogs } from '@sim/
33
import { createLogger } from '@sim/logger'
44
import { task } from '@trigger.dev/sdk'
55
import { and, eq, inArray, isNull, lt, notInArray, or, sql } from 'drizzle-orm'
6-
import { type CleanupJobPayload, resolveCleanupScope } from '@/lib/billing/cleanup-dispatcher'
6+
import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher'
77
import {
88
batchDeleteByWorkspaceAndTimestamp,
9+
chunkArray,
910
chunkedBatchDelete,
1011
type TableCleanupResult,
1112
} from '@/lib/cleanup/batch-delete'
@@ -27,33 +28,36 @@ interface FileDeleteStats {
2728

2829
const RESUMABLE_PAUSED_STATUSES = ['paused', 'partially_resumed', 'cancelling']
2930

31+
/** Caps the per-row predicate cost: keys-per-row is `O(chunk)` not `O(uniqueKeys)`. */
32+
const REFERENCE_CHECK_KEY_CHUNK_SIZE = 200
33+
34+
/**
35+
* One `LATERAL unnest` scan per chunk replaces N per-key sequential scans
36+
* (each detoasting the entire JSONB column). Substring semantics identical.
37+
*/
3038
async function filterLargeValueKeysWithoutRetainedReferences(
3139
keys: string[],
3240
deletedLogIds: string[],
3341
workspaceIds: string[]
3442
): Promise<string[]> {
3543
if (keys.length === 0 || deletedLogIds.length === 0 || workspaceIds.length === 0) return []
3644

37-
const unreferencedKeys: string[] = []
38-
for (const key of Array.from(new Set(keys))) {
39-
const [referencingLog] = await db
40-
.select({ id: workflowExecutionLogs.id })
41-
.from(workflowExecutionLogs)
42-
.where(
43-
and(
44-
inArray(workflowExecutionLogs.workspaceId, workspaceIds),
45-
notInArray(workflowExecutionLogs.id, deletedLogIds),
46-
sql`position(${key} in ${workflowExecutionLogs.executionData}::text) > 0`
47-
)
48-
)
49-
.limit(1)
50-
51-
if (!referencingLog) {
52-
unreferencedKeys.push(key)
53-
}
45+
const uniqueKeys = Array.from(new Set(keys))
46+
const referencedKeys = new Set<string>()
47+
48+
for (const keyChunk of chunkArray(uniqueKeys, REFERENCE_CHECK_KEY_CHUNK_SIZE)) {
49+
const rows = await db.execute<{ key: string }>(sql`
50+
SELECT DISTINCT k.key AS key
51+
FROM ${workflowExecutionLogs} AS wel,
52+
unnest(${keyChunk}::text[]) AS k(key)
53+
WHERE wel.workspace_id = ANY(${workspaceIds}::text[])
54+
AND wel.id <> ALL(${deletedLogIds}::text[])
55+
AND position(k.key in wel.execution_data::text) > 0
56+
`)
57+
for (const row of rows) referencedKeys.add(row.key)
5458
}
5559

56-
return unreferencedKeys
60+
return uniqueKeys.filter((key) => !referencedKeys.has(key))
5761
}
5862

5963
async function deleteExecutionFiles(files: unknown, stats: FileDeleteStats): Promise<void> {
@@ -159,14 +163,7 @@ async function cleanupWorkflowExecutionLogs(
159163
return { ...dbStats, ...fileStats }
160164
}
161165

162-
async function cleanupFreePlanOrphanedSnapshots(
163-
payload: CleanupJobPayload,
164-
retentionHours: number
165-
): Promise<void> {
166-
if (payload.plan !== 'free' || payload.runGlobalMaintenance !== true) {
167-
return
168-
}
169-
166+
async function cleanupFreePlanOrphanedSnapshots(retentionHours: number): Promise<void> {
170167
try {
171168
const retentionDays = Math.floor(retentionHours / 24)
172169
const snapshotsCleaned = await snapshotService.cleanupOrphanedSnapshots(retentionDays + 1)
@@ -178,20 +175,15 @@ async function cleanupFreePlanOrphanedSnapshots(
178175

179176
export async function runCleanupLogs(payload: CleanupJobPayload): Promise<void> {
180177
const startTime = Date.now()
181-
182-
const scope = await resolveCleanupScope('cleanup-logs', payload)
183-
if (!scope) {
184-
logger.info(`[${payload.plan}] No retention configured, skipping`)
185-
return
186-
}
187-
188-
const { workspaceIds, retentionHours, label } = scope
178+
const { workspaceIds, retentionHours, label, plan, runGlobalHousekeeping } = payload
189179

190180
const retentionDate = new Date(Date.now() - retentionHours * 60 * 60 * 1000)
191181

192182
if (workspaceIds.length === 0) {
193183
logger.info(`[${label}] No workspaces to process`)
194-
await cleanupFreePlanOrphanedSnapshots(payload, retentionHours)
184+
if (runGlobalHousekeeping && plan === 'free') {
185+
await cleanupFreePlanOrphanedSnapshots(retentionHours)
186+
}
195187
return
196188
}
197189

@@ -216,17 +208,17 @@ export async function runCleanupLogs(payload: CleanupJobPayload): Promise<void>
216208
tableName: `${label}/job_execution_logs`,
217209
})
218210

219-
await cleanupFreePlanOrphanedSnapshots(payload, retentionHours)
211+
if (runGlobalHousekeeping && plan === 'free') {
212+
await cleanupFreePlanOrphanedSnapshots(retentionHours)
213+
}
220214

221215
const timeElapsed = (Date.now() - startTime) / 1000
222216
logger.info(`[${label}] Job completed in ${timeElapsed.toFixed(2)}s`)
223217
}
224218

225219
export const cleanupLogsTask = task({
226220
id: 'cleanup-logs',
227-
queue: {
228-
name: 'cleanup-logs',
229-
concurrencyLimit: 1,
230-
},
221+
machine: 'large-1x',
222+
queue: { concurrencyLimit: 5 },
231223
run: runCleanupLogs,
232224
})

0 commit comments

Comments
 (0)