mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
22 Commits
v0.5.104
...
waleedlati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448548ec31 | ||
|
|
5782b6830e | ||
|
|
c4cb2aaf06 | ||
|
|
7b7ab8d2c2 | ||
|
|
3d0b07b555 | ||
|
|
281f5bb213 | ||
|
|
8622ea4349 | ||
|
|
6774ad132b | ||
|
|
94679a6e55 | ||
|
|
0f293e8584 | ||
|
|
5dc31b5cf9 | ||
|
|
24e1bfdf09 | ||
|
|
cf67966e15 | ||
|
|
2a8aae68c5 | ||
|
|
6802245a8a | ||
|
|
0a6a2ee694 | ||
|
|
8579beb199 | ||
|
|
115b4581a5 | ||
|
|
fcdcaed00d | ||
|
|
04fa31864b | ||
|
|
6b355e9b54 | ||
|
|
127994f077 |
@@ -69,7 +69,9 @@ Read records from a ServiceNow table
|
||||
| `number` | string | No | Record number \(e.g., INC0010001\) |
|
||||
| `query` | string | No | Encoded query string \(e.g., "active=true^priority=1"\) |
|
||||
| `limit` | number | No | Maximum number of records to return \(e.g., 10, 50, 100\) |
|
||||
| `offset` | number | No | Number of records to skip for pagination \(e.g., 0, 10, 20\) |
|
||||
| `fields` | string | No | Comma-separated list of fields to return \(e.g., sys_id,number,short_description,state\) |
|
||||
| `displayValue` | string | No | Return display values for reference fields: "true" \(display only\), "false" \(sys_id only\), or "all" \(both\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Slack
|
||||
description: Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events
|
||||
description: Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -39,7 +39,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai](
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
|
||||
|
||||
|
||||
@@ -799,4 +799,128 @@ Add an emoji reaction to a Slack message
|
||||
| ↳ `timestamp` | string | Message timestamp |
|
||||
| ↳ `reaction` | string | Emoji reaction name |
|
||||
|
||||
### `slack_remove_reaction`
|
||||
|
||||
Remove an emoji reaction from a Slack message
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID where the message was posted \(e.g., C1234567890\) |
|
||||
| `timestamp` | string | Yes | Timestamp of the message to remove reaction from \(e.g., 1405894322.002768\) |
|
||||
| `name` | string | Yes | Name of the emoji reaction to remove \(without colons, e.g., thumbsup, heart, eyes\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Success message |
|
||||
| `metadata` | object | Reaction metadata |
|
||||
| ↳ `channel` | string | Channel ID |
|
||||
| ↳ `timestamp` | string | Message timestamp |
|
||||
| ↳ `reaction` | string | Emoji reaction name |
|
||||
|
||||
### `slack_get_channel_info`
|
||||
|
||||
Get detailed information about a Slack channel by its ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID to get information about \(e.g., C1234567890\) |
|
||||
| `includeNumMembers` | boolean | No | Whether to include the member count in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `channelInfo` | object | Detailed channel information |
|
||||
| ↳ `id` | string | Channel ID \(e.g., C1234567890\) |
|
||||
| ↳ `name` | string | Channel name without # prefix |
|
||||
| ↳ `is_channel` | boolean | Whether this is a channel |
|
||||
| ↳ `is_private` | boolean | Whether channel is private |
|
||||
| ↳ `is_archived` | boolean | Whether channel is archived |
|
||||
| ↳ `is_general` | boolean | Whether this is the general channel |
|
||||
| ↳ `is_member` | boolean | Whether the bot/user is a member |
|
||||
| ↳ `is_shared` | boolean | Whether channel is shared across workspaces |
|
||||
| ↳ `is_ext_shared` | boolean | Whether channel is externally shared |
|
||||
| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared |
|
||||
| ↳ `num_members` | number | Number of members in the channel |
|
||||
| ↳ `topic` | string | Channel topic |
|
||||
| ↳ `purpose` | string | Channel purpose/description |
|
||||
| ↳ `created` | number | Unix timestamp when channel was created |
|
||||
| ↳ `creator` | string | User ID of channel creator |
|
||||
| ↳ `updated` | number | Unix timestamp of last update |
|
||||
|
||||
### `slack_get_user_presence`
|
||||
|
||||
Check whether a Slack user is currently active or away
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `userId` | string | Yes | User ID to check presence for \(e.g., U1234567890\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `presence` | string | User presence status: "active" or "away" |
|
||||
| `online` | boolean | Whether user has an active client connection \(only available when checking own presence\) |
|
||||
| `autoAway` | boolean | Whether user was automatically set to away due to inactivity \(only available when checking own presence\) |
|
||||
| `manualAway` | boolean | Whether user manually set themselves as away \(only available when checking own presence\) |
|
||||
| `connectionCount` | number | Total number of active connections for the user \(only available when checking own presence\) |
|
||||
| `lastActivity` | number | Unix timestamp of last detected activity \(only available when checking own presence\) |
|
||||
|
||||
### `slack_edit_canvas`
|
||||
|
||||
Edit an existing Slack canvas by inserting, replacing, or deleting content
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `canvasId` | string | Yes | Canvas ID to edit \(e.g., F1234ABCD\) |
|
||||
| `operation` | string | Yes | Edit operation: insert_at_start, insert_at_end, insert_after, insert_before, replace, delete, or rename |
|
||||
| `content` | string | No | Markdown content for the operation \(required for insert/replace operations\) |
|
||||
| `sectionId` | string | No | Section ID to target \(required for insert_after, insert_before, replace, and delete\) |
|
||||
| `title` | string | No | New title for the canvas \(only used with rename operation\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Success message |
|
||||
|
||||
### `slack_create_channel_canvas`
|
||||
|
||||
Create a canvas pinned to a Slack channel as its resource hub
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `channel` | string | Yes | Channel ID to create the canvas in \(e.g., C1234567890\) |
|
||||
| `title` | string | No | Title for the channel canvas |
|
||||
| `content` | string | No | Canvas content in markdown format |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `canvas_id` | string | ID of the created channel canvas |
|
||||
|
||||
|
||||
|
||||
@@ -32,3 +32,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
|
||||
# Admin API (Optional - for self-hosted GitOps)
|
||||
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
|
||||
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
|
||||
|
||||
# Execute Command (Optional - self-hosted only)
|
||||
# EXECUTE_COMMAND_ENABLED=true # Enable shell command execution in workflows
|
||||
# NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED=true # Show Execute Command block in the UI
|
||||
|
||||
@@ -366,7 +366,7 @@ function resolveWorkflowVariables(
|
||||
const variableName = match[1].trim()
|
||||
|
||||
const foundVariable = Object.entries(workflowVariables).find(
|
||||
([_, variable]) => normalizeName(variable.name || '') === variableName
|
||||
([_, variable]) => normalizeName(variable.name || '') === normalizeName(variableName)
|
||||
)
|
||||
|
||||
if (!foundVariable) {
|
||||
|
||||
424
apps/sim/app/api/tools/execute-command/run/route.ts
Normal file
424
apps/sim/app/api/tools/execute-command/run/route.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { exec } from 'child_process'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { isExecuteCommandEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import { normalizeName, REFERENCE, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants'
|
||||
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
|
||||
import {
|
||||
createEnvVarPattern,
|
||||
createWorkflowVariablePattern,
|
||||
} from '@/executor/utils/reference-validation'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
export const MAX_DURATION = 210
|
||||
|
||||
const logger = createLogger('ExecuteCommandAPI')
|
||||
|
||||
const MAX_BUFFER = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
const SAFE_ENV_KEYS = ['PATH', 'HOME', 'SHELL', 'USER', 'LOGNAME', 'LANG', 'TERM', 'TZ'] as const
|
||||
|
||||
/**
|
||||
* Returns a minimal base environment for child processes.
|
||||
* Only includes POSIX essentials — never server secrets like DATABASE_URL, AUTH_SECRET, etc.
|
||||
*/
|
||||
function getSafeBaseEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { NODE_ENV: process.env.NODE_ENV }
|
||||
for (const key of SAFE_ENV_KEYS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key]
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
...SAFE_ENV_KEYS,
|
||||
'NODE_ENV',
|
||||
'NODE_OPTIONS',
|
||||
'LD_PRELOAD',
|
||||
'LD_LIBRARY_PATH',
|
||||
'LD_AUDIT',
|
||||
'DYLD_INSERT_LIBRARIES',
|
||||
'DYLD_LIBRARY_PATH',
|
||||
'DYLD_FRAMEWORK_PATH',
|
||||
'BASH_ENV',
|
||||
'ENV',
|
||||
'GLIBC_TUNABLES',
|
||||
'NODE_PATH',
|
||||
'PYTHONPATH',
|
||||
'PERL5LIB',
|
||||
'PERL5OPT',
|
||||
'RUBYLIB',
|
||||
'JAVA_TOOL_OPTIONS',
|
||||
])
|
||||
|
||||
/**
|
||||
* Filters user-supplied env vars to prevent overriding safe base env
|
||||
* and injecting process-influencing variables.
|
||||
*/
|
||||
function filterUserEnv(env?: Record<string, string>): Record<string, string> {
|
||||
if (!env) return {}
|
||||
const filtered: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!BLOCKED_ENV_KEYS.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
interface Replacement {
|
||||
index: number
|
||||
length: number
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects workflow variable (<variable.name>) replacements from the original command string
|
||||
*/
|
||||
function collectWorkflowVariableReplacements(
|
||||
command: string,
|
||||
workflowVariables: Record<string, any>
|
||||
): Replacement[] {
|
||||
const regex = createWorkflowVariablePattern()
|
||||
const replacements: Replacement[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = regex.exec(command)) !== null) {
|
||||
const variableName = match[1].trim()
|
||||
const foundVariable = Object.entries(workflowVariables).find(
|
||||
([_, variable]) => normalizeName(variable.name || '') === normalizeName(variableName)
|
||||
)
|
||||
|
||||
if (!foundVariable) {
|
||||
const availableVars = Object.values(workflowVariables)
|
||||
.map((v: any) => v.name)
|
||||
.filter(Boolean)
|
||||
throw new Error(
|
||||
`Variable "${variableName}" doesn't exist.` +
|
||||
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
const variable = foundVariable[1]
|
||||
let value = variable.value
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
value = JSON.stringify(value)
|
||||
} else {
|
||||
value = String(value ?? '')
|
||||
}
|
||||
|
||||
replacements.push({ index: match.index, length: match[0].length, value })
|
||||
}
|
||||
|
||||
return replacements
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects environment variable ({{ENV_VAR}}) replacements from the original command string
|
||||
*/
|
||||
function collectEnvVarReplacements(
|
||||
command: string,
|
||||
envVars: Record<string, string>
|
||||
): Replacement[] {
|
||||
const regex = createEnvVarPattern()
|
||||
const replacements: Replacement[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = regex.exec(command)) !== null) {
|
||||
const varName = match[1].trim()
|
||||
if (!(varName in envVars)) {
|
||||
continue
|
||||
}
|
||||
replacements.push({ index: match.index, length: match[0].length, value: envVars[varName] })
|
||||
}
|
||||
|
||||
return replacements
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects block reference tag (<blockName.field>) replacements from the original command string
|
||||
*/
|
||||
function collectTagReplacements(
|
||||
command: string,
|
||||
blockData: Record<string, unknown>,
|
||||
blockNameMapping: Record<string, string>,
|
||||
blockOutputSchemas: Record<string, OutputSchema>
|
||||
): Replacement[] {
|
||||
const tagPattern = new RegExp(
|
||||
`${REFERENCE.START}(\\S[^${REFERENCE.START}${REFERENCE.END}]*)${REFERENCE.END}`,
|
||||
'g'
|
||||
)
|
||||
|
||||
const replacements: Replacement[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = tagPattern.exec(command)) !== null) {
|
||||
const tagName = match[1].trim()
|
||||
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
|
||||
const blockName = pathParts[0]
|
||||
const fieldPath = pathParts.slice(1)
|
||||
|
||||
const result = resolveBlockReference(blockName, fieldPath, {
|
||||
blockNameMapping,
|
||||
blockData,
|
||||
blockOutputSchemas,
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
const isSpecialPrefix = SPECIAL_REFERENCE_PREFIXES.some((prefix) => blockName === prefix)
|
||||
if (!isSpecialPrefix) {
|
||||
throw new Error(
|
||||
`Block reference "<${tagName}>" could not be resolved. Check that the block name and field path are correct.`
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let stringValue: string
|
||||
if (result.value === undefined || result.value === null) {
|
||||
stringValue = ''
|
||||
} else if (typeof result.value === 'object') {
|
||||
stringValue = JSON.stringify(result.value)
|
||||
} else {
|
||||
stringValue = String(result.value)
|
||||
}
|
||||
|
||||
replacements.push({ index: match.index, length: match[0].length, value: stringValue })
|
||||
}
|
||||
|
||||
return replacements
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves all variable references in a command string in a single pass.
|
||||
* All three patterns are matched against the ORIGINAL command to prevent
|
||||
* cascading resolution (e.g. a workflow variable value containing {{ENV_VAR}}
|
||||
* would NOT be further resolved).
|
||||
*/
|
||||
function resolveCommandVariables(
|
||||
command: string,
|
||||
envVars: Record<string, string>,
|
||||
blockData: Record<string, unknown>,
|
||||
blockNameMapping: Record<string, string>,
|
||||
blockOutputSchemas: Record<string, OutputSchema>,
|
||||
workflowVariables: Record<string, unknown>
|
||||
): string {
|
||||
const allReplacements = [
|
||||
...collectWorkflowVariableReplacements(command, workflowVariables),
|
||||
...collectEnvVarReplacements(command, envVars),
|
||||
...collectTagReplacements(command, blockData, blockNameMapping, blockOutputSchemas),
|
||||
]
|
||||
|
||||
allReplacements.sort((a, b) => a.index - b.index)
|
||||
|
||||
for (let i = 1; i < allReplacements.length; i++) {
|
||||
const prev = allReplacements[i - 1]
|
||||
const curr = allReplacements[i]
|
||||
if (curr.index < prev.index + prev.length) {
|
||||
throw new Error(
|
||||
`Overlapping variable references detected at positions ${prev.index} and ${curr.index}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = command
|
||||
for (let i = allReplacements.length - 1; i >= 0; i--) {
|
||||
const { index, length, value } = allReplacements[i]
|
||||
resolved = resolved.slice(0, index) + value + resolved.slice(index + length)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
timedOut: boolean
|
||||
maxBufferExceeded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command and return stdout, stderr, exitCode.
|
||||
* Distinguishes between a process that exited with non-zero (normal) and one that was killed (timeout).
|
||||
*/
|
||||
function executeCommand(
|
||||
command: string,
|
||||
options: { timeout: number; cwd?: string; env?: Record<string, string> }
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = exec(
|
||||
command,
|
||||
{
|
||||
timeout: options.timeout,
|
||||
cwd: options.cwd || undefined,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
env: { ...getSafeBaseEnv(), ...filterUserEnv(options.env) },
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const killed = error.killed ?? false
|
||||
const isMaxBuffer = /maxBuffer/i.test(error.message ?? '')
|
||||
const exitCode = typeof error.code === 'number' ? error.code : 1
|
||||
resolve({
|
||||
stdout: stdout.trimEnd(),
|
||||
stderr: stderr.trimEnd(),
|
||||
exitCode,
|
||||
timedOut: killed && !isMaxBuffer,
|
||||
maxBufferExceeded: isMaxBuffer,
|
||||
})
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
stdout: stdout.trimEnd(),
|
||||
stderr: stderr.trimEnd(),
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
maxBufferExceeded: false,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
resolve({
|
||||
stdout: '',
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
maxBufferExceeded: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
if (!isExecuteCommandEnabled) {
|
||||
logger.warn(`[${requestId}] Execute Command is disabled`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Execute Command is not enabled. Set EXECUTE_COMMAND_ENABLED=true in your environment to use this feature. Only available for self-hosted deployments.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const auth = await checkInternalAuth(req)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized execute command attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
const {
|
||||
command,
|
||||
workingDirectory,
|
||||
envVars = {},
|
||||
blockData = {},
|
||||
blockNameMapping = {},
|
||||
blockOutputSchemas = {},
|
||||
workflowVariables = {},
|
||||
workflowId,
|
||||
} = body
|
||||
|
||||
const MAX_ALLOWED_TIMEOUT_MS = (MAX_DURATION - 10) * 1000
|
||||
const parsedTimeout = Number(body.timeout)
|
||||
const timeout = Math.min(
|
||||
parsedTimeout > 0 ? parsedTimeout : DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
MAX_ALLOWED_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (!command || typeof command !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Command is required and must be a string' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Execute command request`, {
|
||||
commandLength: command.length,
|
||||
timeout,
|
||||
workingDirectory: workingDirectory || '(default)',
|
||||
workflowId,
|
||||
})
|
||||
|
||||
const resolvedCommand = resolveCommandVariables(
|
||||
command,
|
||||
envVars,
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
workflowVariables
|
||||
)
|
||||
|
||||
const result = await executeCommand(resolvedCommand, {
|
||||
timeout,
|
||||
cwd: workingDirectory,
|
||||
env: envVars,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Command completed`, {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
stdoutLength: result.stdout.length,
|
||||
stderrLength: result.stderr.length,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
if (result.timedOut) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
error: `Command timed out after ${timeout}ms`,
|
||||
})
|
||||
}
|
||||
|
||||
if (result.maxBufferExceeded) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
error: `Command output exceeded maximum buffer size of ${MAX_BUFFER / 1024 / 1024}MB`,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Execute command failed`, { error: message })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
output: { stdout: '', stderr: message, exitCode: 1 },
|
||||
error: message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
87
apps/sim/app/api/tools/slack/remove-reaction/route.ts
Normal file
87
apps/sim/app/api/tools/slack/remove-reaction/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const SlackRemoveReactionSchema = z.object({
|
||||
accessToken: z.string().min(1, 'Access token is required'),
|
||||
channel: z.string().min(1, 'Channel is required'),
|
||||
timestamp: z.string().min(1, 'Message timestamp is required'),
|
||||
name: z.string().min(1, 'Emoji name is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = SlackRemoveReactionSchema.parse(body)
|
||||
|
||||
const slackResponse = await fetch('https://slack.com/api/reactions.remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
name: validatedData.name,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to remove reaction',
|
||||
},
|
||||
{ status: slackResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
content: `Successfully removed :${validatedData.name}: reaction`,
|
||||
metadata: {
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
reaction: validatedData.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,7 @@ export async function POST(request: NextRequest) {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
throw new Error(`Failed to download audio from URL: ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ async function fetchDocumentBytes(url: string): Promise<{ bytes: string; content
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
throw new Error(`Failed to fetch document: ${response.statusText}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
logger.error(`Failed to generate TTS: ${response.status} ${response.statusText}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to generate TTS: ${response.status} ${response.statusText}` },
|
||||
|
||||
@@ -184,6 +184,7 @@ export async function POST(request: NextRequest) {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch image for Gemini' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -964,7 +964,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
logger.error(`[${requestId}] Error streaming block content:`, error)
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock()
|
||||
await reader.cancel().catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,17 +501,6 @@ export function Chat() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExecuting && isStreaming) {
|
||||
const lastMessage = workflowMessages[workflowMessages.length - 1]
|
||||
if (lastMessage?.isStreaming) {
|
||||
streamReaderRef.current?.cancel()
|
||||
streamReaderRef.current = null
|
||||
finalizeMessageStream(lastMessage.id)
|
||||
}
|
||||
}
|
||||
}, [isExecuting, isStreaming, workflowMessages, finalizeMessageStream])
|
||||
|
||||
const handleStopStreaming = useCallback(() => {
|
||||
streamReaderRef.current?.cancel()
|
||||
streamReaderRef.current = null
|
||||
|
||||
@@ -40,6 +40,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
isAncestorProtected,
|
||||
isBlockProtected,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockType } from '@/blocks/types'
|
||||
@@ -107,12 +111,11 @@ export function Editor() {
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Check if block is locked (or inside a locked container) and compute edit permission
|
||||
// Check if block is locked (or inside a locked ancestor) and compute edit permission
|
||||
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const parentId = currentBlock?.data?.parentId as string | undefined
|
||||
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
|
||||
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
|
||||
const isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false
|
||||
const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false
|
||||
const canEditBlock = userPermissions.canEdit && !isLocked
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
@@ -247,10 +250,7 @@ export function Editor() {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return
|
||||
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
|
||||
const isLocked = (block.locked ?? false) || isParentLocked
|
||||
if (!userPermissions.canEdit || isLocked) return
|
||||
if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return
|
||||
|
||||
renamingBlockIdRef.current = blockId
|
||||
setEditedName(block.name || '')
|
||||
@@ -364,11 +364,11 @@ export function Editor() {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
|
||||
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked directly, and not locked by an ancestor */}
|
||||
{isLocked && currentBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
|
||||
{userPermissions.canAdmin && currentBlock.locked && !isAncestorLocked ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
@@ -385,8 +385,8 @@ export function Editor() {
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>
|
||||
{isParentLocked
|
||||
? 'Parent container is locked'
|
||||
{isAncestorLocked
|
||||
? 'Ancestor container is locked'
|
||||
: userPermissions.canAdmin && currentBlock.locked
|
||||
? 'Unlock block'
|
||||
: 'Block is locked'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { Badge } from '@/components/emcn'
|
||||
@@ -28,6 +28,28 @@ export interface SubflowNodeData {
|
||||
executionStatus?: 'success' | 'error' | 'not-executed'
|
||||
}
|
||||
|
||||
const HANDLE_STYLE = {
|
||||
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Reusable class names for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
/**
|
||||
* Subflow node component for loop and parallel execution containers.
|
||||
* Renders a resizable container with a header displaying the block name and icon,
|
||||
@@ -38,7 +60,6 @@ export interface SubflowNodeData {
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -52,7 +73,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
const isLocked = currentBlock?.locked ?? false
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Focus state
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
@@ -84,7 +104,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
}
|
||||
|
||||
return level
|
||||
}, [id, data?.parentId, getNodes])
|
||||
}, [data?.parentId, getNodes])
|
||||
|
||||
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
|
||||
@@ -92,27 +112,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
|
||||
|
||||
/**
|
||||
* Reusable styles and positioning for Handle components.
|
||||
* Matches the styling pattern from workflow-block.tsx.
|
||||
*/
|
||||
const getHandleClasses = (position: 'left' | 'right') => {
|
||||
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
|
||||
const colorClasses = '!bg-[var(--workflow-edge)]'
|
||||
|
||||
const positionClasses = {
|
||||
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
|
||||
right:
|
||||
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
|
||||
}
|
||||
|
||||
return cn(baseClasses, colorClasses, positionClasses[position])
|
||||
}
|
||||
|
||||
const getHandleStyle = () => {
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
|
||||
@@ -127,46 +126,37 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
!!runPathStatus
|
||||
|
||||
/**
|
||||
* Compute the outline color for the subflow ring.
|
||||
* Uses CSS outline instead of box-shadow ring because in ReactFlow v11,
|
||||
* child nodes are DOM children of parent nodes and paint over the parent's
|
||||
* internal ring overlay. Outline renders on the element's own compositing
|
||||
* layer, so it stays visible above nested child nodes.
|
||||
* Compute the ring color for the subflow selection indicator.
|
||||
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
|
||||
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
|
||||
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
|
||||
*/
|
||||
const outlineColor = hasRing
|
||||
? isFocused || isSelected || isPreviewSelected
|
||||
? 'var(--brand-secondary)'
|
||||
: diffStatus === 'new'
|
||||
? 'var(--brand-tertiary-2)'
|
||||
: diffStatus === 'edited'
|
||||
? 'var(--warning)'
|
||||
: runPathStatus === 'success'
|
||||
? executionStatus
|
||||
? 'var(--brand-tertiary-2)'
|
||||
: 'var(--border-success)'
|
||||
: runPathStatus === 'error'
|
||||
? 'var(--text-error)'
|
||||
: undefined
|
||||
: undefined
|
||||
const getRingColor = (): string | undefined => {
|
||||
if (!hasRing) return undefined
|
||||
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
|
||||
if (diffStatus === 'new') return 'var(--brand-tertiary-2)'
|
||||
if (diffStatus === 'edited') return 'var(--warning)'
|
||||
if (runPathStatus === 'success') {
|
||||
return executionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
}
|
||||
if (runPathStatus === 'error') return 'var(--text-error)'
|
||||
return undefined
|
||||
}
|
||||
const ringColor = getRingColor()
|
||||
|
||||
return (
|
||||
<div className='group pointer-events-none relative'>
|
||||
<div
|
||||
ref={blockRef}
|
||||
className={cn(
|
||||
'relative select-none rounded-[8px] border border-[var(--border-1)]',
|
||||
'transition-block-bg'
|
||||
)}
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)] transition-block-bg'
|
||||
style={{
|
||||
width: data.width || 500,
|
||||
height: data.height || 300,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none',
|
||||
...(outlineColor && {
|
||||
outline: `1.75px solid ${outlineColor}`,
|
||||
outlineOffset: '-1px',
|
||||
...(ringColor && {
|
||||
boxShadow: `0 0 0 1.75px ${ringColor}`,
|
||||
}),
|
||||
}}
|
||||
data-node-id={id}
|
||||
@@ -181,9 +171,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
{/* Header Section — only interactive area for dragging */}
|
||||
<div
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className={cn(
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
@@ -209,6 +197,17 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Click-catching background — selects this subflow when the body area is clicked.
|
||||
* No event bubbling concern: ReactFlow renders child nodes as viewport-level siblings,
|
||||
* not as DOM children of this component, so child clicks never reach this div.
|
||||
*/}
|
||||
<div
|
||||
className='absolute inset-0 top-[44px] rounded-b-[8px]'
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
/>
|
||||
|
||||
{!isPreview && (
|
||||
<div
|
||||
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
|
||||
@@ -217,12 +216,9 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
)}
|
||||
|
||||
<div
|
||||
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
className='relative h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
data-dragarea='true'
|
||||
style={{
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Subflow Start */}
|
||||
<div
|
||||
@@ -255,7 +251,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
position={Position.Left}
|
||||
className={getHandleClasses('left')}
|
||||
style={{
|
||||
...getHandleStyle(),
|
||||
...HANDLE_STYLE,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
@@ -266,7 +262,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
position={Position.Right}
|
||||
className={getHandleClasses('right')}
|
||||
style={{
|
||||
...getHandleStyle(),
|
||||
...HANDLE_STYLE,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
id={endHandleId}
|
||||
|
||||
@@ -527,7 +527,8 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
const { displayName: credentialName } = useCredentialName(
|
||||
credentialSourceId,
|
||||
credentialProviderId,
|
||||
workflowId
|
||||
workflowId,
|
||||
workspaceId
|
||||
)
|
||||
|
||||
const credentialId = dependencyValues.credential
|
||||
|
||||
@@ -1124,9 +1124,7 @@ export function useWorkflowExecution() {
|
||||
{} as typeof workflowBlocks
|
||||
)
|
||||
|
||||
const isExecutingFromChat =
|
||||
overrideTriggerType === 'chat' ||
|
||||
(workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput)
|
||||
const isExecutingFromChat = overrideTriggerType === 'chat'
|
||||
|
||||
logger.info('Executing workflow', {
|
||||
isDiffMode: currentWorkflow.isDiffMode,
|
||||
@@ -1495,8 +1493,13 @@ export function useWorkflowExecution() {
|
||||
: null
|
||||
if (activeWorkflowId && !workflowExecState?.isDebugging) {
|
||||
setExecutionResult(executionResult)
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
// For chat executions, don't set isExecuting=false here — the chat's
|
||||
// client-side stream wrapper still has buffered data to deliver.
|
||||
// The chat's finally block handles cleanup after the stream is fully consumed.
|
||||
if (!isExecutingFromChat) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
||||
}, 1000)
|
||||
@@ -1536,7 +1539,7 @@ export function useWorkflowExecution() {
|
||||
isPreExecutionError,
|
||||
})
|
||||
|
||||
if (activeWorkflowId) {
|
||||
if (activeWorkflowId && !isExecutingFromChat) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
@@ -1562,7 +1565,7 @@ export function useWorkflowExecution() {
|
||||
durationMs: data?.duration,
|
||||
})
|
||||
|
||||
if (activeWorkflowId) {
|
||||
if (activeWorkflowId && !isExecutingFromChat) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { isAncestorProtected, isBlockProtected } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
export { isAncestorProtected, isBlockProtected }
|
||||
|
||||
/**
|
||||
* Result of filtering protected blocks from a deletion operation
|
||||
@@ -12,28 +15,6 @@ export interface FilterProtectedBlocksResult {
|
||||
allProtected: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected from editing/deletion.
|
||||
* A block is protected if it is locked or if its parent container is locked.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the block is protected
|
||||
*/
|
||||
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
|
||||
// Block is locked directly
|
||||
if (block.locked) return true
|
||||
|
||||
// Block is inside a locked container
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && blocks[parentId]?.locked) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an edge is protected from modification.
|
||||
* An edge is protected only if its target block is protected.
|
||||
|
||||
@@ -196,17 +196,14 @@ const edgeTypes: EdgeTypes = {
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
|
||||
const reactFlowStyles = [
|
||||
'bg-[var(--bg)]',
|
||||
'[&_.react-flow__edges]:!z-0',
|
||||
'[&_.react-flow__node]:z-[21]',
|
||||
'[&_.react-flow__handle]:!z-[30]',
|
||||
'[&_.react-flow__edge-labels]:!z-[60]',
|
||||
'[&_.react-flow__pane]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__edge-labels]:!z-[1001]',
|
||||
'[&_.react-flow__pane]:select-none',
|
||||
'[&_.react-flow__selectionpane]:select-none',
|
||||
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__background]:hidden',
|
||||
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
|
||||
].join(' ')
|
||||
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
|
||||
const reactFlowProOptions = { hideAttribution: true } as const
|
||||
@@ -2412,6 +2409,12 @@ const WorkflowContent = React.memo(() => {
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
|
||||
|
||||
// Compute zIndex for blocks inside containers so they render above the
|
||||
// parent subflow's interactive body area (which needs pointer-events for
|
||||
// click-to-select). Container nodes use zIndex: depth (0, 1, 2...),
|
||||
// so child blocks use a baseline that is always above any container.
|
||||
const childZIndex = block.data?.parentId ? 1000 : undefined
|
||||
|
||||
// Create stable node object - React Flow will handle shallow comparison
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
@@ -2420,6 +2423,7 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle,
|
||||
draggable: !isBlockProtected(block.id, blocks),
|
||||
...(childZIndex !== undefined && { zIndex: childZIndex }),
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
@@ -3768,21 +3772,20 @@ const WorkflowContent = React.memo(() => {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden'>
|
||||
<div className='relative h-full w-full flex-1'>
|
||||
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
|
||||
<div
|
||||
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
<div
|
||||
className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isWorkflowReady && (
|
||||
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
|
||||
<div
|
||||
className='h-[18px] w-[18px] animate-spin rounded-full'
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWorkflowReady && (
|
||||
<>
|
||||
@@ -3835,7 +3838,7 @@ const WorkflowContent = React.memo(() => {
|
||||
noWheelClassName='allow-scroll'
|
||||
edgesFocusable={true}
|
||||
edgesUpdatable={effectivePermissions.canEdit}
|
||||
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
|
||||
@@ -3847,7 +3850,7 @@ const WorkflowContent = React.memo(() => {
|
||||
elevateEdgesOnSelect={true}
|
||||
onlyRenderVisibleElements={false}
|
||||
deleteKeyCode={null}
|
||||
elevateNodesOnSelect={true}
|
||||
elevateNodesOnSelect={false}
|
||||
autoPanOnConnect={effectivePermissions.canEdit}
|
||||
autoPanOnNodeDrag={effectivePermissions.canEdit}
|
||||
/>
|
||||
|
||||
88
apps/sim/blocks/blocks/execute-command.ts
Normal file
88
apps/sim/blocks/blocks/execute-command.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { TerminalIcon } from '@/components/icons'
|
||||
import { isTruthy } from '@/lib/core/config/env'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ExecuteCommandOutput } from '@/tools/execute-command/types'
|
||||
|
||||
export const ExecuteCommandBlock: BlockConfig<ExecuteCommandOutput> = {
|
||||
type: 'execute_command',
|
||||
name: 'Execute Command',
|
||||
description: 'Run shell commands',
|
||||
hideFromToolbar: !isTruthy(process.env.NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED),
|
||||
longDescription:
|
||||
'Execute shell commands on the host machine. Only available for self-hosted deployments with EXECUTE_COMMAND_ENABLED=true. Commands run in the default shell of the host OS.',
|
||||
bestPractices: `
|
||||
- Commands execute in the default shell of the host machine (bash, zsh, cmd, PowerShell).
|
||||
- Chain multiple commands with && to run them sequentially.
|
||||
- Use <blockName.output> syntax to reference outputs from other blocks.
|
||||
- Use {{ENV_VAR}} syntax to reference environment variables.
|
||||
- The working directory defaults to the server process directory if not specified. Variable references are not supported in the Working Directory field — use a literal path.
|
||||
- A non-zero exit code is returned as data (exitCode > 0), not treated as a workflow error. Use a Condition block to branch on exitCode if needed.
|
||||
- Variable values from other blocks are interpolated directly into the command string. Avoid passing untrusted user input as block references to prevent shell injection.
|
||||
`,
|
||||
docsLink: 'https://docs.sim.ai/blocks/execute-command',
|
||||
category: 'blocks',
|
||||
bgColor: '#1E1E1E',
|
||||
icon: TerminalIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'command',
|
||||
title: 'Command',
|
||||
type: 'long-input',
|
||||
required: true,
|
||||
placeholder: 'echo "Hello, World!"',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `You are an expert shell scripting assistant.
|
||||
Generate ONLY the raw shell command(s) based on the user's request. Never wrap in markdown formatting.
|
||||
The command runs in the default shell of the host OS (bash, zsh, cmd, or PowerShell).
|
||||
|
||||
- Reference outputs from previous blocks using angle bracket syntax: <blockName.output>
|
||||
- Reference environment variables using double curly brace syntax: {{ENV_VAR_NAME}}
|
||||
- Chain multiple commands with && to run them sequentially.
|
||||
- Use pipes (|) to chain command output.
|
||||
|
||||
Current command context: {context}
|
||||
|
||||
IMPORTANT FORMATTING RULES:
|
||||
1. Output ONLY the shell command(s). No explanations, no markdown, no comments.
|
||||
2. Use <blockName.field> to reference block outputs. Do NOT wrap in quotes.
|
||||
3. Use {{VAR_NAME}} to reference environment variables. Do NOT wrap in quotes.
|
||||
4. Write portable commands when possible (prefer POSIX-compatible syntax).
|
||||
5. For multi-step operations, chain with && or use subshells.`,
|
||||
placeholder: 'Describe the command you want to run...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'workingDirectory',
|
||||
title: 'Working Directory',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: '/path/to/directory',
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
title: 'Timeout (ms)',
|
||||
type: 'short-input',
|
||||
required: false,
|
||||
placeholder: String(DEFAULT_EXECUTION_TIMEOUT_MS),
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['execute_command_run'],
|
||||
},
|
||||
inputs: {
|
||||
command: { type: 'string', description: 'Shell command to execute' },
|
||||
workingDirectory: { type: 'string', description: 'Working directory for the command' },
|
||||
timeout: { type: 'number', description: 'Execution timeout in milliseconds' },
|
||||
},
|
||||
outputs: {
|
||||
stdout: { type: 'string', description: 'Standard output from the command' },
|
||||
stderr: { type: 'string', description: 'Standard error output from the command' },
|
||||
exitCode: { type: 'number', description: 'Exit code of the command (0 = success)' },
|
||||
error: {
|
||||
type: 'string',
|
||||
description: 'Error message if the command timed out or exceeded buffer limits',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -129,6 +129,30 @@ Output: {"short_description": "Network outage", "description": "Network connecti
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'offset',
|
||||
title: 'Offset',
|
||||
type: 'short-input',
|
||||
placeholder: '0',
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
description: 'Number of records to skip for pagination',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'displayValue',
|
||||
title: 'Display Value',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Default (not set)', id: '' },
|
||||
{ label: 'False (sys_id only)', id: 'false' },
|
||||
{ label: 'True (display value only)', id: 'true' },
|
||||
{ label: 'All (both)', id: 'all' },
|
||||
],
|
||||
value: () => '',
|
||||
condition: { field: 'operation', value: 'servicenow_read_record' },
|
||||
description: 'Return display values for reference fields instead of sys_ids',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Fields to Return',
|
||||
@@ -203,6 +227,9 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
const isCreateOrUpdate =
|
||||
operation === 'servicenow_create_record' || operation === 'servicenow_update_record'
|
||||
|
||||
if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit)
|
||||
if (rest.offset != null && rest.offset !== '') rest.offset = Number(rest.offset)
|
||||
|
||||
if (fields && isCreateOrUpdate) {
|
||||
const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields
|
||||
return { ...rest, fields: parsedFields }
|
||||
@@ -222,7 +249,9 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
|
||||
number: { type: 'string', description: 'Record number' },
|
||||
query: { type: 'string', description: 'Query string' },
|
||||
limit: { type: 'number', description: 'Result limit' },
|
||||
offset: { type: 'number', description: 'Pagination offset' },
|
||||
fields: { type: 'json', description: 'Fields object or JSON string' },
|
||||
displayValue: { type: 'string', description: 'Display value mode for reference fields' },
|
||||
},
|
||||
outputs: {
|
||||
record: { type: 'json', description: 'Single ServiceNow record' },
|
||||
|
||||
@@ -9,10 +9,10 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
description:
|
||||
'Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events',
|
||||
'Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack',
|
||||
authMode: AuthMode.OAuth,
|
||||
longDescription:
|
||||
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
|
||||
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
|
||||
docsLink: 'https://docs.sim.ai/tools/slack',
|
||||
category: 'tools',
|
||||
bgColor: '#611f69',
|
||||
@@ -38,6 +38,11 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
{ label: 'Update Message', id: 'update' },
|
||||
{ label: 'Delete Message', id: 'delete' },
|
||||
{ label: 'Add Reaction', id: 'react' },
|
||||
{ label: 'Remove Reaction', id: 'unreact' },
|
||||
{ label: 'Get Channel Info', id: 'get_channel_info' },
|
||||
{ label: 'Get User Presence', id: 'get_user_presence' },
|
||||
{ label: 'Edit Canvas', id: 'edit_canvas' },
|
||||
{ label: 'Create Channel Canvas', id: 'create_channel_canvas' },
|
||||
],
|
||||
value: () => 'send',
|
||||
},
|
||||
@@ -141,7 +146,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
}
|
||||
return {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'],
|
||||
not: true,
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
@@ -166,7 +171,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
}
|
||||
return {
|
||||
field: 'operation',
|
||||
value: ['list_channels', 'list_users', 'get_user'],
|
||||
value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'],
|
||||
not: true,
|
||||
and: {
|
||||
field: 'destinationType',
|
||||
@@ -209,8 +214,26 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
|
||||
{
|
||||
id: 'ephemeralUser',
|
||||
title: 'Target User',
|
||||
type: 'user-selector',
|
||||
canonicalParamId: 'ephemeralUser',
|
||||
serviceId: 'slack',
|
||||
selectorKey: 'slack.users',
|
||||
placeholder: 'Select Slack user',
|
||||
mode: 'basic',
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'ephemeral',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualEphemeralUser',
|
||||
title: 'Target User ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'User ID who will see the message (e.g., U1234567890)',
|
||||
canonicalParamId: 'ephemeralUser',
|
||||
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'ephemeral',
|
||||
@@ -440,9 +463,27 @@ Do not include any explanations, markdown formatting, or other text outside the
|
||||
// Get User specific fields
|
||||
{
|
||||
id: 'userId',
|
||||
title: 'User',
|
||||
type: 'user-selector',
|
||||
canonicalParamId: 'userId',
|
||||
serviceId: 'slack',
|
||||
selectorKey: 'slack.users',
|
||||
placeholder: 'Select Slack user',
|
||||
mode: 'basic',
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualUserId',
|
||||
title: 'User ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'userId',
|
||||
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user',
|
||||
@@ -608,7 +649,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'react',
|
||||
value: ['react', 'unreact'],
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
@@ -619,10 +660,150 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Emoji name without colons (e.g., thumbsup, heart, eyes)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'react',
|
||||
value: ['react', 'unreact'],
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Get Channel Info specific fields
|
||||
{
|
||||
id: 'includeNumMembers',
|
||||
title: 'Include Member Count',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
value: () => 'true',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_channel_info',
|
||||
},
|
||||
},
|
||||
// Get User Presence specific fields
|
||||
{
|
||||
id: 'presenceUserId',
|
||||
title: 'User',
|
||||
type: 'user-selector',
|
||||
canonicalParamId: 'presenceUserId',
|
||||
serviceId: 'slack',
|
||||
selectorKey: 'slack.users',
|
||||
placeholder: 'Select Slack user',
|
||||
mode: 'basic',
|
||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user_presence',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualPresenceUserId',
|
||||
title: 'User ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'presenceUserId',
|
||||
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user_presence',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Edit Canvas specific fields
|
||||
{
|
||||
id: 'editCanvasId',
|
||||
title: 'Canvas ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter canvas ID (e.g., F1234ABCD)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'canvasOperation',
|
||||
title: 'Edit Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Insert at Start', id: 'insert_at_start' },
|
||||
{ label: 'Insert at End', id: 'insert_at_end' },
|
||||
{ label: 'Insert After Section', id: 'insert_after' },
|
||||
{ label: 'Insert Before Section', id: 'insert_before' },
|
||||
{ label: 'Replace Section', id: 'replace' },
|
||||
{ label: 'Delete Section', id: 'delete' },
|
||||
{ label: 'Rename Canvas', id: 'rename' },
|
||||
],
|
||||
value: () => 'insert_at_end',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'canvasContent',
|
||||
title: 'Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter content in markdown format',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
and: {
|
||||
field: 'canvasOperation',
|
||||
value: ['delete', 'rename'],
|
||||
not: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sectionId',
|
||||
title: 'Section ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Section ID to target',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
and: {
|
||||
field: 'canvasOperation',
|
||||
value: ['insert_after', 'insert_before', 'replace', 'delete'],
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'canvasTitle',
|
||||
title: 'New Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter new canvas title',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'edit_canvas',
|
||||
and: { field: 'canvasOperation', value: 'rename' },
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
// Create Channel Canvas specific fields
|
||||
{
|
||||
id: 'channelCanvasTitle',
|
||||
title: 'Canvas Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter canvas title (optional)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'create_channel_canvas',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'channelCanvasContent',
|
||||
title: 'Canvas Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter canvas content (markdown supported)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'create_channel_canvas',
|
||||
},
|
||||
},
|
||||
...getTrigger('slack_webhook').subBlocks,
|
||||
],
|
||||
tools: {
|
||||
@@ -641,6 +822,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
'slack_update_message',
|
||||
'slack_delete_message',
|
||||
'slack_add_reaction',
|
||||
'slack_remove_reaction',
|
||||
'slack_get_channel_info',
|
||||
'slack_get_user_presence',
|
||||
'slack_edit_canvas',
|
||||
'slack_create_channel_canvas',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -673,6 +859,16 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
return 'slack_delete_message'
|
||||
case 'react':
|
||||
return 'slack_add_reaction'
|
||||
case 'unreact':
|
||||
return 'slack_remove_reaction'
|
||||
case 'get_channel_info':
|
||||
return 'slack_get_channel_info'
|
||||
case 'get_user_presence':
|
||||
return 'slack_get_user_presence'
|
||||
case 'edit_canvas':
|
||||
return 'slack_edit_canvas'
|
||||
case 'create_channel_canvas':
|
||||
return 'slack_create_channel_canvas'
|
||||
default:
|
||||
throw new Error(`Invalid Slack operation: ${params.operation}`)
|
||||
}
|
||||
@@ -710,6 +906,15 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
getMessageTimestamp,
|
||||
getThreadTimestamp,
|
||||
threadLimit,
|
||||
includeNumMembers,
|
||||
presenceUserId,
|
||||
editCanvasId,
|
||||
canvasOperation,
|
||||
canvasContent,
|
||||
sectionId,
|
||||
canvasTitle,
|
||||
channelCanvasTitle,
|
||||
channelCanvasContent,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
@@ -820,10 +1025,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
|
||||
case 'download': {
|
||||
const fileId = (rest as any).fileId
|
||||
const downloadFileName = (rest as any).downloadFileName
|
||||
const fileName = (rest as any).fileName
|
||||
baseParams.fileId = fileId
|
||||
if (downloadFileName) {
|
||||
baseParams.fileName = downloadFileName
|
||||
if (fileName) {
|
||||
baseParams.fileName = fileName
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -841,9 +1046,41 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
break
|
||||
|
||||
case 'react':
|
||||
case 'unreact':
|
||||
baseParams.timestamp = reactionTimestamp
|
||||
baseParams.name = emojiName
|
||||
break
|
||||
|
||||
case 'get_channel_info':
|
||||
baseParams.includeNumMembers = includeNumMembers !== 'false'
|
||||
break
|
||||
|
||||
case 'get_user_presence':
|
||||
baseParams.userId = presenceUserId
|
||||
break
|
||||
|
||||
case 'edit_canvas':
|
||||
baseParams.canvasId = editCanvasId
|
||||
baseParams.operation = canvasOperation
|
||||
if (canvasContent) {
|
||||
baseParams.content = canvasContent
|
||||
}
|
||||
if (sectionId) {
|
||||
baseParams.sectionId = sectionId
|
||||
}
|
||||
if (canvasTitle) {
|
||||
baseParams.title = canvasTitle
|
||||
}
|
||||
break
|
||||
|
||||
case 'create_channel_canvas':
|
||||
if (channelCanvasTitle) {
|
||||
baseParams.title = channelCanvasTitle
|
||||
}
|
||||
if (channelCanvasContent) {
|
||||
baseParams.content = channelCanvasContent
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return baseParams
|
||||
@@ -898,6 +1135,19 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
type: 'string',
|
||||
description: 'Maximum number of messages to return from thread',
|
||||
},
|
||||
// Get Channel Info inputs
|
||||
includeNumMembers: { type: 'string', description: 'Include member count (true/false)' },
|
||||
// Get User Presence inputs
|
||||
presenceUserId: { type: 'string', description: 'User ID to check presence for' },
|
||||
// Edit Canvas inputs
|
||||
editCanvasId: { type: 'string', description: 'Canvas ID to edit' },
|
||||
canvasOperation: { type: 'string', description: 'Canvas edit operation' },
|
||||
canvasContent: { type: 'string', description: 'Markdown content for canvas edit' },
|
||||
sectionId: { type: 'string', description: 'Canvas section ID to target' },
|
||||
canvasTitle: { type: 'string', description: 'New canvas title for rename' },
|
||||
// Create Channel Canvas inputs
|
||||
channelCanvasTitle: { type: 'string', description: 'Title for channel canvas' },
|
||||
channelCanvasContent: { type: 'string', description: 'Content for channel canvas' },
|
||||
},
|
||||
outputs: {
|
||||
// slack_message outputs (send operation)
|
||||
@@ -994,6 +1244,43 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
description: 'Updated message metadata (legacy, use message object instead)',
|
||||
},
|
||||
|
||||
// slack_get_channel_info outputs (get_channel_info operation)
|
||||
channelInfo: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Detailed channel object with properties: id, name, is_private, is_archived, is_member, num_members, topic, purpose, created, creator',
|
||||
},
|
||||
|
||||
// slack_get_user_presence outputs (get_user_presence operation)
|
||||
presence: {
|
||||
type: 'string',
|
||||
description: 'User presence status: "active" or "away"',
|
||||
},
|
||||
online: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user has an active client connection (only available when checking own presence)',
|
||||
},
|
||||
autoAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user was automatically set to away (only available when checking own presence)',
|
||||
},
|
||||
manualAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user manually set themselves as away (only available when checking own presence)',
|
||||
},
|
||||
connectionCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of active connections (only available when checking own presence)',
|
||||
},
|
||||
lastActivity: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Unix timestamp of last detected activity (only available when checking own presence)',
|
||||
},
|
||||
|
||||
// Trigger outputs (when used as webhook trigger)
|
||||
event_type: { type: 'string', description: 'Type of Slack event that triggered the workflow' },
|
||||
channel_name: { type: 'string', description: 'Human-readable channel name' },
|
||||
|
||||
@@ -39,6 +39,7 @@ import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
|
||||
import { EnrichBlock } from '@/blocks/blocks/enrich'
|
||||
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
|
||||
import { ExaBlock } from '@/blocks/blocks/exa'
|
||||
import { ExecuteCommandBlock } from '@/blocks/blocks/execute-command'
|
||||
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
|
||||
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
|
||||
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies'
|
||||
@@ -235,6 +236,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
elevenlabs: ElevenLabsBlock,
|
||||
enrich: EnrichBlock,
|
||||
evaluator: EvaluatorBlock,
|
||||
execute_command: ExecuteCommandBlock,
|
||||
exa: ExaBlock,
|
||||
file: FileBlock,
|
||||
file_v2: FileV2Block,
|
||||
|
||||
@@ -367,6 +367,34 @@ export function CodeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TerminalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M4 17L10 11L4 5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M12 19H20'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChartBarIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum BlockType {
|
||||
TRIGGER = 'trigger',
|
||||
|
||||
FUNCTION = 'function',
|
||||
EXECUTE_COMMAND = 'execute_command',
|
||||
AGENT = 'agent',
|
||||
API = 'api',
|
||||
EVALUATOR = 'evaluator',
|
||||
|
||||
@@ -618,6 +618,8 @@ export class BlockExecutor {
|
||||
await ctx.onStream?.(clientStreamingExec)
|
||||
} catch (error) {
|
||||
logger.error('Error in onStream callback', { blockId, error })
|
||||
// Cancel the client stream to release the tee'd buffer
|
||||
await processedClientStream.cancel().catch(() => {})
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -646,6 +648,7 @@ export class BlockExecutor {
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error in onStream callback', { blockId, error })
|
||||
await processedStream.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,22 +660,25 @@ export class BlockExecutor {
|
||||
): Promise<void> {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let fullContent = ''
|
||||
const chunks: string[] = []
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
fullContent += decoder.decode(value, { stream: true })
|
||||
chunks.push(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
const tail = decoder.decode()
|
||||
if (tail) chunks.push(tail)
|
||||
} catch (error) {
|
||||
logger.error('Error reading executor stream for block', { blockId, error })
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock()
|
||||
await reader.cancel().catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fullContent = chunks.join('')
|
||||
if (!fullContent) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
/**
|
||||
* Handler for Execute Command blocks that run shell commands on the host machine.
|
||||
*/
|
||||
export class ExecuteCommandBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.EXECUTE_COMMAND
|
||||
}
|
||||
|
||||
async execute(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<any> {
|
||||
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||
|
||||
const result = await executeTool(
|
||||
'execute_command_run',
|
||||
{
|
||||
command: inputs.command,
|
||||
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
workingDirectory: inputs.workingDirectory,
|
||||
envVars: ctx.environmentVariables || {},
|
||||
workflowVariables: ctx.workflowVariables || {},
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
},
|
||||
false,
|
||||
ctx
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...(result.output ?? { stdout: '', stderr: '', exitCode: 1 }),
|
||||
error: result.error || 'Command execution failed',
|
||||
}
|
||||
}
|
||||
|
||||
return { ...result.output, error: null }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import { ExecuteCommandBlockHandler } from '@/executor/handlers/execute-command/execute-command-handler'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
|
||||
@@ -17,6 +18,7 @@ export {
|
||||
ApiBlockHandler,
|
||||
ConditionBlockHandler,
|
||||
EvaluatorBlockHandler,
|
||||
ExecuteCommandBlockHandler,
|
||||
FunctionBlockHandler,
|
||||
GenericBlockHandler,
|
||||
ResponseBlockHandler,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import { ExecuteCommandBlockHandler } from '@/executor/handlers/execute-command/execute-command-handler'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler'
|
||||
@@ -30,6 +31,7 @@ export function createBlockHandlers(): BlockHandler[] {
|
||||
return [
|
||||
new TriggerBlockHandler(),
|
||||
new FunctionBlockHandler(),
|
||||
new ExecuteCommandBlockHandler(),
|
||||
new ApiBlockHandler(),
|
||||
new ConditionBlockHandler(),
|
||||
new RouterBlockHandler(),
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
|
||||
import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('CollaborativeWorkflow')
|
||||
|
||||
@@ -748,9 +749,7 @@ export function useCollaborativeWorkflow() {
|
||||
const block = blocks[id]
|
||||
|
||||
if (block) {
|
||||
const parentId = block.data?.parentId
|
||||
const isParentLocked = parentId ? blocks[parentId]?.locked : false
|
||||
if (block.locked || isParentLocked) {
|
||||
if (isBlockProtected(id, blocks)) {
|
||||
logger.error('Cannot rename locked block')
|
||||
useNotificationStore.getState().addNotification({
|
||||
level: 'info',
|
||||
@@ -858,21 +857,21 @@ export function useCollaborativeWorkflow() {
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
// For each ID, collect non-locked blocks and their children for undo/redo
|
||||
// For each ID, collect non-locked blocks and their descendants for undo/redo
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
// Skip locked blocks
|
||||
if (block.locked) continue
|
||||
// Skip protected blocks (locked or inside a locked ancestor)
|
||||
if (isBlockProtected(id, currentBlocks)) continue
|
||||
validIds.push(id)
|
||||
previousStates[id] = block.enabled
|
||||
|
||||
// If it's a loop or parallel, also capture children's previous states for undo/redo
|
||||
// If it's a loop or parallel, also capture descendants' previous states for undo/redo
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id && !b.locked) {
|
||||
previousStates[blockId] = b.enabled
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
if (!isBlockProtected(descId, currentBlocks)) {
|
||||
previousStates[descId] = currentBlocks[descId]?.enabled ?? true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1038,21 +1037,12 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && blocks[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
const block = blocks[id]
|
||||
if (block && !isProtected(id)) {
|
||||
if (block && !isBlockProtected(id, blocks)) {
|
||||
previousStates[id] = block.horizontalHandles ?? false
|
||||
validIds.push(id)
|
||||
}
|
||||
@@ -1100,10 +1090,8 @@ export function useCollaborativeWorkflow() {
|
||||
previousStates[id] = block.locked ?? false
|
||||
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id) {
|
||||
previousStates[blockId] = b.locked ?? false
|
||||
}
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
previousStates[descId] = currentBlocks[descId]?.locked ?? false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,9 @@ export const env = createEnv({
|
||||
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
|
||||
E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation
|
||||
|
||||
// Execute Command - for self-hosted deployments
|
||||
EXECUTE_COMMAND_ENABLED: z.string().optional(), // Enable shell command execution (self-hosted only)
|
||||
|
||||
// Credential Sets (Email Polling) - for self-hosted deployments
|
||||
CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements)
|
||||
|
||||
@@ -366,6 +369,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email
|
||||
|
||||
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
|
||||
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
|
||||
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
|
||||
@@ -420,6 +424,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
|
||||
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
|
||||
NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: process.env.NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED,
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
|
||||
NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND,
|
||||
NEXT_PUBLIC_POSTHOG_ENABLED: process.env.NEXT_PUBLIC_POSTHOG_ENABLED,
|
||||
|
||||
@@ -105,6 +105,31 @@ export const isOrganizationsEnabled =
|
||||
*/
|
||||
export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
|
||||
/**
|
||||
* Is Execute Command enabled for running shell commands on the host machine.
|
||||
* Only available for self-hosted deployments. Blocked on hosted environments.
|
||||
*/
|
||||
export const isExecuteCommandEnabled = isTruthy(env.EXECUTE_COMMAND_ENABLED) && !isHosted
|
||||
|
||||
if (isTruthy(env.EXECUTE_COMMAND_ENABLED)) {
|
||||
import('@sim/logger')
|
||||
.then(({ createLogger }) => {
|
||||
const logger = createLogger('FeatureFlags')
|
||||
if (isHosted) {
|
||||
logger.error(
|
||||
'EXECUTE_COMMAND_ENABLED is set but ignored on hosted environment. Shell command execution is not available on hosted instances for security.'
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
'EXECUTE_COMMAND_ENABLED is enabled. Workflows can execute arbitrary shell commands on the host machine. Only use this in trusted environments.'
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback during config compilation when logger is unavailable
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Are invitations disabled globally
|
||||
* When true, workspace invitations are disabled for all users
|
||||
|
||||
@@ -23,6 +23,16 @@ export function startMemoryTelemetry(intervalMs = 60_000) {
|
||||
started = true
|
||||
|
||||
const timer = setInterval(() => {
|
||||
// Trigger opportunistic (non-blocking) garbage collection if running on Bun.
|
||||
// This signals JSC GC + mimalloc page purge without blocking the event loop,
|
||||
// helping reclaim RSS that mimalloc otherwise retains under sustained load.
|
||||
const bunGlobal = (globalThis as Record<string, unknown>).Bun as
|
||||
| { gc?: (force: boolean) => void }
|
||||
| undefined
|
||||
if (typeof bunGlobal?.gc === 'function') {
|
||||
bunGlobal.gc(false)
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
const heap = v8.getHeapStatistics()
|
||||
|
||||
|
||||
@@ -759,6 +759,7 @@ async function markEmailAsRead(accessToken: string, messageId: string) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
throw new Error(
|
||||
`Failed to mark email ${messageId} as read: ${response.status} ${response.statusText}`
|
||||
)
|
||||
|
||||
@@ -95,6 +95,7 @@ const nextConfig: NextConfig = {
|
||||
optimizeCss: true,
|
||||
turbopackSourceMaps: false,
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
},
|
||||
...(isDev && {
|
||||
allowedDevOrigins: [
|
||||
|
||||
@@ -39,6 +39,56 @@ const db = socketDb
|
||||
const DEFAULT_LOOP_ITERATIONS = 5
|
||||
const DEFAULT_PARALLEL_COUNT = 5
|
||||
|
||||
/** Minimal block shape needed for protection and descendant checks */
|
||||
interface DbBlockRef {
|
||||
id: string
|
||||
locked?: boolean | null
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected (locked or inside a locked ancestor).
|
||||
* Works with raw DB records.
|
||||
*/
|
||||
function isDbBlockProtected(blockId: string, blocksById: Record<string, DbBlockRef>): boolean {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const visited = new Set<string>()
|
||||
let parentId = (block.data as Record<string, unknown> | null)?.parentId as string | undefined
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
visited.add(parentId)
|
||||
if (blocksById[parentId]?.locked) return true
|
||||
parentId = (blocksById[parentId]?.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all descendant block IDs of a container (recursive).
|
||||
* Works with raw DB block arrays.
|
||||
*/
|
||||
function findDbDescendants(containerId: string, allBlocks: DbBlockRef[]): string[] {
|
||||
const descendants: string[] = []
|
||||
const visited = new Set<string>()
|
||||
const stack = [containerId]
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!
|
||||
if (visited.has(current)) continue
|
||||
visited.add(current)
|
||||
for (const b of allBlocks) {
|
||||
const pid = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (pid === current) {
|
||||
descendants.push(b.id)
|
||||
stack.push(b.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared function to handle auto-connect edge insertion
|
||||
* @param tx - Database transaction
|
||||
@@ -753,20 +803,8 @@ async function handleBlocksOperationTx(
|
||||
allBlocks.map((b: BlockRecord) => [b.id, b])
|
||||
)
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter out protected blocks from deletion request
|
||||
const deletableIds = ids.filter((id) => !isProtected(id))
|
||||
const deletableIds = ids.filter((id) => !isDbBlockProtected(id, blocksById))
|
||||
if (deletableIds.length === 0) {
|
||||
logger.info('All requested blocks are protected, skipping deletion')
|
||||
return
|
||||
@@ -778,18 +816,14 @@ async function handleBlocksOperationTx(
|
||||
)
|
||||
}
|
||||
|
||||
// Collect all block IDs including children of subflows
|
||||
// Collect all block IDs including all descendants of subflows
|
||||
const allBlocksToDelete = new Set<string>(deletableIds)
|
||||
|
||||
for (const id of deletableIds) {
|
||||
const block = blocksById[id]
|
||||
if (block && isSubflowBlockType(block.type)) {
|
||||
// Include all children of the subflow (they should be deleted with parent)
|
||||
for (const b of allBlocks) {
|
||||
const parentId = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (parentId === id) {
|
||||
allBlocksToDelete.add(b.id)
|
||||
}
|
||||
for (const descId of findDbDescendants(id, allBlocks)) {
|
||||
allBlocksToDelete.add(descId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -902,19 +936,18 @@ async function handleBlocksOperationTx(
|
||||
)
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// Collect all blocks to toggle including children of containers
|
||||
// Collect all blocks to toggle including descendants of containers
|
||||
for (const id of blockIds) {
|
||||
const block = blocksById[id]
|
||||
if (!block || block.locked) continue
|
||||
if (!block || isDbBlockProtected(id, blocksById)) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include all children
|
||||
// If it's a loop or parallel, also include all non-locked descendants
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
for (const b of allBlocks) {
|
||||
const parentId = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (parentId === id && !b.locked) {
|
||||
blocksToToggle.add(b.id)
|
||||
for (const descId of findDbDescendants(id, allBlocks)) {
|
||||
if (!isDbBlockProtected(descId, blocksById)) {
|
||||
blocksToToggle.add(descId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,20 +999,10 @@ async function handleBlocksOperationTx(
|
||||
allBlocks.map((b: HandleBlockRecord) => [b.id, b])
|
||||
)
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter to only toggle handles on unprotected blocks
|
||||
const blocksToToggle = blockIds.filter((id) => blocksById[id] && !isProtected(id))
|
||||
const blocksToToggle = blockIds.filter(
|
||||
(id) => blocksById[id] && !isDbBlockProtected(id, blocksById)
|
||||
)
|
||||
if (blocksToToggle.length === 0) {
|
||||
logger.info('All requested blocks are protected, skipping handles toggle')
|
||||
break
|
||||
@@ -1025,20 +1048,17 @@ async function handleBlocksOperationTx(
|
||||
)
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// Collect all blocks to toggle including children of containers
|
||||
// Collect all blocks to toggle including descendants of containers
|
||||
for (const id of blockIds) {
|
||||
const block = blocksById[id]
|
||||
if (!block) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include all children
|
||||
// If it's a loop or parallel, also include all descendants
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
for (const b of allBlocks) {
|
||||
const parentId = (b.data as Record<string, unknown> | null)?.parentId
|
||||
if (parentId === id) {
|
||||
blocksToToggle.add(b.id)
|
||||
}
|
||||
for (const descId of findDbDescendants(id, allBlocks)) {
|
||||
blocksToToggle.add(descId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1088,31 +1108,19 @@ async function handleBlocksOperationTx(
|
||||
allBlocks.map((b: ParentBlockRecord) => [b.id, b])
|
||||
)
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const currentParentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (currentParentId && blocksById[currentParentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
const { id, parentId, position } = update
|
||||
if (!id) continue
|
||||
|
||||
// Skip protected blocks (locked or inside locked container)
|
||||
if (isProtected(id)) {
|
||||
if (isDbBlockProtected(id, blocksById)) {
|
||||
logger.info(`Skipping block ${id} parent update - block is protected`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if trying to move into a locked container
|
||||
if (parentId && blocksById[parentId]?.locked) {
|
||||
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is locked`)
|
||||
// Skip if trying to move into a locked container (or any of its ancestors)
|
||||
if (parentId && isDbBlockProtected(parentId, blocksById)) {
|
||||
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1235,18 +1243,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
|
||||
}
|
||||
}
|
||||
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (isBlockProtected(payload.target)) {
|
||||
if (isDbBlockProtected(payload.target, blocksById)) {
|
||||
logger.info(`Skipping edge add - target block is protected`)
|
||||
break
|
||||
}
|
||||
@@ -1334,18 +1331,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
|
||||
}
|
||||
}
|
||||
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (isBlockProtected(edgeToRemove.targetBlockId)) {
|
||||
if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) {
|
||||
logger.info(`Skipping edge remove - target block is protected`)
|
||||
break
|
||||
}
|
||||
@@ -1455,19 +1441,8 @@ async function handleEdgesOperationTx(
|
||||
}
|
||||
}
|
||||
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const safeEdgeIds = edgesToRemove
|
||||
.filter((e: EdgeToRemove) => !isBlockProtected(e.targetBlockId))
|
||||
.filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById))
|
||||
.map((e: EdgeToRemove) => e.id)
|
||||
|
||||
if (safeEdgeIds.length === 0) {
|
||||
@@ -1552,20 +1527,9 @@ async function handleEdgesOperationTx(
|
||||
}
|
||||
}
|
||||
|
||||
const isBlockProtected = (blockId: string): boolean => {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
||||
| string
|
||||
| undefined
|
||||
if (parentId && blocksById[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter edges - only add edges where target block is not protected
|
||||
const safeEdges = (edges as Array<Record<string, unknown>>).filter(
|
||||
(e) => !isBlockProtected(e.target as string)
|
||||
(e) => !isDbBlockProtected(e.target as string, blocksById)
|
||||
)
|
||||
|
||||
if (safeEdges.length === 0) {
|
||||
|
||||
@@ -20,8 +20,10 @@ import type {
|
||||
WorkflowStore,
|
||||
} from '@/stores/workflows/workflow/types'
|
||||
import {
|
||||
findAllDescendantNodes,
|
||||
generateLoopBlocks,
|
||||
generateParallelBlocks,
|
||||
isBlockProtected,
|
||||
wouldCreateCycle,
|
||||
} from '@/stores/workflows/workflow/utils'
|
||||
|
||||
@@ -374,21 +376,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// For each ID, collect blocks to toggle (skip locked blocks entirely)
|
||||
// If it's a container, also include non-locked children
|
||||
// If it's a container, also include non-locked descendants
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
// Skip locked blocks entirely (including their children)
|
||||
if (block.locked) continue
|
||||
// Skip protected blocks entirely (locked or inside a locked ancestor)
|
||||
if (isBlockProtected(id, currentBlocks)) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include non-locked children
|
||||
// If it's a loop or parallel, also include non-locked descendants
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id && !b.locked) {
|
||||
blocksToToggle.add(blockId)
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
if (!isBlockProtected(descId, currentBlocks)) {
|
||||
blocksToToggle.add(descId)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -415,18 +417,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const currentBlocks = get().blocks
|
||||
const newBlocks = { ...currentBlocks }
|
||||
|
||||
// Helper to check if a block is protected (locked or inside locked parent)
|
||||
const isProtected = (blockId: string): boolean => {
|
||||
const block = currentBlocks[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const parentId = block.data?.parentId
|
||||
if (parentId && currentBlocks[parentId]?.locked) return true
|
||||
return false
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
if (!newBlocks[id] || isProtected(id)) continue
|
||||
if (!newBlocks[id] || isBlockProtected(id, currentBlocks)) continue
|
||||
newBlocks[id] = {
|
||||
...newBlocks[id],
|
||||
horizontalHandles: !newBlocks[id].horizontalHandles,
|
||||
@@ -1267,19 +1259,17 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// For each ID, collect blocks to toggle
|
||||
// If it's a container, also include all children
|
||||
// If it's a container, also include all descendants
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include all children
|
||||
// If it's a loop or parallel, also include all descendants
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id) {
|
||||
blocksToToggle.add(blockId)
|
||||
}
|
||||
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
|
||||
blocksToToggle.add(descId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,21 +143,56 @@ export function findAllDescendantNodes(
|
||||
blocks: Record<string, BlockState>
|
||||
): string[] {
|
||||
const descendants: string[] = []
|
||||
const findDescendants = (parentId: string) => {
|
||||
const children = Object.values(blocks)
|
||||
.filter((block) => block.data?.parentId === parentId)
|
||||
.map((block) => block.id)
|
||||
|
||||
children.forEach((childId) => {
|
||||
descendants.push(childId)
|
||||
findDescendants(childId)
|
||||
})
|
||||
const visited = new Set<string>()
|
||||
const stack = [containerId]
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!
|
||||
if (visited.has(current)) continue
|
||||
visited.add(current)
|
||||
for (const block of Object.values(blocks)) {
|
||||
if (block.data?.parentId === current) {
|
||||
descendants.push(block.id)
|
||||
stack.push(block.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findDescendants(containerId)
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any ancestor container of a block is locked.
|
||||
* Unlike {@link isBlockProtected}, this ignores the block's own locked state.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if any ancestor is locked
|
||||
*/
|
||||
export function isAncestorProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const visited = new Set<string>()
|
||||
let parentId = blocks[blockId]?.data?.parentId
|
||||
while (parentId && !visited.has(parentId)) {
|
||||
visited.add(parentId)
|
||||
if (blocks[parentId]?.locked) return true
|
||||
parentId = blocks[parentId]?.data?.parentId
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is protected from editing/deletion.
|
||||
* A block is protected if it is locked or if any ancestor container is locked.
|
||||
*
|
||||
* @param blockId - The ID of the block to check
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the block is protected
|
||||
*/
|
||||
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
|
||||
const block = blocks[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
return isAncestorProtected(blockId, blocks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete collection of loops from the UI blocks
|
||||
*
|
||||
|
||||
122
apps/sim/tools/execute-command/execute.ts
Normal file
122
apps/sim/tools/execute-command/execute.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import type { ExecuteCommandInput, ExecuteCommandOutput } from '@/tools/execute-command/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const executeCommandRunTool: ToolConfig<ExecuteCommandInput, ExecuteCommandOutput> = {
|
||||
id: 'execute_command_run',
|
||||
name: 'Execute Command',
|
||||
description:
|
||||
'Execute a shell command on the host machine. Only available for self-hosted deployments with EXECUTE_COMMAND_ENABLED=true.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
command: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The shell command to execute on the host machine',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Execution timeout in milliseconds',
|
||||
default: DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
},
|
||||
workingDirectory: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Working directory for the command. Defaults to the server process cwd.',
|
||||
},
|
||||
envVars: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Environment variables to make available during execution',
|
||||
default: {},
|
||||
},
|
||||
blockData: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Block output data for variable resolution',
|
||||
default: {},
|
||||
},
|
||||
blockNameMapping: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Mapping of block names to block IDs',
|
||||
default: {},
|
||||
},
|
||||
blockOutputSchemas: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Mapping of block IDs to their output schemas for validation',
|
||||
default: {},
|
||||
},
|
||||
workflowVariables: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Workflow variables for <variable.name> resolution',
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/execute-command/run',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: ExecuteCommandInput) => ({
|
||||
command: params.command,
|
||||
timeout: params.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
workingDirectory: params.workingDirectory,
|
||||
envVars: params.envVars || {},
|
||||
workflowVariables: params.workflowVariables || {},
|
||||
blockData: params.blockData || {},
|
||||
blockNameMapping: params.blockNameMapping || {},
|
||||
blockOutputSchemas: params.blockOutputSchemas || {},
|
||||
workflowId: params._context?.workflowId,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<ExecuteCommandOutput> => {
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
stdout: result.output?.stdout || '',
|
||||
stderr: result.output?.stderr || '',
|
||||
exitCode: result.output?.exitCode ?? 1,
|
||||
},
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
stdout: result.output.stdout,
|
||||
stderr: result.output.stderr,
|
||||
exitCode: result.output.exitCode,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
stdout: { type: 'string', description: 'Standard output from the command' },
|
||||
stderr: { type: 'string', description: 'Standard error output from the command' },
|
||||
exitCode: { type: 'number', description: 'Exit code of the command (0 = success)' },
|
||||
error: {
|
||||
type: 'string',
|
||||
description: 'Error message if the command timed out or exceeded buffer limits',
|
||||
},
|
||||
},
|
||||
}
|
||||
2
apps/sim/tools/execute-command/index.ts
Normal file
2
apps/sim/tools/execute-command/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { executeCommandRunTool } from '@/tools/execute-command/execute'
|
||||
export type { ExecuteCommandInput, ExecuteCommandOutput } from '@/tools/execute-command/types'
|
||||
24
apps/sim/tools/execute-command/types.ts
Normal file
24
apps/sim/tools/execute-command/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface ExecuteCommandInput {
|
||||
command: string
|
||||
timeout?: number
|
||||
workingDirectory?: string
|
||||
envVars?: Record<string, string>
|
||||
workflowVariables?: Record<string, unknown>
|
||||
blockData?: Record<string, unknown>
|
||||
blockNameMapping?: Record<string, string>
|
||||
blockOutputSchemas?: Record<string, Record<string, unknown>>
|
||||
_context?: {
|
||||
workflowId?: string
|
||||
userId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteCommandOutput extends ToolResponse {
|
||||
output: {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,7 @@ export async function downloadAttachments(
|
||||
)
|
||||
|
||||
if (!attachmentResponse.ok) {
|
||||
await attachmentResponse.body?.cancel().catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -433,6 +433,7 @@ import {
|
||||
exaResearchTool,
|
||||
exaSearchTool,
|
||||
} from '@/tools/exa'
|
||||
import { executeCommandRunTool } from '@/tools/execute-command'
|
||||
import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file'
|
||||
import {
|
||||
firecrawlAgentTool,
|
||||
@@ -1797,17 +1798,22 @@ import {
|
||||
import {
|
||||
slackAddReactionTool,
|
||||
slackCanvasTool,
|
||||
slackCreateChannelCanvasTool,
|
||||
slackDeleteMessageTool,
|
||||
slackDownloadTool,
|
||||
slackEditCanvasTool,
|
||||
slackEphemeralMessageTool,
|
||||
slackGetChannelInfoTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
slackGetUserPresenceTool,
|
||||
slackGetUserTool,
|
||||
slackListChannelsTool,
|
||||
slackListMembersTool,
|
||||
slackListUsersTool,
|
||||
slackMessageReaderTool,
|
||||
slackMessageTool,
|
||||
slackRemoveReactionTool,
|
||||
slackUpdateMessageTool,
|
||||
} from '@/tools/slack'
|
||||
import { smsSendTool } from '@/tools/sms'
|
||||
@@ -2332,6 +2338,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
webhook_request: webhookRequestTool,
|
||||
huggingface_chat: huggingfaceChatTool,
|
||||
llm_chat: llmChatTool,
|
||||
execute_command_run: executeCommandRunTool,
|
||||
function_execute: functionExecuteTool,
|
||||
gamma_generate: gammaGenerateTool,
|
||||
gamma_generate_from_template: gammaGenerateFromTemplateTool,
|
||||
@@ -2611,6 +2618,11 @@ export const tools: Record<string, ToolConfig> = {
|
||||
slack_update_message: slackUpdateMessageTool,
|
||||
slack_delete_message: slackDeleteMessageTool,
|
||||
slack_add_reaction: slackAddReactionTool,
|
||||
slack_remove_reaction: slackRemoveReactionTool,
|
||||
slack_get_channel_info: slackGetChannelInfoTool,
|
||||
slack_get_user_presence: slackGetUserPresenceTool,
|
||||
slack_edit_canvas: slackEditCanvasTool,
|
||||
slack_create_channel_canvas: slackCreateChannelCanvasTool,
|
||||
github_repo_info: githubRepoInfoTool,
|
||||
github_repo_info_v2: githubRepoInfoV2Tool,
|
||||
github_latest_commit: githubLatestCommitTool,
|
||||
|
||||
@@ -60,6 +60,12 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of records to return (e.g., 10, 50, 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of records to skip for pagination (e.g., 0, 10, 20)',
|
||||
},
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
@@ -67,6 +73,13 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
|
||||
description:
|
||||
'Comma-separated list of fields to return (e.g., sys_id,number,short_description,state)',
|
||||
},
|
||||
displayValue: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Return display values for reference fields: "true" (display only), "false" (sys_id only), or "all" (both)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -96,10 +109,18 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
|
||||
queryParams.append('sysparm_limit', params.limit.toString())
|
||||
}
|
||||
|
||||
if (params.offset !== undefined && params.offset !== null) {
|
||||
queryParams.append('sysparm_offset', params.offset.toString())
|
||||
}
|
||||
|
||||
if (params.fields) {
|
||||
queryParams.append('sysparm_fields', params.fields)
|
||||
}
|
||||
|
||||
if (params.displayValue) {
|
||||
queryParams.append('sysparm_display_value', params.displayValue)
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
return queryString ? `${url}?${queryString}` : url
|
||||
},
|
||||
|
||||
@@ -31,7 +31,9 @@ export interface ServiceNowReadParams extends ServiceNowBaseParams {
|
||||
number?: string
|
||||
query?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
fields?: string
|
||||
displayValue?: string
|
||||
}
|
||||
|
||||
export interface ServiceNowReadResponse extends ToolResponse {
|
||||
|
||||
@@ -87,9 +87,21 @@ export const slackCanvasTool: ToolConfig<SlackCanvasParams, SlackCanvasResponse>
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
transformResponse: async (response: Response): Promise<SlackCanvasResponse> => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
canvas_id: '',
|
||||
channel: '',
|
||||
title: '',
|
||||
},
|
||||
error: data.error || 'Unknown error',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
|
||||
108
apps/sim/tools/slack/create_channel_canvas.ts
Normal file
108
apps/sim/tools/slack/create_channel_canvas.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
SlackCreateChannelCanvasParams,
|
||||
SlackCreateChannelCanvasResponse,
|
||||
} from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackCreateChannelCanvasTool: ToolConfig<
|
||||
SlackCreateChannelCanvasParams,
|
||||
SlackCreateChannelCanvasResponse
|
||||
> = {
|
||||
id: 'slack_create_channel_canvas',
|
||||
name: 'Slack Create Channel Canvas',
|
||||
description: 'Create a canvas pinned to a Slack channel as its resource hub',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Channel ID to create the canvas in (e.g., C1234567890)',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Title for the channel canvas',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Canvas content in markdown format',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://slack.com/api/conversations.canvases.create',
|
||||
method: 'POST',
|
||||
headers: (params: SlackCreateChannelCanvasParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
body: (params: SlackCreateChannelCanvasParams) => {
|
||||
const body: Record<string, unknown> = {
|
||||
channel_id: params.channel.trim(),
|
||||
}
|
||||
|
||||
if (params.title) {
|
||||
body.title = params.title
|
||||
}
|
||||
|
||||
if (params.content) {
|
||||
body.document_content = {
|
||||
type: 'markdown',
|
||||
markdown: params.content,
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'channel_canvas_already_exists') {
|
||||
throw new Error('A canvas already exists for this channel. Use Edit Canvas to modify it.')
|
||||
}
|
||||
throw new Error(data.error || 'Failed to create channel canvas')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
canvas_id: data.canvas_id,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
canvas_id: { type: 'string', description: 'ID of the created channel canvas' },
|
||||
},
|
||||
}
|
||||
121
apps/sim/tools/slack/edit_canvas.ts
Normal file
121
apps/sim/tools/slack/edit_canvas.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { SlackEditCanvasParams, SlackEditCanvasResponse } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackEditCanvasTool: ToolConfig<SlackEditCanvasParams, SlackEditCanvasResponse> = {
|
||||
id: 'slack_edit_canvas',
|
||||
name: 'Slack Edit Canvas',
|
||||
description: 'Edit an existing Slack canvas by inserting, replacing, or deleting content',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
canvasId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Canvas ID to edit (e.g., F1234ABCD)',
|
||||
},
|
||||
operation: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Edit operation: insert_at_start, insert_at_end, insert_after, insert_before, replace, delete, or rename',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Markdown content for the operation (required for insert/replace operations)',
|
||||
},
|
||||
sectionId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Section ID to target (required for insert_after, insert_before, replace, and delete)',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'New title for the canvas (only used with rename operation)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://slack.com/api/canvases.edit',
|
||||
method: 'POST',
|
||||
headers: (params: SlackEditCanvasParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
body: (params: SlackEditCanvasParams) => {
|
||||
const change: Record<string, unknown> = {
|
||||
operation: params.operation,
|
||||
}
|
||||
|
||||
if (params.sectionId) {
|
||||
change.section_id = params.sectionId.trim()
|
||||
}
|
||||
|
||||
if (params.operation === 'rename' && params.title) {
|
||||
change.title_content = {
|
||||
type: 'markdown',
|
||||
markdown: params.title,
|
||||
}
|
||||
} else if (params.content && params.operation !== 'delete') {
|
||||
change.document_content = {
|
||||
type: 'markdown',
|
||||
markdown: params.content,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canvas_id: params.canvasId.trim(),
|
||||
changes: [change],
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Failed to edit canvas')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: 'Successfully edited canvas',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Success message' },
|
||||
},
|
||||
}
|
||||
115
apps/sim/tools/slack/get_channel_info.ts
Normal file
115
apps/sim/tools/slack/get_channel_info.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { SlackGetChannelInfoParams, SlackGetChannelInfoResponse } from '@/tools/slack/types'
|
||||
import { CHANNEL_OUTPUT_PROPERTIES } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackGetChannelInfoTool: ToolConfig<
|
||||
SlackGetChannelInfoParams,
|
||||
SlackGetChannelInfoResponse
|
||||
> = {
|
||||
id: 'slack_get_channel_info',
|
||||
name: 'Slack Get Channel Info',
|
||||
description: 'Get detailed information about a Slack channel by its ID',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Channel ID to get information about (e.g., C1234567890)',
|
||||
},
|
||||
includeNumMembers: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include the member count in the response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: SlackGetChannelInfoParams) => {
|
||||
const url = new URL('https://slack.com/api/conversations.info')
|
||||
url.searchParams.append('channel', params.channel.trim())
|
||||
url.searchParams.append('include_num_members', String(params.includeNumMembers ?? true))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: SlackGetChannelInfoParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'channel_not_found') {
|
||||
throw new Error('Channel not found. Please check the channel ID and try again.')
|
||||
}
|
||||
if (data.error === 'missing_scope') {
|
||||
throw new Error(
|
||||
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:read).'
|
||||
)
|
||||
}
|
||||
throw new Error(data.error || 'Failed to get channel info from Slack')
|
||||
}
|
||||
|
||||
const channel = data.channel
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
channelInfo: {
|
||||
id: channel.id,
|
||||
name: channel.name ?? '',
|
||||
is_channel: channel.is_channel ?? false,
|
||||
is_private: channel.is_private ?? false,
|
||||
is_archived: channel.is_archived ?? false,
|
||||
is_general: channel.is_general ?? false,
|
||||
is_member: channel.is_member ?? false,
|
||||
is_shared: channel.is_shared ?? false,
|
||||
is_ext_shared: channel.is_ext_shared ?? false,
|
||||
is_org_shared: channel.is_org_shared ?? false,
|
||||
num_members: channel.num_members ?? null,
|
||||
topic: channel.topic?.value ?? '',
|
||||
purpose: channel.purpose?.value ?? '',
|
||||
created: channel.created ?? null,
|
||||
creator: channel.creator ?? null,
|
||||
updated: channel.updated ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
channelInfo: {
|
||||
type: 'object',
|
||||
description: 'Detailed channel information',
|
||||
properties: CHANNEL_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
122
apps/sim/tools/slack/get_user_presence.ts
Normal file
122
apps/sim/tools/slack/get_user_presence.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { SlackGetUserPresenceParams, SlackGetUserPresenceResponse } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackGetUserPresenceTool: ToolConfig<
|
||||
SlackGetUserPresenceParams,
|
||||
SlackGetUserPresenceResponse
|
||||
> = {
|
||||
id: 'slack_get_user_presence',
|
||||
name: 'Slack Get User Presence',
|
||||
description: 'Check whether a Slack user is currently active or away',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'User ID to check presence for (e.g., U1234567890)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: SlackGetUserPresenceParams) => {
|
||||
const url = new URL('https://slack.com/api/users.getPresence')
|
||||
url.searchParams.append('user', params.userId.trim())
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: SlackGetUserPresenceParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken || params.botToken}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
if (data.error === 'user_not_found') {
|
||||
throw new Error('User not found. Please check the user ID and try again.')
|
||||
}
|
||||
if (data.error === 'missing_scope') {
|
||||
throw new Error(
|
||||
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (users:read).'
|
||||
)
|
||||
}
|
||||
throw new Error(data.error || 'Failed to get user presence from Slack')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
presence: data.presence,
|
||||
online: data.online ?? null,
|
||||
autoAway: data.auto_away ?? null,
|
||||
manualAway: data.manual_away ?? null,
|
||||
connectionCount: data.connection_count ?? null,
|
||||
lastActivity: data.last_activity ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
presence: {
|
||||
type: 'string',
|
||||
description: 'User presence status: "active" or "away"',
|
||||
},
|
||||
online: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user has an active client connection (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
autoAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user was automatically set to away due to inactivity (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
manualAway: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether user manually set themselves as away (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
connectionCount: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Total number of active connections for the user (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
lastActivity: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Unix timestamp of last detected activity (only available when checking own presence)',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,31 +1,41 @@
|
||||
import { slackAddReactionTool } from '@/tools/slack/add_reaction'
|
||||
import { slackCanvasTool } from '@/tools/slack/canvas'
|
||||
import { slackCreateChannelCanvasTool } from '@/tools/slack/create_channel_canvas'
|
||||
import { slackDeleteMessageTool } from '@/tools/slack/delete_message'
|
||||
import { slackDownloadTool } from '@/tools/slack/download'
|
||||
import { slackEditCanvasTool } from '@/tools/slack/edit_canvas'
|
||||
import { slackEphemeralMessageTool } from '@/tools/slack/ephemeral_message'
|
||||
import { slackGetChannelInfoTool } from '@/tools/slack/get_channel_info'
|
||||
import { slackGetMessageTool } from '@/tools/slack/get_message'
|
||||
import { slackGetThreadTool } from '@/tools/slack/get_thread'
|
||||
import { slackGetUserTool } from '@/tools/slack/get_user'
|
||||
import { slackGetUserPresenceTool } from '@/tools/slack/get_user_presence'
|
||||
import { slackListChannelsTool } from '@/tools/slack/list_channels'
|
||||
import { slackListMembersTool } from '@/tools/slack/list_members'
|
||||
import { slackListUsersTool } from '@/tools/slack/list_users'
|
||||
import { slackMessageTool } from '@/tools/slack/message'
|
||||
import { slackMessageReaderTool } from '@/tools/slack/message_reader'
|
||||
import { slackRemoveReactionTool } from '@/tools/slack/remove_reaction'
|
||||
import { slackUpdateMessageTool } from '@/tools/slack/update_message'
|
||||
|
||||
export {
|
||||
slackMessageTool,
|
||||
slackCanvasTool,
|
||||
slackCreateChannelCanvasTool,
|
||||
slackMessageReaderTool,
|
||||
slackDownloadTool,
|
||||
slackEditCanvasTool,
|
||||
slackEphemeralMessageTool,
|
||||
slackUpdateMessageTool,
|
||||
slackDeleteMessageTool,
|
||||
slackAddReactionTool,
|
||||
slackRemoveReactionTool,
|
||||
slackGetChannelInfoTool,
|
||||
slackListChannelsTool,
|
||||
slackListMembersTool,
|
||||
slackListUsersTool,
|
||||
slackGetUserTool,
|
||||
slackGetUserPresenceTool,
|
||||
slackGetMessageTool,
|
||||
slackGetThreadTool,
|
||||
}
|
||||
|
||||
108
apps/sim/tools/slack/remove_reaction.ts
Normal file
108
apps/sim/tools/slack/remove_reaction.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { SlackRemoveReactionParams, SlackRemoveReactionResponse } from '@/tools/slack/types'
|
||||
import { REACTION_METADATA_OUTPUT_PROPERTIES } from '@/tools/slack/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const slackRemoveReactionTool: ToolConfig<
|
||||
SlackRemoveReactionParams,
|
||||
SlackRemoveReactionResponse
|
||||
> = {
|
||||
id: 'slack_remove_reaction',
|
||||
name: 'Slack Remove Reaction',
|
||||
description: 'Remove an emoji reaction from a Slack message',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'slack',
|
||||
},
|
||||
|
||||
params: {
|
||||
authMethod: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Authentication method: oauth or bot_token',
|
||||
},
|
||||
botToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Bot token for Custom Bot',
|
||||
},
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token or bot token for Slack API',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Channel ID where the message was posted (e.g., C1234567890)',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Timestamp of the message to remove reaction from (e.g., 1405894322.002768)',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Name of the emoji reaction to remove (without colons, e.g., thumbsup, heart, eyes)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/slack/remove-reaction',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: SlackRemoveReactionParams) => ({
|
||||
accessToken: params.accessToken || params.botToken,
|
||||
channel: params.channel,
|
||||
timestamp: params.timestamp,
|
||||
name: params.name,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
content: data.error || 'Failed to remove reaction',
|
||||
metadata: {
|
||||
channel: '',
|
||||
timestamp: '',
|
||||
reaction: '',
|
||||
},
|
||||
},
|
||||
error: data.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: data.output.content,
|
||||
metadata: data.output.metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Success message' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Reaction metadata',
|
||||
properties: REACTION_METADATA_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -561,6 +561,12 @@ export interface SlackAddReactionParams extends SlackBaseParams {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SlackRemoveReactionParams extends SlackBaseParams {
|
||||
channel: string
|
||||
timestamp: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SlackListChannelsParams extends SlackBaseParams {
|
||||
includePrivate?: boolean
|
||||
excludeArchived?: boolean
|
||||
@@ -600,6 +606,29 @@ export interface SlackGetThreadParams extends SlackBaseParams {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SlackGetChannelInfoParams extends SlackBaseParams {
|
||||
channel: string
|
||||
includeNumMembers?: boolean
|
||||
}
|
||||
|
||||
export interface SlackGetUserPresenceParams extends SlackBaseParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface SlackEditCanvasParams extends SlackBaseParams {
|
||||
canvasId: string
|
||||
operation: string
|
||||
content?: string
|
||||
sectionId?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface SlackCreateChannelCanvasParams extends SlackBaseParams {
|
||||
channel: string
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface SlackMessageResponse extends ToolResponse {
|
||||
output: {
|
||||
// Legacy properties for backward compatibility
|
||||
@@ -759,17 +788,34 @@ export interface SlackAddReactionResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackRemoveReactionResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
metadata: {
|
||||
channel: string
|
||||
timestamp: string
|
||||
reaction: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackChannel {
|
||||
id: string
|
||||
name: string
|
||||
is_channel?: boolean
|
||||
is_private: boolean
|
||||
is_archived: boolean
|
||||
is_general?: boolean
|
||||
is_member: boolean
|
||||
is_shared?: boolean
|
||||
is_ext_shared?: boolean
|
||||
is_org_shared?: boolean
|
||||
num_members?: number
|
||||
topic?: string
|
||||
purpose?: string
|
||||
created?: number
|
||||
creator?: string
|
||||
updated?: number
|
||||
}
|
||||
|
||||
export interface SlackListChannelsResponse extends ToolResponse {
|
||||
@@ -858,6 +904,35 @@ export interface SlackGetThreadResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetChannelInfoResponse extends ToolResponse {
|
||||
output: {
|
||||
channelInfo: SlackChannel
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackGetUserPresenceResponse extends ToolResponse {
|
||||
output: {
|
||||
presence: string
|
||||
online?: boolean | null
|
||||
autoAway?: boolean | null
|
||||
manualAway?: boolean | null
|
||||
connectionCount?: number | null
|
||||
lastActivity?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackEditCanvasResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlackCreateChannelCanvasResponse extends ToolResponse {
|
||||
output: {
|
||||
canvas_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SlackResponse =
|
||||
| SlackCanvasResponse
|
||||
| SlackMessageReaderResponse
|
||||
@@ -866,6 +941,7 @@ export type SlackResponse =
|
||||
| SlackUpdateMessageResponse
|
||||
| SlackDeleteMessageResponse
|
||||
| SlackAddReactionResponse
|
||||
| SlackRemoveReactionResponse
|
||||
| SlackListChannelsResponse
|
||||
| SlackListMembersResponse
|
||||
| SlackListUsersResponse
|
||||
@@ -873,3 +949,7 @@ export type SlackResponse =
|
||||
| SlackEphemeralMessageResponse
|
||||
| SlackGetMessageResponse
|
||||
| SlackGetThreadResponse
|
||||
| SlackGetChannelInfoResponse
|
||||
| SlackGetUserPresenceResponse
|
||||
| SlackEditCanvasResponse
|
||||
| SlackCreateChannelCanvasResponse
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -13,7 +13,7 @@
|
||||
"glob": "13.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.8.12",
|
||||
"turbo": "2.8.13",
|
||||
},
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -3493,19 +3493,19 @@
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"turbo": ["turbo@2.8.12", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.12", "turbo-darwin-arm64": "2.8.12", "turbo-linux-64": "2.8.12", "turbo-linux-arm64": "2.8.12", "turbo-windows-64": "2.8.12", "turbo-windows-arm64": "2.8.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-auUAMLmi0eJhxDhQrxzvuhfEbICnVt0CTiYQYY8WyRJ5nwCDZxD0JG8bCSxT4nusI2CwJzmZAay5BfF6LmK7Hw=="],
|
||||
"turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-EiHJmW2MeQQx+21x8hjMHw/uPhXt9PIxvDrxzOtyVwrXzL0tQmsxtO4qHf2l7uA+K6PUJ4+TjY1MHZDuCvWXrw=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cbqqGN0vd7ly2TeuaM8k9AK9u1CABO4kBA5KPSqovTiLL3sORccn/mZzJSbvQf0EsYRfU34MgW5FotfwW3kx8Q=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jXKw9j4r4q6s0goSXuKI3aKbQK2qiNeP25lGGEnq018TM6SWRW1CCpPMxyG91aCKrub7wDm/K45sGNT4ZFBcFQ=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-BRJCMdyXjyBoL0GYpvj9d2WNfMHwc3tKmJG5ATn2Efvil9LsiOsd/93/NxDqW0jACtHFNVOPnd/CBwXRPiRbwA=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.12", "", { "os": "win32", "cpu": "x64" }, "sha512-vyFOlpFFzQFkikvSVhVkESEfzIopgs2J7J1rYvtSwSHQ4zmHxkC95Q8Kjkus8gg+8X2mZyP1GS5jirmaypGiPw=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-9nRnlw5DF0LkJClkIws1evaIF36dmmMEO84J5Uj4oQ8C0QTHwlH7DNe5Kq2Jdmu8GXESCNDNuUYG8Cx6W/vm3g=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
|
||||
@@ -206,6 +206,10 @@ app:
|
||||
DISABLE_PUBLIC_API: "" # Set to "true" to disable public API toggle globally
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: "" # Set to "true" to hide public API toggle in UI
|
||||
|
||||
# Execute Command (Self-Hosted Only - runs shell commands on host machine)
|
||||
EXECUTE_COMMAND_ENABLED: "" # Set to "true" to enable shell command execution in workflows (self-hosted only)
|
||||
NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: "" # Set to "true" to show Execute Command block in UI
|
||||
|
||||
# SSO Configuration (Enterprise Single Sign-On)
|
||||
# Set to "true" AFTER running the SSO registration script
|
||||
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"glob": "13.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.8.12"
|
||||
"turbo": "2.8.13"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,scss}": [
|
||||
|
||||
Reference in New Issue
Block a user