Compare commits

...

23 Commits

Author SHA1 Message Date
Waleed
70c36cb7aa v0.5.105: slack remove reaction, nested subflow locks fix, servicenow pagination, memory improvements 2026-03-04 22:38:26 -08:00
Waleed
0a6a2ee694 feat(slack): add new tools and user selectors (#3420)
* feat(slack): add new tools and user selectors

* fix(slack): fix download fileName param and canvas error handling

* fix(slack): use markdown format for canvas rename title_content

* fix(slack): rename channel output to channelInfo and document presence API limitation

* lint

* fix(chat): use explicit trigger type check instead of heuristic for chat guard (#3419)

* fix(chat): use explicit trigger type check instead of heuristic for chat guard

* fix(chat): remove heuristic fallback from isExecutingFromChat

Use only overrideTriggerType === 'chat' instead of also checking
for 'input' in workflowInput, which can false-positive on manual
executions with workflow input.

* fix(chat): use isExecutingFromChat variable consistently in callbacks

Replace inline overrideTriggerType !== 'chat' checks with
!isExecutingFromChat to stay consistent with the rest of the function.

* fix(slack): add missing fields to SlackChannel interface

* fix(slack): fix canvas transformResponse type mismatch

Provide required output fields on error path to match SlackCanvasResponse type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(slack): move error field to top level in canvas transformResponse

The error field belongs on ToolResponse, not inside the output object.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:28:10 -08:00
Waleed
8579beb199 fix(chat): use explicit trigger type check instead of heuristic for chat guard (#3419)
* fix(chat): use explicit trigger type check instead of heuristic for chat guard

* fix(chat): remove heuristic fallback from isExecutingFromChat

Use only overrideTriggerType === 'chat' instead of also checking
for 'input' in workflowInput, which can false-positive on manual
executions with workflow input.

* fix(chat): use isExecutingFromChat variable consistently in callbacks

Replace inline overrideTriggerType !== 'chat' checks with
!isExecutingFromChat to stay consistent with the rest of the function.
2026-03-04 19:05:45 -08:00
Waleed
115b4581a5 fix(editor): pass workspaceId to useCredentialName in block preview (#3418) 2026-03-04 18:15:27 -08:00
Waleed
fcdcaed00d fix(memory): add Bun.gc, stream cancellation, and unconsumed fetch drains (#3416)
* fix(memory): add Bun.gc, stream cancellation, and unconsumed fetch drains

* fix(memory): await reader.cancel() and use non-blocking Bun.gc

* fix(memory): update Bun.gc comment to match non-blocking call

* fix(memory): use response.body.cancel() instead of response.text() for drains

* fix(executor): flush TextDecoder after streaming loop for multi-byte chars

* fix(memory): use text() drain for SecureFetchResponse which lacks body property

* fix(chat): prevent premature isExecuting=false from killing chat stream

The onExecutionCompleted/Error/Cancelled callbacks were setting
isExecuting=false as soon as the server-side SSE stream completed.
For chat executions, this triggered a useEffect in chat.tsx that
cancelled the client-side stream reader before it finished consuming
buffered data — causing empty or partial chat responses.

Skip the isExecuting=false in these callbacks for chat executions
since the chat's own finally block handles cleanup after the stream
is fully consumed.

* fix(chat): remove useEffect anti-pattern that killed chat stream on state change

The effect reacted to isExecuting becoming false to clean up streams,
but this is an anti-pattern per React guidelines — using state changes
as a proxy for events. All cleanup cases are already handled by proper
event paths: stream done (processStreamingResponse), user cancel
(handleStopStreaming), component unmount (cleanup effect), and
abort/error (catch block).

* fix(servicenow): remove invalid string comparison on numeric offset param

* upgrade turborepo
2026-03-04 17:46:20 -08:00
Waleed
04fa31864b feat(servicenow): add offset and display value params to read records (#3415)
* feat(servicenow): add offset and display value params to read records

* fix(servicenow): address greptile review feedback for offset and displayValue

* fix(servicenow): handle offset=0 correctly in pagination

* fix(servicenow): guard offset against empty string in URL builder
2026-03-04 17:01:31 -08:00
Waleed
6b355e9b54 fix(subflows): recurse into all descendants for lock, enable, and protection checks (#3412)
* fix(subflows): recurse into all descendants for lock, enable, and protection checks

* fix(subflows): prevent container resize on initial render and clean up code

- Add canvasReadyRef to skip container dimension recalculation during
  ReactFlow init — position changes from extent clamping fired before
  block heights are measured, causing containers to resize on page load
- Resolve globals.css merge conflict, remove global z-index overrides
  (handled via ReactFlow zIndex prop instead)
- Clean up subflow-node: hoist static helpers to module scope, remove
  unused ref, fix nested ternary readability, rename outlineColor→ringColor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subflows): use full ancestor-chain protection for descendant enable-toggle

The enable-toggle for descendants was checking only direct `locked` status
instead of walking the full ancestor chain via `isBlockProtected`. This meant
a block nested 2+ levels inside a locked subflow could still be toggled.
Also added TSDoc clarifying why boxShadow works for subflow ring indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert(subflows): remove canvasReadyRef height-gating approach

The canvasReadyRef gating in onNodesChange didn't fully fix the
container resize-on-load issue. Reverting to address properly later.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unintentional edge-interaction CSS from globals

Leftover from merge conflict resolution — not part of this PR's changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(editor): correct isAncestorLocked when block and ancestor both locked, restore fade-in transition

isAncestorLocked was derived from isBlockProtected which short-circuits
on block.locked, so a self-locked block inside a locked ancestor showed
"Unlock block" instead of "Ancestor container is locked". Now walks the
ancestor chain independently.

Also restores the accidentally removed transition-opacity duration-150
class on the ReactFlow container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subflows): use full ancestor-chain protection for top-level enable-toggle, restore edge-label z-index

The top-level block check in batchToggleEnabled used block.locked (self
only) while descendants used isBlockProtected (full ancestor chain). A
block inside a locked ancestor but not itself locked would bypass the
check. Now all three layers (store, collaborative hook, DB operations)
consistently use isBlockProtected/isDbBlockProtected at both levels.

Also restores the accidentally removed edge-labels z-index rule, bumped
from 60 to 1001 so labels render above child nodes (zIndex: 1000).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(subflows): extract isAncestorProtected utility, add cycle detection to all traversals

- Extract isAncestorProtected from utils.ts so editor.tsx doesn't
  duplicate the ancestor-chain walk. isBlockProtected now delegates to it.
- Add visited-set cycle detection to all ancestor walks
  (isBlockProtected, isAncestorProtected, isDbBlockProtected) and
  descendant searches (findAllDescendantNodes, findDbDescendants) to
  guard against corrupt parentId references.
- Document why click-catching div has no event bubbling concern
  (ReactFlow renders children as viewport siblings, not DOM children).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:51:32 -08:00
Waleed
127994f077 feat(slack): add remove reaction tool (#3414)
* feat(slack): add remove reaction tool

* lint
2026-03-04 15:28:41 -08:00
Waleed
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -08:00
Waleed
efc1aeed70 fix(subflows): fix pointer events for nested subflow interaction (#3409)
* fix(subflows): fix pointer events for nested subflow interaction

* fix(subflows): use Tailwind class for pointer-events-none
2026-03-03 23:28:51 -08:00
Waleed
e07e3c34cc v0.5.103: memory util instrumentation, API docs, amplitude, google pagespeed insights, pagerduty 2026-03-01 23:27:02 -08:00
Waleed
0d2e6ff31d v0.5.102: new integrations, new tools, ci speedups, memory leak instrumentation 2026-02-28 12:48:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
40 changed files with 1548 additions and 337 deletions

View File

@@ -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

View File

@@ -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 |

View 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 }
)
}
}

View File

@@ -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}`)
}

View File

@@ -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}`)
}

