Compare commits

..

2 Commits

Author SHA1 Message Date
waleed
060237f4dd fix(slack): resolve file metadata via files.info when event payload is partial 2026-02-09 19:19:38 -08:00
Waleed
622d0cad22 Merge pull request #3172 from simstudioai/fix/notifs
fix(notifications): throw notification on runtime errors, move predeploy checks to update in deploy modal
2026-02-09 11:49:58 -08:00
5 changed files with 142 additions and 124 deletions

View File

@@ -1,38 +0,0 @@
# Sim Cloud Agent Guide
## Project Overview
Sim is an AI agent workflow builder. Turborepo monorepo with Bun workspaces.
### Services
| Service | Port | Command |
|---------|------|---------|
| Next.js App | 3000 | `bun run dev` (from root) |
| Realtime Socket Server | 3002 | `cd apps/sim && bun run dev:sockets` |
| Both together | 3000+3002 | `bun run dev:full` (from root) |
| Docs site | 3001 | `cd apps/docs && bun run dev` |
| PostgreSQL (pgvector) | 5432 | Docker container `simstudio-db` |
## Common Commands
- **Lint**: `bun run lint:check` (read-only) or `bun run lint` (auto-fix)
- **Format**: `bun run format:check` (read-only) or `bun run format` (auto-fix)
- **Test**: `bun run test` (all packages via turborepo)
- **Test single app**: `cd apps/sim && bunx vitest run`
- **Type check**: `bun run type-check`
- **Dev**: `bun run dev:full` (Next.js app + realtime socket server)
- **DB migrations**: `cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts`
## Architecture Notes
- Package manager is **bun** (not npm/npx). Use `bun` and `bunx`.
- Linter/formatter is **Biome** (not ESLint/Prettier).
- Testing framework is **Vitest** with `@sim/testing` for shared mocks/factories.
- Database uses **Drizzle ORM** with PostgreSQL + pgvector.
- Auth is **Better Auth** (session cookies).
- Pre-commit hook runs `bunx lint-staged` which applies `biome check --write`.
- `.npmrc` has `ignore-scripts=true`.
- Docs app requires `fumadocs-mdx` generation before type-check (`bunx fumadocs-mdx` in `apps/docs/`).
- Coding guidelines are in `CLAUDE.md` (root) and `.cursor/rules/*.mdc`.
- See `.github/CONTRIBUTING.md` for contribution workflow details.

View File