View File

@@ -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}` },

View File

@@ -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 }

View File

@@ -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 {}
}
}

View File

@@ -164,7 +164,7 @@ export const ActionBar = memo(
return (
<div
className={cn(
'-top-[46px] absolute right-0',
'-top-[46px] pointer-events-auto absolute right-0',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] p-[5px]',

View File

@@ -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

View File

@@ -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'}

View File

@@ -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 relative'>
<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}

View File

@@ -527,7 +527,8 @@ const SubBlockRow = memo(function SubBlockRow({
const { displayName: credentialName } = useCredentialName(
credentialSourceId,
credentialProviderId,
workflowId
workflowId,
workspaceId
)
const credentialId = dependencyValues.credential

View File

@@ -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())

View File

@@ -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.

View File

@@ -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}
/>

View File

@@ -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' },

View File

@@ -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' },

View File

@@ -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
}

View File

@@ -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
})
}
}

View File

@@ -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()

View File

@@ -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}`
)

View File

@@ -95,6 +95,7 @@ const nextConfig: NextConfig = {
optimizeCss: true,
turbopackSourceMaps: false,
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
},
...(isDev && {
allowedDevOrigins: [

View File

@@ -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) {

View File

@@ -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)
})
}
}

View File

@@ -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
*

View File

@@ -239,6 +239,7 @@ export async function downloadAttachments(
)
if (!attachmentResponse.ok) {
await attachmentResponse.body?.cancel().catch(() => {})
continue
}

View File

@@ -1797,17 +1797,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'
@@ -2611,6 +2616,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,

View File

@@ -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
},

View File

@@ -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 {

View File

@@ -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: {

View 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' },
},
}

View 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' },
},
}

View 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,
},
},
}

View 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,
},
},
}

View File

@@ -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,
}

View 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,
},
},
}

View File

@@ -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

View File

@@ -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=="],

View File

@@ -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}": [