@@ -19,6 +19,7 @@ import {
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
import { startsWithUuid } from '@/executor/constants'
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
@@ -38,6 +39,7 @@ import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { A2aDeploy } from './components/a2a/a2a'
@@ -335,6 +337,20 @@ export function DeployModal({
setDeployError(null)
setDeployWarnings([])
const { blocks, edges, loops, parallels } = useWorkflowStore.getState()
const liveBlocks = mergeSubblockState(blocks, workflowId)
const checkResult = runPreDeployChecks({
blocks: liveBlocks,
edges,
loops,
parallels,
workflowId,
})
if (!checkResult.passed) {
setDeployError(checkResult.error || 'Pre-deploy validation failed')
return
}
try {
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
if (result.warnings && result.warnings.length > 0) {

View File

@@ -2,9 +2,6 @@ import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { runPreDeployChecks } from './use-predeploy-checks'
const logger = createLogger('useDeployment')
@@ -25,36 +22,16 @@ export function useDeployment({
const [isDeploying, setIsDeploying] = useState(false)
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
const addNotification = useNotificationStore((state) => state.addNotification)
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
const loops = useWorkflowStore((state) => state.loops)
const parallels = useWorkflowStore((state) => state.parallels)
/**
* Handle deploy button click
* First deploy: calls API to deploy, then opens modal on success
* Redeploy: validates client-side, then opens modal if valid
* Already deployed: opens modal directly (validation happens on Update in modal)
*/
const handleDeployClick = useCallback(async () => {
if (!workflowId) return { success: false, shouldOpenModal: false }
if (isDeployed) {
const liveBlocks = mergeSubblockState(blocks, workflowId)
const checkResult = runPreDeployChecks({
blocks: liveBlocks,
edges,
loops,
parallels,
workflowId,
})
if (!checkResult.passed) {
addNotification({
level: 'error',
message: checkResult.error || 'Pre-deploy validation failed',
workflowId,
})
return { success: false, shouldOpenModal: false }
}
return { success: true, shouldOpenModal: true }
}
@@ -101,17 +78,7 @@ export function useDeployment({
} finally {
setIsDeploying(false)
}
}, [
workflowId,
isDeployed,
blocks,
edges,
loops,
parallels,
refetchDeployedState,
setDeploymentStatus,
addNotification,
])
}, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus, addNotification])
return {
isDeploying,

View File

@@ -527,17 +527,61 @@ export async function validateTwilioSignature(
}
}
const SLACK_FILE_HOSTS = new Set(['files.slack.com', 'files-pri.slack.com'])
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
const SLACK_MAX_FILES = 10
const SLACK_MAX_FILES = 15
/**
* Resolves the full file object from the Slack API when the event payload
* only contains a partial file (e.g. missing url_private due to file_access restrictions).
* @see https://docs.slack.dev/reference/methods/files.info
*/
async function resolveSlackFileInfo(
fileId: string,
botToken: string
): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> {
try {
const response = await fetch(
`https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`,
{
headers: { Authorization: `Bearer ${botToken}` },
}
)
const data = (await response.json()) as {
ok: boolean
error?: string
file?: Record<string, any>
}
if (!data.ok || !data.file) {
logger.warn('Slack files.info failed', { fileId, error: data.error })
return null
}
return {
url_private: data.file.url_private,
name: data.file.name,
mimetype: data.file.mimetype,
size: data.file.size,
}
} catch (error) {
logger.error('Error calling Slack files.info', {
fileId,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
/**
* Downloads file attachments from Slack using the bot token.
* Returns files in the format expected by WebhookAttachmentProcessor:
* { name, data (base64 string), mimeType, size }
*
* When the event payload contains partial file objects (missing url_private),
* falls back to the Slack files.info API to resolve the full file metadata.
*
* Security:
* - Validates each url_private against allowlisted Slack file hosts
* - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF
* - Enforces per-file size limit and max file count
*/
@@ -549,30 +593,31 @@ async function downloadSlackFiles(
const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = []
for (const file of filesToProcess) {
const urlPrivate = file.url_private as string | undefined
let urlPrivate = file.url_private as string | undefined
let fileName = file.name as string | undefined
let fileMimeType = file.mimetype as string | undefined
let fileSize = file.size as number | undefined
// If url_private is missing, resolve via files.info API
if (!urlPrivate && file.id) {
const resolved = await resolveSlackFileInfo(file.id, botToken)
if (resolved?.url_private) {
urlPrivate = resolved.url_private
fileName = fileName || resolved.name
fileMimeType = fileMimeType || resolved.mimetype
fileSize = fileSize ?? resolved.size
}
}
if (!urlPrivate) {
continue
}
// Validate the URL points to a known Slack file host
let parsedUrl: URL
try {
parsedUrl = new URL(urlPrivate)
} catch {
logger.warn('Slack file has invalid url_private, skipping', { fileId: file.id })
continue
}
if (!SLACK_FILE_HOSTS.has(parsedUrl.hostname)) {
logger.warn('Slack file url_private points to unexpected host, skipping', {
logger.warn('Slack file has no url_private and could not be resolved, skipping', {
fileId: file.id,
hostname: sanitizeUrlForLog(urlPrivate),
})
continue
}
// Skip files that exceed the size limit
const reportedSize = Number(file.size) || 0
const reportedSize = Number(fileSize) || 0
if (reportedSize > SLACK_MAX_FILE_SIZE) {
logger.warn('Slack file exceeds size limit, skipping', {
fileId: file.id,
@@ -618,9 +663,9 @@ async function downloadSlackFiles(
}
downloaded.push({
name: file.name || 'download',
name: fileName || 'download',
data: buffer.toString('base64'),
mimeType: file.mimetype || 'application/octet-stream',
mimeType: fileMimeType || 'application/octet-stream',
size: buffer.length,
})
} catch (error) {

View File

@@ -62,6 +62,45 @@ const shouldSkipEntry = (output: any): boolean => {
return false
}
interface NotifyBlockErrorParams {
error: unknown
blockName: string
workflowId?: string
logContext: Record<string, unknown>
}
/**
* Sends an error notification for a block failure if error notifications are enabled.
*/
const notifyBlockError = ({ error, blockName, workflowId, logContext }: NotifyBlockErrorParams) => {
const settings = getQueryClient().getQueryData<GeneralSettings>(generalSettingsKeys.settings())
const isErrorNotificationsEnabled = settings?.errorNotificationsEnabled ?? true
if (!isErrorNotificationsEnabled) return
try {
const errorMessage = String(error)
const displayName = blockName || 'Unknown Block'
const displayMessage = `${displayName}: ${errorMessage}`
const copilotMessage = `${errorMessage}\n\nError in ${displayName}.\n\nPlease fix this.`
useNotificationStore.getState().addNotification({
level: 'error',
message: displayMessage,
workflowId,
action: {
type: 'copilot',
message: copilotMessage,
},
})
} catch (notificationError) {
logger.error('Failed to create block error notification', {
...logContext,
error: notificationError,
})
}
}
export const useTerminalConsoleStore = create<ConsoleStore>()(
devtools(
persist(
@@ -154,35 +193,12 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
const newEntry = get().entries[0]
if (newEntry?.error) {
const settings = getQueryClient().getQueryData<GeneralSettings>(
generalSettingsKeys.settings()
)
const isErrorNotificationsEnabled = settings?.errorNotificationsEnabled ?? true
if (isErrorNotificationsEnabled) {
try {
const errorMessage = String(newEntry.error)
const blockName = newEntry.blockName || 'Unknown Block'
const displayMessage = `${blockName}: ${errorMessage}`
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
useNotificationStore.getState().addNotification({
level: 'error',
message: displayMessage,
workflowId: entry.workflowId,
action: {
type: 'copilot',
message: copilotMessage,
},
})
} catch (notificationError) {
logger.error('Failed to create block error notification', {
entryId: newEntry.id,
error: notificationError,
})
}
}
notifyBlockError({
error: newEntry.error,
blockName: newEntry.blockName || 'Unknown Block',
workflowId: entry.workflowId,
logContext: { entryId: newEntry.id },
})
}
return newEntry
@@ -376,6 +392,18 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
return { entries: updatedEntries }
})
if (typeof update === 'object' && update.error) {
const matchingEntry = get().entries.find(
(e) => e.blockId === blockId && e.executionId === executionId
)
notifyBlockError({
error: update.error,
blockName: matchingEntry?.blockName || 'Unknown Block',
workflowId: matchingEntry?.workflowId,
logContext: { blockId },
})
}
},
cancelRunningEntries: (workflowId: string) => {