Compare commits

...

46 Commits

Author SHA1 Message Date
Waleed
e9bdc57616 v0.5.112: trace spans improvements, fathom integration, jira fixes, canvas navigation updates 2026-03-12 13:30:20 -07:00
Waleed
e7b4da2689 feat(slack): add email field to get user and list users tools (#3509)
* feat(slack): add email field to get user and list users tools

* fix(slack): use empty string fallback for email and make type non-optional

* fix(slack): comment out users:read.email scope pending app review
2026-03-12 13:27:37 -07:00
Waleed
aa0101c666 fix(blocks): clarify condition ID suffix slicing for readability (#3546)
Use explicit hyphen separator instead of relying on slice offset to
implicitly include the hyphen in the suffix, making the intent clearer.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:26:11 -07:00
Waleed
c939f8a76e fix(jira): add explicit fields parameter to search/jql endpoint (#3544)
The GET /rest/api/3/search/jql endpoint requires an explicit `fields`
parameter to return issue data. Without it, only the issue `id` is
returned with all other fields empty. This adds `fields=*all` as the
default when the user doesn't specify custom fields.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:51:27 -07:00
Waleed
0b19ad0013 improvement(canvas): enable middle mouse button panning in cursor mode (#3542) 2026-03-12 12:44:15 -07:00
Waleed
3d5141d852 chore(oauth): remove unused github-repo generic OAuth provider (#3543) 2026-03-12 12:39:31 -07:00
Waleed
75832ca007 fix(jira): add missing write:attachment:jira oauth scope (#3541) 2026-03-12 12:13:57 -07:00
Waleed
97f78c60b4 feat(tools): add Fathom AI Notetaker integration (#3531)
* feat(fathom): add Fathom AI Notetaker integration

* fix(fathom): address PR review feedback

- Add response.ok checks to all 5 tool transformResponse functions
- Fix include_summary default to respect explicit false (check undefined)
- Add externalId validation before URL interpolation in webhook deletion

* fix(fathom): address second round PR review feedback

- Remove redundant 204 status check in deleteFathomWebhook (204 is ok)
- Use consistent undefined-guard pattern for all include flags
- Add .catch() fallback on webhook creation JSON parse
- Change recording_id default from 0 to null to avoid misleading sentinel

* fix(fathom): add missing crm_matches to list_meetings transform and fix action_items type

- Add crm_matches pass-through in list_meetings transform (was silently dropped)
- Fix action_items type to match API schema (description, user_generated, completed, etc.)
- Add crm_matches type with contacts, companies, deals, error fields

* fix(fathom): guard against undefined webhook id on creation success

* fix(fathom): add type to nested trigger outputs and fix boolean coercion

- Add type: 'object' to recorded_by and default_summary trigger outputs
- Use val === true || val === 'true' pattern for include flag coercion
  to safely handle both boolean and string values from providerConfig

---------

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-03-12 11:00:07 -07:00
Waleed
9295499405 fix(traces): prevent condition blocks from rendering source agent's timeSegments (#3534)
* fix(traces): prevent condition blocks from rendering source agent's timeSegments

Condition blocks spread their source block's entire output into their own
output. When the source is an agent, this leaked providerTiming/timeSegments
into the condition's output, causing buildTraceSpans to create "Initial
response" as a child of the condition span instead of the agent span.

Two fixes:
- Skip timeSegment child creation for condition block types in buildTraceSpans
- Filter execution metadata (providerTiming, tokens, toolCalls, model, cost)
  from condition handler's filterSourceOutput

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

* fix(traces): guard condition blocks from leaked metadata on old persisted logs

Extend isConditionBlockType guards to also skip setting span.providerTiming,
span.cost, span.tokens, and span.model for condition blocks. This ensures
old persisted logs (recorded before the filterSourceOutput fix) don't display
misleading execution metadata on condition spans.

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

* fix(traces): guard toolCalls fallback path for condition blocks on old logs

The else branch that extracts toolCalls from log.output also needs a
condition block guard, otherwise old persisted logs with leaked toolCalls
from the source agent would render on the condition span.

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

* refactor(traces): extract isCondition to local variable for readability

Cache isConditionBlockType(log.blockType) in a local const at the top
of the forEach loop instead of calling it 6 times per iteration.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:39:02 -07:00
Waleed
6bcbd15ee6 fix(blocks): remap condition/router IDs when duplicating blocks (#3533)
* fix(blocks): remap condition/router IDs when duplicating blocks

Condition and router blocks embed IDs in the format `{blockId}-{suffix}`
inside their subBlock values and edge sourceHandles. When blocks were
duplicated, these IDs were not updated to reference the new block ID,
causing duplicate handle IDs and broken edge routing.

Fixes all four duplication paths: single block duplicate, copy/paste,
workflow duplication (server-side), and workflow import.

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

* fix(blocks): deep-clone subBlocks before mutating condition IDs

Shallow copy of subBlocks meant remapConditionIds could mutate the
source data (clipboard on repeated paste, or input workflowState on
import). Deep-clone subBlocks in both regenerateBlockIds and
regenerateWorkflowIds to prevent this.

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

* fix(blocks): remap condition IDs in regenerateWorkflowStateIds (template use)

The template use code path was missing condition/router ID remapping,
causing broken condition blocks when creating workflows from templates.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:19:38 -07:00
Vikhyath Mondreti
36612ae42a v0.5.111: non-polling webhook execs off trigger.dev, gmail subject headers, webhook trigger configs (#3530) 2026-03-11 17:47:28 -07:00
Vikhyath Mondreti
68d207df94 improvement(webhooks): move non-polling executions off trigger.dev (#3527)
* improvement(webhooks): move non-polling off trigger.dev

* restore constants file

* improve comment

* add unit test to prevent drift
2026-03-11 17:07:24 -07:00
Vikhyath Mondreti
d5502d602b feat(webhooks): dedup and custom ack configuration (#3525)
* feat(webhooks): dedup and custom ack configuration

* address review comments

* reject object typed idempotency key
2026-03-11 15:51:35 -07:00
Waleed
37d524bb0a fix(gmail): RFC 2047 encode subject headers for non-ASCII characters (#3526)
* fix(gmail): RFC 2047 encode subject headers for non-ASCII characters

* Fix RFC 2047 encoded word length limit

Split long email subjects into multiple RFC 2047 encoded words to comply with the 75-character limit per RFC 2047 Section 2. Each encoded word now contains at most 45 bytes of UTF-8 content (producing max 60 chars of base64 + 12 chars overhead = 72 total). Multiple encoded words are separated by CRLF + space (folding whitespace).

Applied via @cursor push command

* fix(gmail): split RFC 2047 encoded words on character boundaries

* fix(gmail): simplify RFC 2047 encoding to match Google's own sample

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-11 15:48:07 -07:00
Waleed
1c2c2c65d4 v0.5.110: webhook execution speedups, SSRF patches 2026-03-11 15:00:24 -07:00
Waleed
19ef526886 fix(webhooks): eliminate redundant DB queries from webhook execution path (#3523)
* fix(webhooks): eliminate redundant DB queries from webhook execution path

* chore(webhooks): remove implementation-detail comments

* fix(webhooks): restore auth-first ordering and add credential resolution warning

- Revert parallel auth+preprocessing to sequential auth→preprocessing
  to prevent rate-limit exhaustion via unauthenticated requests
- Add warning log when credential account resolution fails in background job

* fix(webhooks): restore auth-before-reachability ordering and remove dead credentialAccountUserId field

- Move reachability test back after auth to prevent path enumeration
- Remove dead credentialAccountUserId from WebhookExecutionPayload
- Simplify credential resolution condition in background job
2026-03-11 14:51:04 -07:00
Waleed
ff2a1527ab fix(security): add SSRF protection to database tools and webhook delivery (#3500)
* fix(security): add SSRF protection to database tools and webhook delivery

* fix(security): address review comments on SSRF PR

- Remove Promise.race timeout pattern to avoid unhandled rejections
  (http.request timeout is sufficient for webhook delivery)
- Use safeCompare in verifyCronAuth instead of inline HMAC logic
- Strip IPv6 brackets before validateDatabaseHost in Redis route

* fix(security): allow HTTP webhooks and fix misleading MCP error docs

- Add allowHttp option to validateExternalUrl, validateUrlWithDNS,
  and secureFetchWithValidation to support HTTP webhook URLs
- Pass allowHttp: true for webhook delivery and test endpoints
- Fix misleading JSDoc on createMcpErrorResponse (doesn't log errors)
- Mark unused error param with underscore prefix

* fix(security): forward allowHttp option through redirect validation

Pass allowHttp to validateUrlWithDNS in the redirect handler of
secureFetchWithPinnedIP so HTTP-to-HTTP redirects work when allowHttp
is enabled for webhook delivery.

* fix(security): block localhost when allowHttp is enabled

When allowHttp is true (user-supplied webhook URLs), explicitly block
localhost/loopback in both validateExternalUrl and validateUrlWithDNS
to prevent SSRF against internal services.

* fix(security): always strip multi-line content in sanitizeConnectionError

Take the first line of the error message regardless of length to
prevent leaking sensitive data from multi-line error messages.
2026-03-09 20:28:28 -07:00
Waleed
2e1c639a81 fix(parallel): align integration with Parallel AI API docs (#3501)
* fix(parallel): align integration with Parallel AI API docs

* fix(parallel): keep processor subBlock ID for backwards compatibility

* fix(parallel): move error field to top level per ToolResponse interface

* fix(parallel): guard research_input and prevent domain leakage across operations

* fix(parallel): make url/title nullable in types to match transformResponse

* fix(parallel): revert search_queries param type to string for backwards compatibility
2026-03-09 19:47:30 -07:00
Waleed
ecd3536a72 v0.5.109: obsidian and evernote integrations, slack fixes, remove memory instrumentation 2026-03-09 10:40:37 -07:00
Theodore Li
635179d696 Revert "feat(hosted key): Add exa hosted key (#3221)" (#3495)
This reverts commit 158d5236bc.

Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-09 10:31:54 -07:00
Waleed
f88926a6a8 fix(webhooks): return empty 200 for Slack to close modals cleanly (#3492)
* fix(webhooks): return empty 200 for Slack to close modals cleanly

* fix(webhooks): add clarifying comment on Slack error path trade-off
2026-03-09 10:11:36 -07:00
Waleed
690b47a0bf chore(monitoring): remove SSE connection tracking and Bun.gc debug instrumentation (#3472) 2026-03-08 17:27:05 -07:00
Theodore Li
158d5236bc feat(hosted key): Add exa hosted key (#3221)
* feat(hosted keys): Implement serper hosted key

* Handle required fields correctly for hosted keys

* Add rate limiting (3 tries, exponential backoff)

* Add custom pricing, switch to exa as first hosted key

* Add telemetry

* Consolidate byok type definitions

* Add warning comment if default calculation is used

* Record usage to user stats table

* Fix unit tests, use cost property

* Include more metadata in cost output

* Fix disabled tests

* Fix spacing

* Fix lint

* Move knowledge cost restructuring away from generic block handler

* Migrate knowledge unit tests

* Lint

* Fix broken tests

* Add user based hosted key throttling

* Refactor hosted key handling. Add optimistic handling of throttling for custom throttle rules.

* Remove research as hosted key. Recommend BYOK if throtttling occurs

* Make adding api keys adjustable via env vars

* Remove vestigial fields from research

* Make billing actor id required for throttling

* Switch to round robin for api key distribution

* Add helper method for adding hosted key cost

* Strip leading double underscores to avoid breaking change

* Lint fix

* Remove falsy check in favor for explicit null check

* Add more detailed metrics for different throttling types

* Fix _costDollars field

* Handle hosted agent tool calls

* Fail loudly if cost field isn't found

* Remove any type

* Fix type error

* Fix lint

* Fix usage log double logging data

* Fix test

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-07 13:06:57 -05:00
Waleed
1ba1bc8edb feat(evernote): add Evernote integration with 11 tools (#3456)
* feat(evernote): add Evernote integration with 11 tools

* fix(evernote): fix signed integer mismatch in Thrift version check

* fix(evernote): fix exception field mapping and add sandbox support

* fix(evernote): address PR review feedback

* fix(evernote): clamp maxNotes to Evernote's 250 limit

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:52:57 -08:00
Waleed
53fd92a30a feat(obsidian): add Obsidian integration with 15 tools (#3455)
* feat(obsidian): add Obsidian integration with 15 tools

* fix(obsidian): encode path segments individually to preserve slashes

* improvement(obsidian): add type re-exports and improve output descriptions

* fix(obsidian): remove unreachable 404 handling from transformResponse
2026-03-06 23:13:47 -08:00
Vikhyath Mondreti
8c0a2e04b1 v0.5.108: workflow input params in agent tools, bun upgrade, dropdown selectors for 14 blocks 2026-03-06 21:02:25 -08:00
Waleed
0a52b09deb feat(jira): add search_users tool for user lookup by email (#3451)
* feat(jira): add search_users tool for user lookup by email

* improvement(jira): reuse shared transformUser utility in search_users

* improvement(jira): add pagination fields to search_users response

* update

* fix(jira): filter falsy entries before transforming search_users results

* fix(jira): add defensive fallback for nullable transformUser in search_users

* fix(jira): align search_users response type with transformUser return type
2026-03-06 19:52:37 -08:00
Vikhyath Mondreti
1d36b80172 improvement(selectors): remove dead semantic fallback code (#3454)
* improvement(selectors): simplify selectorContext + add tests

* fix resolve values fallback

* another workflowid pass through

* remove dead code

* make workspace id required
2026-03-06 19:38:57 -08:00
Vikhyath Mondreti
e6a5e7f4e4 improvement(selectors): simplify selector context + add tests (#3453)
* improvement(selectors): simplify selectorContext + add tests

* fix resolve values fallback

* another workflowid pass through
2026-03-06 18:30:46 -08:00
Waleed
6586c5ce40 v0.5.107: new reddit, slack tools 2026-03-05 22:48:20 -08:00
Vikhyath Mondreti
3ce947566d v0.5.106: condition block and legacy kbs fixes, GPT 5.4 2026-03-05 17:30:05 -08:00
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
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -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
175 changed files with 8940 additions and 1060 deletions

View File

@@ -710,6 +710,155 @@ export function PerplexityIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function ObsidianIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const bl = `${id}-bl`
const tr = `${id}-tr`
const tl = `${id}-tl`
const br = `${id}-br`
const te = `${id}-te`
const le = `${id}-le`
const be = `${id}-be`
const me = `${id}-me`
const clip = `${id}-clip`
return (
<svg {...props} viewBox='0 0 512 512' fill='none' xmlns='http://www.w3.org/2000/svg'>
<radialGradient
id={bl}
cx='0'
cy='0'
gradientTransform='matrix(-59 -225 150 -39 161.4 470)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.4' />
<stop offset='1' stopOpacity='.1' />
</radialGradient>
<radialGradient
id={tr}
cx='0'
cy='0'
gradientTransform='matrix(50 -379 280 37 360 374.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.6' />
<stop offset='1' stopColor='#fff' stopOpacity='.1' />
</radialGradient>
<radialGradient
id={tl}
cx='0'
cy='0'
gradientTransform='matrix(69 -319 218 47 175.4 307)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.8' />
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
</radialGradient>
<radialGradient
id={br}
cx='0'
cy='0'
gradientTransform='matrix(-96 -163 187 -111 335.3 512.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.3' />
<stop offset='1' stopOpacity='.3' />
</radialGradient>
<radialGradient
id={te}
cx='0'
cy='0'
gradientTransform='matrix(-36 166 -112 -24 310 128.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='0' />
<stop offset='1' stopColor='#fff' stopOpacity='.2' />
</radialGradient>
<radialGradient
id={le}
cx='0'
cy='0'
gradientTransform='matrix(88 89 -190 187 111 220.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
</radialGradient>
<radialGradient
id={be}
cx='0'
cy='0'
gradientTransform='matrix(9 130 -276 20 215 284)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
</radialGradient>
<radialGradient
id={me}
cx='0'
cy='0'
gradientTransform='matrix(-198 -104 327 -623 400 399.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
<stop offset='.5' stopColor='#fff' stopOpacity='.2' />
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
</radialGradient>
<clipPath id={clip}>
<path d='M.2.2h512v512H.2z' />
</clipPath>
<g clipPath={`url(#${clip})`}>
<path
d='M382.3 475.6c-3.1 23.4-26 41.6-48.7 35.3-32.4-8.9-69.9-22.8-103.6-25.4l-51.7-4a34 34 0 0 1-22-10.2l-89-91.7a34 34 0 0 1-6.7-37.7s55-121 57.1-127.3c2-6.3 9.6-61.2 14-90.6 1.2-7.9 5-15 11-20.3L248 8.9a34.1 34.1 0 0 1 49.6 4.3L386 125.6a37 37 0 0 1 7.6 22.4c0 21.3 1.8 65 13.6 93.2 11.5 27.3 32.5 57 43.5 71.5a17.3 17.3 0 0 1 1.3 19.2 1494 1494 0 0 1-44.8 70.6c-15 22.3-21.9 49.9-25 73.1z'
fill='#6c31e3'
/>
<path
d='M165.9 478.3c41.4-84 40.2-144.2 22.6-187-16.2-39.6-46.3-64.5-70-80-.6 2.3-1.3 4.4-2.2 6.5L60.6 342a34 34 0 0 0 6.6 37.7l89.1 91.7a34 34 0 0 0 9.6 7z'
fill={`url(#${bl})`}
/>
<path
d='M278.4 307.8c11.2 1.2 22.2 3.6 32.8 7.6 34 12.7 65 41.2 90.5 96.3 1.8-3.1 3.6-6.2 5.6-9.2a1536 1536 0 0 0 44.8-70.6 17 17 0 0 0-1.3-19.2c-11-14.6-32-44.2-43.5-71.5-11.8-28.2-13.5-72-13.6-93.2 0-8.1-2.6-16-7.6-22.4L297.6 13.2a34 34 0 0 0-1.5-1.7 96 96 0 0 1 2 54 198.3 198.3 0 0 1-17.6 41.3l-7.2 14.2a171 171 0 0 0-19.4 71c-1.2 29.4 4.8 66.4 24.5 115.8z'
fill={`url(#${tr})`}
/>
<path
d='M278.4 307.8c-19.7-49.4-25.8-86.4-24.5-115.9a171 171 0 0 1 19.4-71c2.3-4.8 4.8-9.5 7.2-14.1 7.1-13.9 14-27 17.6-41.4a96 96 0 0 0-2-54A34.1 34.1 0 0 0 248 9l-105.4 94.8a34.1 34.1 0 0 0-10.9 20.3l-12.8 85-.5 2.3c23.8 15.5 54 40.4 70.1 80a147 147 0 0 1 7.8 24.8c28-6.8 55.7-11 82.1-8.3z'
fill={`url(#${tl})`}
/>
<path
d='M333.6 511c22.7 6.2 45.6-12 48.7-35.4a187 187 0 0 1 19.4-63.9c-25.6-55-56.5-83.6-90.4-96.3-36-13.4-75.2-9-115 .7 8.9 40.4 3.6 93.3-30.4 162.2 4 1.8 8.1 3 12.5 3.3 0 0 24.4 2 53.6 4.1 29 2 72.4 17.1 101.6 25.2z'
fill={`url(#${br})`}
/>
<g clipRule='evenodd' fillRule='evenodd'>
<path
d='M254.1 190c-1.3 29.2 2.4 62.8 22.1 112.1l-6.2-.5c-17.7-51.5-21.5-78-20.2-107.6a174.7 174.7 0 0 1 20.4-72c2.4-4.9 8-14.1 10.5-18.8 7.1-13.7 11.9-21 16-33.6 5.7-17.5 4.5-25.9 3.8-34.1 4.6 29.9-12.7 56-25.7 82.4a177.1 177.1 0 0 0-20.7 72z'
fill={`url(#${te})`}
/>
<path
d='M194.3 293.4c2.4 5.4 4.6 9.8 6 16.5L195 311c-2.1-7.8-3.8-13.4-6.8-20-17.8-42-46.3-63.6-69.7-79.5 28.2 15.2 57.2 39 75.7 81.9z'
fill={`url(#${le})`}
/>
<path
d='M200.6 315.1c9.8 46-1.2 104.2-33.6 160.9 27.1-56.2 40.2-110.1 29.3-160z'
fill={`url(#${be})`}
/>
<path
d='M312.5 311c53.1 19.9 73.6 63.6 88.9 100-19-38.1-45.2-80.3-90.8-96-34.8-11.8-64.1-10.4-114.3 1l-1.1-5c53.2-12.1 81-13.5 117.3 0z'
fill={`url(#${me})`}
/>
</g>
</g>
</svg>
)
}
export function NotionIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='1em' height='1em' {...props}>
@@ -1806,6 +1955,14 @@ export function Mem0Icon(props: SVGProps<SVGSVGElement>) {
)
}
export function EvernoteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='#7fce2c'>
<path d='M29.343 16.818c.1 1.695-.08 3.368-.305 5.045-.225 1.712-.508 3.416-.964 5.084-.3 1.067-.673 2.1-1.202 3.074-.65 1.192-1.635 1.87-2.992 1.924l-3.832.036c-.636-.017-1.278-.146-1.9-.297-1.192-.3-1.862-1.1-2.06-2.3-.186-1.08-.173-2.187.04-3.264.252-1.23 1-1.96 2.234-2.103.817-.1 1.65-.077 2.476-.1.205-.007.275.098.203.287-.196.53-.236 1.07-.098 1.623.053.207-.023.307-.26.305a7.77 7.77 0 0 0-1.123.053c-.636.086-.96.47-.96 1.112 0 .205.026.416.066.622.103.507.45.78.944.837 1.123.127 2.247.138 3.37-.05.675-.114 1.08-.54 1.16-1.208.152-1.3.155-2.587-.228-3.845-.33-1.092-1.006-1.565-2.134-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.757-.013-.106-.074-.208-.1-.3-.04.1-.106.212-.117.326-.066.68-.053 1.373-.185 2.04-.16.8-.404 1.566-.67 2.33-.185.535-.616.837-1.205.8a37.76 37.76 0 0 1-7.123-1.353l-.64-.207c-.927-.26-1.487-.903-1.74-1.787l-1-3.853-.74-4.3c-.115-.755-.2-1.523-.083-2.293.154-1.112.914-1.903 2.04-1.964l3.558-.062c.127 0 .254.003.373-.026a1.23 1.23 0 0 0 1.01-1.255l-.05-3.036c-.048-1.576.8-2.38 2.156-2.622a10.58 10.58 0 0 1 4.91.26c.933.275 1.467.923 1.715 1.83.058.22.146.3.37.287l2.582.01 3.333.37c.686.095 1.364.25 2.032.42 1.165.298 1.793 1.112 1.962 2.256l.357 3.355.3 5.577.01 2.277zm-4.534-1.155c-.02-.666-.07-1.267-.444-1.784a1.66 1.66 0 0 0-2.469-.15c-.364.4-.494.88-.564 1.4-.008.034.106.126.16.126l.8-.053c.768.007 1.523.113 2.25.393.066.026.136.04.265.077zM8.787 1.154a3.82 3.82 0 0 0-.278 1.592l.05 2.934c.005.357-.075.45-.433.45L5.1 6.156c-.583 0-1.143.1-1.554.278l5.2-5.332c.02.013.04.033.06.053z' />
</svg>
)
}
export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1822,6 +1979,24 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
<path
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
fill='#007299'
/>
<path
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
<path
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
</svg>
)
}
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>

View File

@@ -40,8 +40,10 @@ import {
ElasticsearchIcon,
ElevenLabsIcon,
EnrichSoIcon,
EvernoteIcon,
ExaAIIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
FirefliesIcon,
GammaIcon,
@@ -103,6 +105,7 @@ import {
MySQLIcon,
Neo4jIcon,
NotionIcon,
ObsidianIcon,
OnePasswordIcon,
OpenAIIcon,
OutlookIcon,
@@ -202,7 +205,9 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon,
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies_v2: FirefliesIcon,
@@ -265,6 +270,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
mysql: MySQLIcon,
neo4j: Neo4jIcon,
notion_v2: NotionIcon,
obsidian: ObsidianIcon,
onedrive: MicrosoftOneDriveIcon,
onepassword: OnePasswordIcon,
openai: OpenAIIcon,

View File

@@ -0,0 +1,267 @@
---
title: Evernote
description: Manage notes, notebooks, and tags in Evernote
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="evernote"
color="#E0E0E0"
/>
## Usage Instructions
Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags.
## Tools
### `evernote_copy_note`
Copy a note to another notebook in Evernote
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `noteGuid` | string | Yes | GUID of the note to copy |
| `toNotebookGuid` | string | Yes | GUID of the destination notebook |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `note` | object | The copied note metadata |
| ↳ `guid` | string | New note GUID |
| ↳ `title` | string | Note title |
| ↳ `notebookGuid` | string | GUID of the destination notebook |
| ↳ `created` | number | Creation timestamp in milliseconds |
| ↳ `updated` | number | Last updated timestamp in milliseconds |
### `evernote_create_note`
Create a new note in Evernote
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `title` | string | Yes | Title of the note |
| `content` | string | Yes | Content of the note \(plain text or ENML\) |
| `notebookGuid` | string | No | GUID of the notebook to create the note in \(defaults to default notebook\) |
| `tagNames` | string | No | Comma-separated list of tag names to apply |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `note` | object | The created note |
| ↳ `guid` | string | Unique identifier of the note |
| ↳ `title` | string | Title of the note |
| ↳ `content` | string | ENML content of the note |
| ↳ `notebookGuid` | string | GUID of the containing notebook |
| ↳ `tagNames` | array | Tag names applied to the note |
| ↳ `created` | number | Creation timestamp in milliseconds |
| ↳ `updated` | number | Last updated timestamp in milliseconds |
### `evernote_create_notebook`
Create a new notebook in Evernote
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `name` | string | Yes | Name for the new notebook |
| `stack` | string | No | Stack name to group the notebook under |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `notebook` | object | The created notebook |
| ↳ `guid` | string | Notebook GUID |
| ↳ `name` | string | Notebook name |
| ↳ `defaultNotebook` | boolean | Whether this is the default notebook |
| ↳ `serviceCreated` | number | Creation timestamp in milliseconds |
| ↳ `serviceUpdated` | number | Last updated timestamp in milliseconds |
| ↳ `stack` | string | Notebook stack name |
### `evernote_create_tag`
Create a new tag in Evernote
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `name` | string | Yes | Name for the new tag |
| `parentGuid` | string | No | GUID of the parent tag for hierarchy |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tag` | object | The created tag |
| ↳ `guid` | string | Tag GUID |
| ↳ `name` | string | Tag name |
| ↳ `parentGuid` | string | Parent tag GUID |
| ↳ `updateSequenceNum` | number | Update sequence number |
### `evernote_delete_note`
Move a note to the trash in Evernote
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `noteGuid` | string | Yes | GUID of the note to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the note was successfully deleted |
| `noteGuid` | string | GUID of the deleted note |
### `evernote_get_note`
Retrieve a note from Evernote by its GUID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `noteGuid` | string | Yes | GUID of the note to retrieve |
| `withContent` | boolean | No | Whether to include note content \(default: true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `note` | object | The retrieved note |
| ↳ `guid` | string | Unique identifier of the note |
| ↳ `title` | string | Title of the note |
| ↳ `content` | string | ENML content of the note |
| ↳ `contentLength` | number | Length of the note content |
| ↳ `notebookGuid` | string | GUID of the containing notebook |
| ↳ `tagGuids` | array | GUIDs of tags on the note |
| ↳ `tagNames` | array | Names of tags on the note |
| ↳ `created` | number | Creation timestamp in milliseconds |
| ↳ `updated` | number | Last updated timestamp in milliseconds |
| ↳ `active` | boolean | Whether the note is active \(not in trash\) |
### `evernote_get_notebook`
Retrieve a notebook from Evernote by its GUID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `notebookGuid` | string | Yes | GUID of the notebook to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `notebook` | object | The retrieved notebook |
| ↳ `guid` | string | Notebook GUID |
| ↳ `name` | string | Notebook name |
| ↳ `defaultNotebook` | boolean | Whether this is the default notebook |
| ↳ `serviceCreated` | number | Creation timestamp in milliseconds |
| ↳ `serviceUpdated` | number | Last updated timestamp in milliseconds |
| ↳ `stack` | string | Notebook stack name |
### `evernote_list_notebooks`
List all notebooks in an Evernote account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `notebooks` | array | List of notebooks |
### `evernote_list_tags`
List all tags in an Evernote account
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tags` | array | List of tags |
### `evernote_search_notes`
Search for notes in Evernote using the Evernote search grammar
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `query` | string | Yes | Search query using Evernote search grammar \(e.g., "tag:work intitle:meeting"\) |
| `notebookGuid` | string | No | Restrict search to a specific notebook by GUID |
| `offset` | number | No | Starting index for results \(default: 0\) |
| `maxNotes` | number | No | Maximum number of notes to return \(default: 25\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalNotes` | number | Total number of matching notes |
| `notes` | array | List of matching note metadata |
### `evernote_update_note`
Update an existing note in Evernote
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Evernote developer token |
| `noteGuid` | string | Yes | GUID of the note to update |
| `title` | string | No | New title for the note |
| `content` | string | No | New content for the note \(plain text or ENML\) |
| `notebookGuid` | string | No | GUID of the notebook to move the note to |
| `tagNames` | string | No | Comma-separated list of tag names \(replaces existing tags\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `note` | object | The updated note |
| ↳ `guid` | string | Unique identifier of the note |
| ↳ `title` | string | Title of the note |
| ↳ `content` | string | ENML content of the note |
| ↳ `notebookGuid` | string | GUID of the containing notebook |
| ↳ `tagNames` | array | Tag names on the note |
| ↳ `created` | number | Creation timestamp in milliseconds |
| ↳ `updated` | number | Last updated timestamp in milliseconds |

View File

@@ -0,0 +1,135 @@
---
title: Fathom
description: Access meeting recordings, transcripts, and summaries
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="fathom"
color="#181C1E"
/>
## Usage Instructions
Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.
## Tools
### `fathom_list_meetings`
List recent meetings recorded by the user or shared to their team.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `includeSummary` | string | No | Include meeting summary \(true/false\) |
| `includeTranscript` | string | No | Include meeting transcript \(true/false\) |
| `includeActionItems` | string | No | Include action items \(true/false\) |
| `includeCrmMatches` | string | No | Include linked CRM matches \(true/false\) |
| `createdAfter` | string | No | Filter meetings created after this ISO 8601 timestamp |
| `createdBefore` | string | No | Filter meetings created before this ISO 8601 timestamp |
| `recordedBy` | string | No | Filter by recorder email address |
| `teams` | string | No | Filter by team name |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `meetings` | array | List of meetings |
| ↳ `title` | string | Meeting title |
| ↳ `recording_id` | number | Unique recording ID |
| ↳ `url` | string | URL to view the meeting |
| ↳ `share_url` | string | Shareable URL |
| ↳ `created_at` | string | Creation timestamp |
| ↳ `transcript_language` | string | Transcript language |
| `next_cursor` | string | Pagination cursor for next page |
### `fathom_get_summary`
Get the call summary for a specific meeting recording.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `recordingId` | string | Yes | The recording ID of the meeting |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `template_name` | string | Name of the summary template used |
| `markdown_formatted` | string | Markdown-formatted summary text |
### `fathom_get_transcript`
Get the full transcript for a specific meeting recording.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `recordingId` | string | Yes | The recording ID of the meeting |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `transcript` | array | Array of transcript entries with speaker, text, and timestamp |
| ↳ `speaker` | object | Speaker information |
| ↳ `display_name` | string | Speaker display name |
| ↳ `matched_calendar_invitee_email` | string | Matched calendar invitee email |
| ↳ `text` | string | Transcript text |
| ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) |
### `fathom_list_team_members`
List team members in your Fathom organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `teams` | string | No | Team name to filter by |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `members` | array | List of team members |
| ↳ `name` | string | Team member name |
| ↳ `email` | string | Team member email |
| ↳ `created_at` | string | Date the member was added |
| `next_cursor` | string | Pagination cursor for next page |
### `fathom_list_teams`
List teams in your Fathom organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `teams` | array | List of teams |
| ↳ `name` | string | Team name |
| ↳ `created_at` | string | Date the team was created |
| `next_cursor` | string | Pagination cursor for next page |

View File

@@ -1014,4 +1014,36 @@ Get Jira users. If an account ID is provided, returns a single user. Otherwise,
| `startAt` | number | Pagination start index |
| `maxResults` | number | Maximum results per page |
### `jira_search_users`
Search for Jira users by email address or display name. Returns matching users with their accountId, displayName, and emailAddress.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `query` | string | Yes | A query string to search for users. Can be an email address, display name, or partial match. |
| `maxResults` | number | No | Maximum number of users to return \(default: 50, max: 1000\) |
| `startAt` | number | No | The index of the first user to return \(for pagination, default: 0\) |
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `users` | array | Array of matching Jira users |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
| ↳ `active` | boolean | Whether the user account is active |
| ↳ `emailAddress` | string | Email address of the user |
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `self` | string | REST API URL for this user |
| `total` | number | Number of users returned in this page \(may be less than total matches\) |
| `startAt` | number | Pagination start index |
| `maxResults` | number | Maximum results per page |

View File

@@ -35,7 +35,9 @@
"elasticsearch",
"elevenlabs",
"enrich",
"evernote",
"exa",
"fathom",
"file",
"firecrawl",
"fireflies",
@@ -98,6 +100,7 @@
"mysql",
"neo4j",
"notion",
"obsidian",
"onedrive",
"onepassword",
"openai",

View File

@@ -0,0 +1,323 @@
---
title: Obsidian
description: Interact with your Obsidian vault via the Local REST API
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="obsidian"
color="#0F0F0F"
/>
## Usage Instructions
Read, create, update, search, and delete notes in your Obsidian vault. Manage periodic notes, execute commands, and patch content at specific locations. Requires the Obsidian Local REST API plugin.
## Tools
### `obsidian_append_active`
Append content to the currently active file in Obsidian
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `content` | string | Yes | Markdown content to append to the active file |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `appended` | boolean | Whether content was successfully appended |
### `obsidian_append_note`
Append content to an existing note in your Obsidian vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) |
| `content` | string | Yes | Markdown content to append to the note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `filename` | string | Path of the note |
| `appended` | boolean | Whether content was successfully appended |
### `obsidian_append_periodic_note`
Append content to the current periodic note (daily, weekly, monthly, quarterly, or yearly). Creates the note if it does not exist.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `period` | string | Yes | Period type: daily, weekly, monthly, quarterly, or yearly |
| `content` | string | Yes | Markdown content to append to the periodic note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `period` | string | Period type of the note |
| `appended` | boolean | Whether content was successfully appended |
### `obsidian_create_note`
Create or replace a note in your Obsidian vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `filename` | string | Yes | Path for the note relative to vault root \(e.g. "folder/note.md"\) |
| `content` | string | Yes | Markdown content for the note |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `filename` | string | Path of the created note |
| `created` | boolean | Whether the note was successfully created |
### `obsidian_delete_note`
Delete a note from your Obsidian vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `filename` | string | Yes | Path to the note to delete relative to vault root |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `filename` | string | Path of the deleted note |
| `deleted` | boolean | Whether the note was successfully deleted |
### `obsidian_execute_command`
Execute a command in Obsidian (e.g. open daily note, toggle sidebar)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `commandId` | string | Yes | ID of the command to execute \(use List Commands operation to discover available commands\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `commandId` | string | ID of the executed command |
| `executed` | boolean | Whether the command was successfully executed |
### `obsidian_get_active`
Retrieve the content of the currently active file in Obsidian
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | Markdown content of the active file |
| `filename` | string | Path to the active file |
### `obsidian_get_note`
Retrieve the content of a note from your Obsidian vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | Markdown content of the note |
| `filename` | string | Path to the note |
### `obsidian_get_periodic_note`
Retrieve the current periodic note (daily, weekly, monthly, quarterly, or yearly)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `period` | string | Yes | Period type: daily, weekly, monthly, quarterly, or yearly |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | Markdown content of the periodic note |
| `period` | string | Period type of the note |
### `obsidian_list_commands`
List all available commands in Obsidian
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `commands` | json | List of available commands with IDs and names |
| ↳ `id` | string | Command identifier |
| ↳ `name` | string | Human-readable command name |
### `obsidian_list_files`
List files and directories in your Obsidian vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `path` | string | No | Directory path relative to vault root. Leave empty to list root. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | json | List of files and directories |
| ↳ `path` | string | File or directory path |
| ↳ `type` | string | Whether the entry is a file or directory |
### `obsidian_open_file`
Open a file in the Obsidian UI (creates the file if it does not exist)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `filename` | string | Yes | Path to the file relative to vault root |
| `newLeaf` | boolean | No | Whether to open the file in a new leaf/tab |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `filename` | string | Path of the opened file |
| `opened` | boolean | Whether the file was successfully opened |
### `obsidian_patch_active`
Insert or replace content at a specific heading, block reference, or frontmatter field in the active file
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `content` | string | Yes | Content to insert at the target location |
| `operation` | string | Yes | How to insert content: append, prepend, or replace |
| `targetType` | string | Yes | Type of target: heading, block, or frontmatter |
| `target` | string | Yes | Target identifier \(heading text, block reference ID, or frontmatter field name\) |
| `targetDelimiter` | string | No | Delimiter for nested headings \(default: "::"\) |
| `trimTargetWhitespace` | boolean | No | Whether to trim whitespace from target before matching \(default: false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `patched` | boolean | Whether the active file was successfully patched |
### `obsidian_patch_note`
Insert or replace content at a specific heading, block reference, or frontmatter field in a note
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) |
| `content` | string | Yes | Content to insert at the target location |
| `operation` | string | Yes | How to insert content: append, prepend, or replace |
| `targetType` | string | Yes | Type of target: heading, block, or frontmatter |
| `target` | string | Yes | Target identifier \(heading text, block reference ID, or frontmatter field name\) |
| `targetDelimiter` | string | No | Delimiter for nested headings \(default: "::"\) |
| `trimTargetWhitespace` | boolean | No | Whether to trim whitespace from target before matching \(default: false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `filename` | string | Path of the patched note |
| `patched` | boolean | Whether the note was successfully patched |
### `obsidian_search`
Search for text across notes in your Obsidian vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
| `query` | string | Yes | Text to search for across vault notes |
| `contextLength` | number | No | Number of characters of context around each match \(default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | json | Search results with filenames, scores, and matching contexts |
| ↳ `filename` | string | Path to the matching note |
| ↳ `score` | number | Relevance score |
| ↳ `matches` | json | Matching text contexts |
| ↳ `context` | string | Text surrounding the match |

View File

@@ -44,20 +44,24 @@ Search the web using Parallel AI. Provides comprehensive search results with int
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `objective` | string | Yes | The search objective or question to answer |
| `search_queries` | string | No | Optional comma-separated list of search queries to execute |
| `processor` | string | No | Processing method: base or pro \(default: base\) |
| `max_results` | number | No | Maximum number of results to return \(default: 5\) |
| `max_chars_per_result` | number | No | Maximum characters per result \(default: 1500\) |
| `search_queries` | string | No | Comma-separated list of search queries to execute |
| `mode` | string | No | Search mode: one-shot, agentic, or fast \(default: one-shot\) |
| `max_results` | number | No | Maximum number of results to return \(default: 10\) |
| `max_chars_per_result` | number | No | Maximum characters per result excerpt \(minimum: 1000\) |
| `include_domains` | string | No | Comma-separated list of domains to restrict search results to |
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from search results |
| `apiKey` | string | Yes | Parallel AI API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `search_id` | string | Unique identifier for this search request |
| `results` | array | Search results with excerpts from relevant pages |
| ↳ `url` | string | The URL of the search result |
| ↳ `title` | string | The title of the search result |
| ↳ `excerpts` | array | Text excerpts from the page |
| ↳ `publish_date` | string | Publication date of the page \(YYYY-MM-DD\) |
| ↳ `excerpts` | array | LLM-optimized excerpts from the page |
### `parallel_extract`
@@ -68,31 +72,33 @@ Extract targeted information from specific URLs using Parallel AI. Processes pro
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `urls` | string | Yes | Comma-separated list of URLs to extract information from |
| `objective` | string | Yes | What information to extract from the provided URLs |
| `excerpts` | boolean | Yes | Include relevant excerpts from the content |
| `full_content` | boolean | Yes | Include full page content |
| `objective` | string | No | What information to extract from the provided URLs |
| `excerpts` | boolean | No | Include relevant excerpts from the content \(default: true\) |
| `full_content` | boolean | No | Include full page content as markdown \(default: false\) |
| `apiKey` | string | Yes | Parallel AI API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `extract_id` | string | Unique identifier for this extraction request |
| `results` | array | Extracted information from the provided URLs |
| ↳ `url` | string | The source URL |
| ↳ `title` | string | The title of the page |
| ↳ `content` | string | Extracted content |
| ↳ `excerpts` | array | Relevant text excerpts |
| ↳ `publish_date` | string | Publication date \(YYYY-MM-DD\) |
| ↳ `excerpts` | array | Relevant text excerpts in markdown |
| ↳ `full_content` | string | Full page content as markdown |
### `parallel_deep_research`
Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 15 minutes to complete.
Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 45 minutes to complete.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `input` | string | Yes | Research query or question \(up to 15,000 characters\) |
| `processor` | string | No | Compute level: base, lite, pro, ultra, ultra2x, ultra4x, ultra8x \(default: base\) |
| `processor` | string | No | Processing tier: pro, ultra, pro-fast, ultra-fast \(default: pro\) |
| `include_domains` | string | No | Comma-separated list of domains to restrict research to \(source policy\) |
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from research \(source policy\) |
| `apiKey` | string | Yes | Parallel AI API Key |
@@ -101,17 +107,17 @@ Conduct comprehensive deep research across the web using Parallel AI. Synthesize
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `status` | string | Task status \(completed, failed\) |
| `status` | string | Task status \(completed, failed, running\) |
| `run_id` | string | Unique ID for this research task |
| `message` | string | Status message |
| `content` | object | Research results \(structured based on output_schema\) |
| `basis` | array | Citations and sources with reasoning and confidence levels |
| ↳ `field` | string | Output field name |
| ↳ `field` | string | Output field dot-notation path |
| ↳ `reasoning` | string | Explanation for the result |
| ↳ `citations` | array | Array of sources |
| ↳ `url` | string | Source URL |
| ↳ `title` | string | Source title |
| ↳ `excerpts` | array | Relevant excerpts from the source |
| ↳ `confidence` | string | Confidence level indicator |
| ↳ `confidence` | string | Confidence level \(high, medium\) |

View File

@@ -590,6 +590,7 @@ List all users in a Slack workspace. Returns user profiles with names and avatar
| ↳ `name` | string | Username \(handle\) |
| ↳ `real_name` | string | Full real name |
| ↳ `display_name` | string | Display name shown in Slack |
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
| ↳ `is_bot` | boolean | Whether the user is a bot |
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |
@@ -629,6 +630,7 @@ Get detailed information about a specific Slack user by their user ID.
| ↳ `title` | string | Job title |
| ↳ `phone` | string | Phone number |
| ↳ `skype` | string | Skype handle |
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
| ↳ `is_bot` | boolean | Whether the user is a bot |
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |

View File

@@ -19,7 +19,6 @@ import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import {
@@ -631,11 +630,9 @@ async function handleMessageStream(
}
const encoder = new TextEncoder()
let messageStreamDecremented = false
const stream = new ReadableStream({
async start(controller) {
incrementSSEConnections('a2a-message')
const sendEvent = (event: string, data: unknown) => {
try {
const jsonRpcResponse = {
@@ -845,19 +842,10 @@ async function handleMessageStream(
})
} finally {
await releaseLock(lockKey, lockValue)
if (!messageStreamDecremented) {
messageStreamDecremented = true
decrementSSEConnections('a2a-message')
}
controller.close()
}
},
cancel() {
if (!messageStreamDecremented) {
messageStreamDecremented = true
decrementSSEConnections('a2a-message')
}
},
cancel() {},
})
return new NextResponse(stream, {
@@ -1042,22 +1030,16 @@ async function handleTaskResubscribe(
{ once: true }
)
let sseDecremented = false
const cleanup = () => {
isCancelled = true
if (pollTimeoutId) {
clearTimeout(pollTimeoutId)
pollTimeoutId = null
}
if (!sseDecremented) {
sseDecremented = true
decrementSSEConnections('a2a-resubscribe')
}
}
const stream = new ReadableStream({
async start(controller) {
incrementSSEConnections('a2a-resubscribe')
const sendEvent = (event: string, data: unknown): boolean => {
if (isCancelled || abortSignal.aborted) return false
try {

View File

@@ -14,7 +14,6 @@ import { getSession } from '@/lib/auth'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('McpEventsSSE')
@@ -50,14 +49,11 @@ export async function GET(request: NextRequest) {
for (const unsub of unsubscribers) {
unsub()
}
decrementSSEConnections('mcp-events')
logger.info(`SSE connection closed for workspace ${workspaceId}`)
}
const stream = new ReadableStream({
start(controller) {
incrementSSEConnections('mcp-events')
const send = (eventName: string, data: Record<string, unknown>) => {
if (cleaned) return
try {

View File

@@ -192,7 +192,8 @@ export const POST = withMcpAuth<{ id: string }>('read')(
)
} catch (error) {
connectionStatus = 'error'
lastError = error instanceof Error ? error.message : 'Connection test failed'
lastError =
error instanceof Error ? error.message.split('\n')[0].slice(0, 200) : 'Connection failed'
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
}

View File

@@ -41,6 +41,20 @@ interface TestConnectionResult {
warnings?: string[]
}
/**
* Extracts a user-friendly error message from connection errors.
* Keeps diagnostic info (timeout, DNS, HTTP status) but strips
* verbose internals (Zod details, full response bodies, stack traces).
*/
function sanitizeConnectionError(error: unknown): string {
if (!(error instanceof Error)) {
return 'Unknown connection error'
}
const firstLine = error.message.split('\n')[0]
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}...` : firstLine
}
/**
* POST - Test connection to an MCP server before registering it
*/
@@ -137,8 +151,7 @@ export const POST = withMcpAuth('write')(
} catch (toolError) {
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
result.success = false
const errorMessage = toolError instanceof Error ? toolError.message : 'Unknown error'
result.error = `Connection established but could not list tools: ${errorMessage}`
result.error = 'Connection established but could not list tools'
result.warnings = result.warnings || []
result.warnings.push(
'Server connected but tool listing failed - connection may be incomplete'
@@ -163,11 +176,7 @@ export const POST = withMcpAuth('write')(
logger.warn(`[${requestId}] MCP server test failed:`, error)
result.success = false
if (error instanceof Error) {
result.error = error.message
} else {
result.error = 'Unknown connection error'
}
result.error = sanitizeConnectionError(error)
} finally {
if (client) {
try {

View File

@@ -89,11 +89,12 @@ export const POST = withMcpAuth('read')(
tool = tools.find((t) => t.name === toolName) ?? null
if (!tool) {
logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
availableTools: tools.map((t) => t.name),
})
return createMcpErrorResponse(
new Error(
`Tool ${toolName} not found on server ${serverId}. Available tools: ${tools.map((t) => t.name).join(', ')}`
),
'Tool not found',
new Error('Tool not found'),
'Tool not found on the specified server',
404
)
}

View File

@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to cancel task',
error: 'Failed to cancel task',
},
{ status: 500 }
)

View File

@@ -86,7 +86,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to delete push notification',
error: 'Failed to delete push notification',
},
{ status: 500 }
)

View File

@@ -84,7 +84,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
error: 'Failed to fetch Agent Card',
},
{ status: 500 }
)

View File

@@ -107,7 +107,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get push notification',
error: 'Failed to get push notification',
},
{ status: 500 }
)

View File

@@ -87,7 +87,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get task',
error: 'Failed to get task',
},
{ status: 500 }
)

View File

@@ -111,7 +111,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to resubscribe',
error: 'Failed to resubscribe',
},
{ status: 500 }
)

View File

@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: `Failed to connect to agent: ${clientError instanceof Error ? clientError.message : 'Unknown error'}`,
error: 'Failed to connect to agent',
},
{ status: 502 }
)
@@ -158,7 +158,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: `Failed to send message: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
error: 'Failed to send message to agent',
},
{ status: 502 }
)
@@ -218,7 +218,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
error: 'Internal server error',
},
{ status: 500 }
)

View File

@@ -98,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to set push notification',
error: 'Failed to set push notification',
},
{ status: 500 }
)

View File

@@ -0,0 +1,38 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { copyNote } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteCopyNoteAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, noteGuid, toNotebookGuid } = body
if (!apiKey || !noteGuid || !toNotebookGuid) {
return NextResponse.json(
{ success: false, error: 'apiKey, noteGuid, and toNotebookGuid are required' },
{ status: 400 }
)
}
const note = await copyNote(apiKey, noteGuid, toNotebookGuid)
return NextResponse.json({
success: true,
output: { note },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to copy note', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,51 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNote } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteCreateNoteAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, title, content, notebookGuid, tagNames } = body
if (!apiKey || !title || !content) {
return NextResponse.json(
{ success: false, error: 'apiKey, title, and content are required' },
{ status: 400 }
)
}
const parsedTags = tagNames
? (() => {
const tags =
typeof tagNames === 'string'
? tagNames
.split(',')
.map((t: string) => t.trim())
.filter(Boolean)
: tagNames
return tags.length > 0 ? tags : undefined
})()
: undefined
const note = await createNote(apiKey, title, content, notebookGuid || undefined, parsedTags)
return NextResponse.json({
success: true,
output: { note },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to create note', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,38 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNotebook } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteCreateNotebookAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, name, stack } = body
if (!apiKey || !name) {
return NextResponse.json(
{ success: false, error: 'apiKey and name are required' },
{ status: 400 }
)
}
const notebook = await createNotebook(apiKey, name, stack || undefined)
return NextResponse.json({
success: true,
output: { notebook },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to create notebook', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,38 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createTag } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteCreateTagAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, name, parentGuid } = body
if (!apiKey || !name) {
return NextResponse.json(
{ success: false, error: 'apiKey and name are required' },
{ status: 400 }
)
}
const tag = await createTag(apiKey, name, parentGuid || undefined)
return NextResponse.json({
success: true,
output: { tag },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to create tag', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,41 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { deleteNote } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteDeleteNoteAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, noteGuid } = body
if (!apiKey || !noteGuid) {
return NextResponse.json(
{ success: false, error: 'apiKey and noteGuid are required' },
{ status: 400 }
)
}
await deleteNote(apiKey, noteGuid)
return NextResponse.json({
success: true,
output: {
success: true,
noteGuid,
},
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to delete note', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,38 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { getNote } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteGetNoteAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, noteGuid, withContent = true } = body
if (!apiKey || !noteGuid) {
return NextResponse.json(
{ success: false, error: 'apiKey and noteGuid are required' },
{ status: 400 }
)
}
const note = await getNote(apiKey, noteGuid, withContent)
return NextResponse.json({
success: true,
output: { note },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to get note', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,38 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { getNotebook } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteGetNotebookAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, notebookGuid } = body
if (!apiKey || !notebookGuid) {
return NextResponse.json(
{ success: false, error: 'apiKey and notebookGuid are required' },
{ status: 400 }
)
}
const notebook = await getNotebook(apiKey, notebookGuid)
return NextResponse.json({
success: true,
output: { notebook },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to get notebook', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,799 @@
/**
* Evernote API client using Thrift binary protocol over HTTP.
* Implements only the NoteStore methods needed for the integration.
*/
import {
ThriftReader,
ThriftWriter,
TYPE_BOOL,
TYPE_I32,
TYPE_I64,
TYPE_LIST,
TYPE_STRING,
TYPE_STRUCT,
} from './thrift'
export interface EvernoteNotebook {
guid: string
name: string
defaultNotebook: boolean
serviceCreated: number | null
serviceUpdated: number | null
stack: string | null
}
export interface EvernoteNote {
guid: string
title: string
content: string | null
contentLength: number | null
created: number | null
updated: number | null
deleted: number | null
active: boolean
notebookGuid: string | null
tagGuids: string[]
tagNames: string[]
}
export interface EvernoteNoteMetadata {
guid: string
title: string | null
contentLength: number | null
created: number | null
updated: number | null
notebookGuid: string | null
tagGuids: string[]
}
export interface EvernoteTag {
guid: string
name: string
parentGuid: string | null
updateSequenceNum: number | null
}
export interface EvernoteSearchResult {
startIndex: number
totalNotes: number
notes: EvernoteNoteMetadata[]
}
/** Extract shard ID from an Evernote developer token */
function extractShardId(token: string): string {
const match = token.match(/S=s(\d+)/)
if (!match) {
throw new Error('Invalid Evernote token format: cannot extract shard ID')
}
return `s${match[1]}`
}
/** Get the NoteStore URL for the given token */
function getNoteStoreUrl(token: string): string {
const shardId = extractShardId(token)
const host = token.includes(':Sandbox') ? 'sandbox.evernote.com' : 'www.evernote.com'
return `https://${host}/shard/${shardId}/notestore`
}
/** Make a Thrift RPC call to the NoteStore */
async function callNoteStore(token: string, writer: ThriftWriter): Promise<ThriftReader> {
const url = getNoteStoreUrl(token)
const body = writer.toBuffer()
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-thrift',
Accept: 'application/x-thrift',
},
body: new Uint8Array(body),
})
if (!response.ok) {
throw new Error(`Evernote API HTTP error: ${response.status} ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
const reader = new ThriftReader(arrayBuffer)
const msg = reader.readMessageBegin()
if (reader.isException(msg.type)) {
const ex = reader.readException()
throw new Error(`Evernote API error: ${ex.message}`)
}
return reader
}
/** Check for Evernote-specific exceptions in the response struct. Returns true if handled. */
function checkEvernoteException(reader: ThriftReader, fieldId: number, fieldType: number): boolean {
if (fieldId === 1 && fieldType === TYPE_STRUCT) {
let message = ''
let errorCode = 0
reader.readStruct((r, fid, ftype) => {
if (fid === 1 && ftype === TYPE_I32) {
errorCode = r.readI32()
} else if (fid === 2 && ftype === TYPE_STRING) {
message = r.readString()
} else {
r.skip(ftype)
}
})
throw new Error(`Evernote error (${errorCode}): ${message}`)
}
if (fieldId === 2 && fieldType === TYPE_STRUCT) {
let message = ''
let errorCode = 0
reader.readStruct((r, fid, ftype) => {
if (fid === 1 && ftype === TYPE_I32) {
errorCode = r.readI32()
} else if (fid === 2 && ftype === TYPE_STRING) {
message = r.readString()
} else {
r.skip(ftype)
}
})
throw new Error(`Evernote system error (${errorCode}): ${message}`)
}
if (fieldId === 3 && fieldType === TYPE_STRUCT) {
let identifier = ''
let key = ''
reader.readStruct((r, fid, ftype) => {
if (fid === 1 && ftype === TYPE_STRING) {
identifier = r.readString()
} else if (fid === 2 && ftype === TYPE_STRING) {
key = r.readString()
} else {
r.skip(ftype)
}
})
throw new Error(`Evernote not found: ${identifier}${key ? ` (${key})` : ''}`)
}
return false
}
function readNotebook(reader: ThriftReader): EvernoteNotebook {
const notebook: EvernoteNotebook = {
guid: '',
name: '',
defaultNotebook: false,
serviceCreated: null,
serviceUpdated: null,
stack: null,
}
reader.readStruct((r, fieldId, fieldType) => {
switch (fieldId) {
case 1:
if (fieldType === TYPE_STRING) notebook.guid = r.readString()
else r.skip(fieldType)
break
case 2:
if (fieldType === TYPE_STRING) notebook.name = r.readString()
else r.skip(fieldType)
break
case 4:
if (fieldType === TYPE_BOOL) notebook.defaultNotebook = r.readBool()
else r.skip(fieldType)
break
case 5:
if (fieldType === TYPE_I64) notebook.serviceCreated = Number(r.readI64())
else r.skip(fieldType)
break
case 6:
if (fieldType === TYPE_I64) notebook.serviceUpdated = Number(r.readI64())
else r.skip(fieldType)
break
case 9:
if (fieldType === TYPE_STRING) notebook.stack = r.readString()
else r.skip(fieldType)
break
default:
r.skip(fieldType)
}
})
return notebook
}
function readNote(reader: ThriftReader): EvernoteNote {
const note: EvernoteNote = {
guid: '',
title: '',
content: null,
contentLength: null,
created: null,
updated: null,
deleted: null,
active: true,
notebookGuid: null,
tagGuids: [],
tagNames: [],
}
reader.readStruct((r, fieldId, fieldType) => {
switch (fieldId) {
case 1:
if (fieldType === TYPE_STRING) note.guid = r.readString()
else r.skip(fieldType)
break
case 2:
if (fieldType === TYPE_STRING) note.title = r.readString()
else r.skip(fieldType)
break
case 3:
if (fieldType === TYPE_STRING) note.content = r.readString()
else r.skip(fieldType)
break
case 5:
if (fieldType === TYPE_I32) note.contentLength = r.readI32()
else r.skip(fieldType)
break
case 6:
if (fieldType === TYPE_I64) note.created = Number(r.readI64())
else r.skip(fieldType)
break
case 7:
if (fieldType === TYPE_I64) note.updated = Number(r.readI64())
else r.skip(fieldType)
break
case 8:
if (fieldType === TYPE_I64) note.deleted = Number(r.readI64())
else r.skip(fieldType)
break
case 9:
if (fieldType === TYPE_BOOL) note.active = r.readBool()
else r.skip(fieldType)
break
case 11:
if (fieldType === TYPE_STRING) note.notebookGuid = r.readString()
else r.skip(fieldType)
break
case 12:
if (fieldType === TYPE_LIST) {
const { size } = r.readListBegin()
for (let i = 0; i < size; i++) {
note.tagGuids.push(r.readString())
}
} else {
r.skip(fieldType)
}
break
case 15:
if (fieldType === TYPE_LIST) {
const { size } = r.readListBegin()
for (let i = 0; i < size; i++) {
note.tagNames.push(r.readString())
}
} else {
r.skip(fieldType)
}
break
default:
r.skip(fieldType)
}
})
return note
}
function readTag(reader: ThriftReader): EvernoteTag {
const tag: EvernoteTag = {
guid: '',
name: '',
parentGuid: null,
updateSequenceNum: null,
}
reader.readStruct((r, fieldId, fieldType) => {
switch (fieldId) {
case 1:
if (fieldType === TYPE_STRING) tag.guid = r.readString()
else r.skip(fieldType)
break
case 2:
if (fieldType === TYPE_STRING) tag.name = r.readString()
else r.skip(fieldType)
break
case 3:
if (fieldType === TYPE_STRING) tag.parentGuid = r.readString()
else r.skip(fieldType)
break
case 4:
if (fieldType === TYPE_I32) tag.updateSequenceNum = r.readI32()
else r.skip(fieldType)
break
default:
r.skip(fieldType)
}
})
return tag
}
function readNoteMetadata(reader: ThriftReader): EvernoteNoteMetadata {
const meta: EvernoteNoteMetadata = {
guid: '',
title: null,
contentLength: null,
created: null,
updated: null,
notebookGuid: null,
tagGuids: [],
}
reader.readStruct((r, fieldId, fieldType) => {
switch (fieldId) {
case 1:
if (fieldType === TYPE_STRING) meta.guid = r.readString()
else r.skip(fieldType)
break
case 2:
if (fieldType === TYPE_STRING) meta.title = r.readString()
else r.skip(fieldType)
break
case 5:
if (fieldType === TYPE_I32) meta.contentLength = r.readI32()
else r.skip(fieldType)
break
case 6:
if (fieldType === TYPE_I64) meta.created = Number(r.readI64())
else r.skip(fieldType)
break
case 7:
if (fieldType === TYPE_I64) meta.updated = Number(r.readI64())
else r.skip(fieldType)
break
case 11:
if (fieldType === TYPE_STRING) meta.notebookGuid = r.readString()
else r.skip(fieldType)
break
case 12:
if (fieldType === TYPE_LIST) {
const { size } = r.readListBegin()
for (let i = 0; i < size; i++) {
meta.tagGuids.push(r.readString())
}
} else {
r.skip(fieldType)
}
break
default:
r.skip(fieldType)
}
})
return meta
}
export async function listNotebooks(token: string): Promise<EvernoteNotebook[]> {
const writer = new ThriftWriter()
writer.writeMessageBegin('listNotebooks', 0)
writer.writeStringField(1, token)
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
const notebooks: EvernoteNotebook[] = []
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_LIST) {
const { size } = r.readListBegin()
for (let i = 0; i < size; i++) {
notebooks.push(readNotebook(r))
}
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
return notebooks
}
export async function getNote(
token: string,
guid: string,
withContent = true
): Promise<EvernoteNote> {
const writer = new ThriftWriter()
writer.writeMessageBegin('getNote', 0)
writer.writeStringField(1, token)
writer.writeStringField(2, guid)
writer.writeBoolField(3, withContent)
writer.writeBoolField(4, false)
writer.writeBoolField(5, false)
writer.writeBoolField(6, false)
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let note: EvernoteNote | null = null
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
note = readNote(r)
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
if (!note) {
throw new Error('No note returned from Evernote API')
}
return note
}
/** Wrap content in ENML if it's not already */
function wrapInEnml(content: string): string {
if (content.includes('<!DOCTYPE en-note')) {
return content
}
const escaped = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br/>')
return `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note>${escaped}</en-note>`
}
export async function createNote(
token: string,
title: string,
content: string,
notebookGuid?: string,
tagNames?: string[]
): Promise<EvernoteNote> {
const writer = new ThriftWriter()
writer.writeMessageBegin('createNote', 0)
writer.writeStringField(1, token)
writer.writeFieldBegin(TYPE_STRUCT, 2)
writer.writeStringField(2, title)
writer.writeStringField(3, wrapInEnml(content))
if (notebookGuid) {
writer.writeStringField(11, notebookGuid)
}
if (tagNames && tagNames.length > 0) {
writer.writeStringListField(15, tagNames)
}
writer.writeFieldStop()
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let note: EvernoteNote | null = null
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
note = readNote(r)
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
if (!note) {
throw new Error('No note returned from Evernote API')
}
return note
}
export async function updateNote(
token: string,
guid: string,
title?: string,
content?: string,
notebookGuid?: string,
tagNames?: string[]
): Promise<EvernoteNote> {
const writer = new ThriftWriter()
writer.writeMessageBegin('updateNote', 0)
writer.writeStringField(1, token)
writer.writeFieldBegin(TYPE_STRUCT, 2)
writer.writeStringField(1, guid)
if (title !== undefined) {
writer.writeStringField(2, title)
}
if (content !== undefined) {
writer.writeStringField(3, wrapInEnml(content))
}
if (notebookGuid !== undefined) {
writer.writeStringField(11, notebookGuid)
}
if (tagNames !== undefined) {
writer.writeStringListField(15, tagNames)
}
writer.writeFieldStop()
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let note: EvernoteNote | null = null
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
note = readNote(r)
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
if (!note) {
throw new Error('No note returned from Evernote API')
}
return note
}
export async function deleteNote(token: string, guid: string): Promise<number> {
const writer = new ThriftWriter()
writer.writeMessageBegin('deleteNote', 0)
writer.writeStringField(1, token)
writer.writeStringField(2, guid)
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let usn = 0
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_I32) {
usn = r.readI32()
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
return usn
}
export async function searchNotes(
token: string,
query: string,
notebookGuid?: string,
offset = 0,
maxNotes = 25
): Promise<EvernoteSearchResult> {
const writer = new ThriftWriter()
writer.writeMessageBegin('findNotesMetadata', 0)
writer.writeStringField(1, token)
// NoteFilter (field 2)
writer.writeFieldBegin(TYPE_STRUCT, 2)
if (query) {
writer.writeStringField(3, query)
}
if (notebookGuid) {
writer.writeStringField(4, notebookGuid)
}
writer.writeFieldStop()
// offset (field 3)
writer.writeI32Field(3, offset)
// maxNotes (field 4)
writer.writeI32Field(4, maxNotes)
// NotesMetadataResultSpec (field 5)
writer.writeFieldBegin(TYPE_STRUCT, 5)
writer.writeBoolField(2, true) // includeTitle
writer.writeBoolField(5, true) // includeContentLength
writer.writeBoolField(6, true) // includeCreated
writer.writeBoolField(7, true) // includeUpdated
writer.writeBoolField(11, true) // includeNotebookGuid
writer.writeBoolField(12, true) // includeTagGuids
writer.writeFieldStop()
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
const result: EvernoteSearchResult = {
startIndex: 0,
totalNotes: 0,
notes: [],
}
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
r.readStruct((r2, fid2, ftype2) => {
switch (fid2) {
case 1:
if (ftype2 === TYPE_I32) result.startIndex = r2.readI32()
else r2.skip(ftype2)
break
case 2:
if (ftype2 === TYPE_I32) result.totalNotes = r2.readI32()
else r2.skip(ftype2)
break
case 3:
if (ftype2 === TYPE_LIST) {
const { size } = r2.readListBegin()
for (let i = 0; i < size; i++) {
result.notes.push(readNoteMetadata(r2))
}
} else {
r2.skip(ftype2)
}
break
default:
r2.skip(ftype2)
}
})
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
return result
}
export async function getNotebook(token: string, guid: string): Promise<EvernoteNotebook> {
const writer = new ThriftWriter()
writer.writeMessageBegin('getNotebook', 0)
writer.writeStringField(1, token)
writer.writeStringField(2, guid)
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let notebook: EvernoteNotebook | null = null
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
notebook = readNotebook(r)
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
if (!notebook) {
throw new Error('No notebook returned from Evernote API')
}
return notebook
}
export async function createNotebook(
token: string,
name: string,
stack?: string
): Promise<EvernoteNotebook> {
const writer = new ThriftWriter()
writer.writeMessageBegin('createNotebook', 0)
writer.writeStringField(1, token)
writer.writeFieldBegin(TYPE_STRUCT, 2)
writer.writeStringField(2, name)
if (stack) {
writer.writeStringField(9, stack)
}
writer.writeFieldStop()
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let notebook: EvernoteNotebook | null = null
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
notebook = readNotebook(r)
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
if (!notebook) {
throw new Error('No notebook returned from Evernote API')
}
return notebook
}
export async function listTags(token: string): Promise<EvernoteTag[]> {
const writer = new ThriftWriter()
writer.writeMessageBegin('listTags', 0)
writer.writeStringField(1, token)
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
const tags: EvernoteTag[] = []
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_LIST) {
const { size } = r.readListBegin()
for (let i = 0; i < size; i++) {
tags.push(readTag(r))
}
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
return tags
}
export async function createTag(
token: string,
name: string,
parentGuid?: string
): Promise<EvernoteTag> {
const writer = new ThriftWriter()
writer.writeMessageBegin('createTag', 0)
writer.writeStringField(1, token)
writer.writeFieldBegin(TYPE_STRUCT, 2)
writer.writeStringField(2, name)
if (parentGuid) {
writer.writeStringField(3, parentGuid)
}
writer.writeFieldStop()
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let tag: EvernoteTag | null = null
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
tag = readTag(r)
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
if (!tag) {
throw new Error('No tag returned from Evernote API')
}
return tag
}
export async function copyNote(
token: string,
noteGuid: string,
toNotebookGuid: string
): Promise<EvernoteNote> {
const writer = new ThriftWriter()
writer.writeMessageBegin('copyNote', 0)
writer.writeStringField(1, token)
writer.writeStringField(2, noteGuid)
writer.writeStringField(3, toNotebookGuid)
writer.writeFieldStop()
const reader = await callNoteStore(token, writer)
let note: EvernoteNote | null = null
reader.readStruct((r, fieldId, fieldType) => {
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
note = readNote(r)
} else {
if (!checkEvernoteException(r, fieldId, fieldType)) {
r.skip(fieldType)
}
}
})
if (!note) {
throw new Error('No note returned from Evernote API')
}
return note
}

View File

@@ -0,0 +1,255 @@
/**
* Minimal Thrift binary protocol encoder/decoder for Evernote API.
* Supports only the types needed for NoteStore operations.
*/
const THRIFT_VERSION_1 = 0x80010000
const MESSAGE_CALL = 1
const MESSAGE_EXCEPTION = 3
const TYPE_STOP = 0
const TYPE_BOOL = 2
const TYPE_I32 = 8
const TYPE_I64 = 10
const TYPE_STRING = 11
const TYPE_STRUCT = 12
const TYPE_LIST = 15
export class ThriftWriter {
private buffer: number[] = []
writeMessageBegin(name: string, seqId: number): void {
this.writeI32(THRIFT_VERSION_1 | MESSAGE_CALL)
this.writeString(name)
this.writeI32(seqId)
}
writeFieldBegin(type: number, id: number): void {
this.buffer.push(type)
this.writeI16(id)
}
writeFieldStop(): void {
this.buffer.push(TYPE_STOP)
}
writeString(value: string): void {
const encoded = new TextEncoder().encode(value)
this.writeI32(encoded.length)
for (const byte of encoded) {
this.buffer.push(byte)
}
}
writeBool(value: boolean): void {
this.buffer.push(value ? 1 : 0)
}
writeI16(value: number): void {
this.buffer.push((value >> 8) & 0xff)
this.buffer.push(value & 0xff)
}
writeI32(value: number): void {
this.buffer.push((value >> 24) & 0xff)
this.buffer.push((value >> 16) & 0xff)
this.buffer.push((value >> 8) & 0xff)
this.buffer.push(value & 0xff)
}
writeI64(value: bigint): void {
const buf = new ArrayBuffer(8)
const view = new DataView(buf)
view.setBigInt64(0, value, false)
for (let i = 0; i < 8; i++) {
this.buffer.push(view.getUint8(i))
}
}
writeStringField(id: number, value: string): void {
this.writeFieldBegin(TYPE_STRING, id)
this.writeString(value)
}
writeBoolField(id: number, value: boolean): void {
this.writeFieldBegin(TYPE_BOOL, id)
this.writeBool(value)
}
writeI32Field(id: number, value: number): void {
this.writeFieldBegin(TYPE_I32, id)
this.writeI32(value)
}
writeStringListField(id: number, values: string[]): void {
this.writeFieldBegin(TYPE_LIST, id)
this.buffer.push(TYPE_STRING)
this.writeI32(values.length)
for (const v of values) {
this.writeString(v)
}
}
toBuffer(): Buffer {
return Buffer.from(this.buffer)
}
}
export class ThriftReader {
private view: DataView
private pos = 0
constructor(buffer: ArrayBuffer) {
this.view = new DataView(buffer)
}
readMessageBegin(): { name: string; type: number; seqId: number } {
const versionAndType = this.readI32()
const version = versionAndType & 0xffff0000
if (version !== (THRIFT_VERSION_1 | 0)) {
throw new Error(`Unsupported Thrift version: 0x${version.toString(16)}`)
}
const type = versionAndType & 0x000000ff
const name = this.readString()
const seqId = this.readI32()
return { name, type, seqId }
}
readFieldBegin(): { type: number; id: number } {
const type = this.view.getUint8(this.pos++)
if (type === TYPE_STOP) {
return { type: TYPE_STOP, id: 0 }
}
const id = this.view.getInt16(this.pos, false)
this.pos += 2
return { type, id }
}
readString(): string {
const length = this.readI32()
const bytes = new Uint8Array(this.view.buffer, this.pos, length)
this.pos += length
return new TextDecoder().decode(bytes)
}
readBool(): boolean {
return this.view.getUint8(this.pos++) !== 0
}
readI32(): number {
const value = this.view.getInt32(this.pos, false)
this.pos += 4
return value
}
readI64(): bigint {
const value = this.view.getBigInt64(this.pos, false)
this.pos += 8
return value
}
readBinary(): Uint8Array {
const length = this.readI32()
const bytes = new Uint8Array(this.view.buffer, this.pos, length)
this.pos += length
return bytes
}
readListBegin(): { elementType: number; size: number } {
const elementType = this.view.getUint8(this.pos++)
const size = this.readI32()
return { elementType, size }
}
/** Skip a value of the given Thrift type */
skip(type: number): void {
switch (type) {
case TYPE_BOOL:
this.pos += 1
break
case 6: // I16
this.pos += 2
break
case 3: // BYTE
this.pos += 1
break
case TYPE_I32:
this.pos += 4
break
case TYPE_I64:
case 4: // DOUBLE
this.pos += 8
break
case TYPE_STRING: {
const len = this.readI32()
this.pos += len
break
}
case TYPE_STRUCT:
this.skipStruct()
break
case TYPE_LIST:
case 14: {
// SET
const { elementType, size } = this.readListBegin()
for (let i = 0; i < size; i++) {
this.skip(elementType)
}
break
}
case 13: {
// MAP
const keyType = this.view.getUint8(this.pos++)
const valueType = this.view.getUint8(this.pos++)
const count = this.readI32()
for (let i = 0; i < count; i++) {
this.skip(keyType)
this.skip(valueType)
}
break
}
default:
throw new Error(`Cannot skip unknown Thrift type: ${type}`)
}
}
private skipStruct(): void {
for (;;) {
const { type } = this.readFieldBegin()
if (type === TYPE_STOP) break
this.skip(type)
}
}
/** Read struct fields, calling the handler for each field */
readStruct<T>(handler: (reader: ThriftReader, fieldId: number, fieldType: number) => void): void {
for (;;) {
const { type, id } = this.readFieldBegin()
if (type === TYPE_STOP) break
handler(this, id, type)
}
}
/** Check if this is an exception response */
isException(messageType: number): boolean {
return messageType === MESSAGE_EXCEPTION
}
/** Read a Thrift application exception */
readException(): { message: string; type: number } {
let message = ''
let type = 0
this.readStruct((reader, fieldId, fieldType) => {
if (fieldId === 1 && fieldType === TYPE_STRING) {
message = reader.readString()
} else if (fieldId === 2 && fieldType === TYPE_I32) {
type = reader.readI32()
} else {
reader.skip(fieldType)
}
})
return { message, type }
}
}
export { TYPE_BOOL, TYPE_I32, TYPE_I64, TYPE_LIST, TYPE_STOP, TYPE_STRING, TYPE_STRUCT }

View File

@@ -0,0 +1,35 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { listNotebooks } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteListNotebooksAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey } = body
if (!apiKey) {
return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 })
}
const notebooks = await listNotebooks(apiKey)
return NextResponse.json({
success: true,
output: { notebooks },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to list notebooks', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,35 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { listTags } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteListTagsAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey } = body
if (!apiKey) {
return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 })
}
const tags = await listTags(apiKey)
return NextResponse.json({
success: true,
output: { tags },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to list tags', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,49 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { searchNotes } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteSearchNotesAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, query, notebookGuid, offset = 0, maxNotes = 25 } = body
if (!apiKey || !query) {
return NextResponse.json(
{ success: false, error: 'apiKey and query are required' },
{ status: 400 }
)
}
const clampedMaxNotes = Math.min(Math.max(Number(maxNotes) || 25, 1), 250)
const result = await searchNotes(
apiKey,
query,
notebookGuid || undefined,
Number(offset),
clampedMaxNotes
)
return NextResponse.json({
success: true,
output: {
totalNotes: result.totalNotes,
notes: result.notes,
},
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to search notes', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -0,0 +1,58 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { updateNote } from '@/app/api/tools/evernote/lib/client'
export const dynamic = 'force-dynamic'
const logger = createLogger('EvernoteUpdateNoteAPI')
export async function POST(request: NextRequest) {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { apiKey, noteGuid, title, content, notebookGuid, tagNames } = body
if (!apiKey || !noteGuid) {
return NextResponse.json(
{ success: false, error: 'apiKey and noteGuid are required' },
{ status: 400 }
)
}
const parsedTags = tagNames
? (() => {
const tags =
typeof tagNames === 'string'
? tagNames
.split(',')
.map((t: string) => t.trim())
.filter(Boolean)
: tagNames
return tags.length > 0 ? tags : undefined
})()
: undefined
const note = await updateNote(
apiKey,
noteGuid,
title || undefined,
content || undefined,
notebookGuid || undefined,
parsedTags
)
return NextResponse.json({
success: true,
output: { note },
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to update note', { error: message })
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}

View File

@@ -1,7 +1,13 @@
import { MongoClient } from 'mongodb'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types'
export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const credentials =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`

View File

@@ -1,4 +1,5 @@
import mysql from 'mysql2/promise'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
export interface MySQLConnectionConfig {
host: string
@@ -10,6 +11,11 @@ export interface MySQLConnectionConfig {
}
export async function createMySQLConnection(config: MySQLConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const connectionConfig: mysql.ConnectionOptions = {
host: config.host,
port: config.port,

View File

@@ -1,7 +1,13 @@
import neo4j from 'neo4j-driver'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import type { Neo4jConnectionConfig } from '@/tools/neo4j/types'
export async function createNeo4jDriver(config: Neo4jConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const isAuraHost =
config.host === 'databases.neo4j.io' || config.host.endsWith('.databases.neo4j.io')

View File

@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
const sql = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
)
}
const sql = createPostgresConnection({
const sql = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
const sql = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Introspecting PostgreSQL schema on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
const sql = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
const sql = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = createPostgresConnection({
const sql = await createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -1,7 +1,13 @@
import postgres from 'postgres'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
export function createPostgresConnection(config: PostgresConnectionConfig) {
export async function createPostgresConnection(config: PostgresConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const sslConfig =
config.ssl === 'disabled'
? false

View File

@@ -3,6 +3,7 @@ import Redis from 'ioredis'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('RedisAPI')
@@ -24,6 +25,16 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const { url, command, args } = RequestSchema.parse(body)
const parsedUrl = new URL(url)
const hostname =
parsedUrl.hostname.startsWith('[') && parsedUrl.hostname.endsWith(']')
? parsedUrl.hostname.slice(1, -1)
: parsedUrl.hostname
const hostValidation = await validateDatabaseHost(hostname, 'host')
if (!hostValidation.isValid) {
return NextResponse.json({ error: hostValidation.error }, { status: 400 })
}
client = new Redis(url, {
connectTimeout: 10000,
commandTimeout: 10000,

View File

@@ -10,7 +10,6 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
import { enrichTableSchema } from '@/lib/table/llm/wand'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils'
@@ -331,14 +330,10 @@ export async function POST(req: NextRequest) {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let wandStreamClosed = false
const readable = new ReadableStream({
async start(controller) {
incrementSSEConnections('wand')
const reader = response.body?.getReader()
if (!reader) {
wandStreamClosed = true
decrementSSEConnections('wand')
controller.close()
return
}
@@ -483,18 +478,9 @@ export async function POST(req: NextRequest) {
controller.close()
} finally {
reader.releaseLock()
if (!wandStreamClosed) {
wandStreamClosed = true
decrementSSEConnections('wand')
}
}
},
cancel() {
if (!wandStreamClosed) {
wandStreamClosed = true
decrementSSEConnections('wand')
}
},
cancel() {},
})
return new Response(readable, {

View File

@@ -367,9 +367,7 @@ export async function POST(request: NextRequest) {
)
}
// Configure each new webhook (for providers that need configuration)
const pollingProviders = ['gmail', 'outlook']
const needsConfiguration = pollingProviders.includes(provider)
const needsConfiguration = provider === 'gmail' || provider === 'outlook'
if (needsConfiguration) {
const configureFunc =

View File

@@ -324,7 +324,9 @@ vi.mock('@/lib/webhooks/processor', () => ({
return null
}
),
checkWebhookPreprocessing: vi.fn().mockResolvedValue(null),
checkWebhookPreprocessing: vi
.fn()
.mockResolvedValue({ error: null, actorUserId: 'test-user-id' }),
formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => {
const { NextResponse } = require('next/server')
return NextResponse.json({ error }, { status })

View File

@@ -4,7 +4,6 @@ import { generateRequestId } from '@/lib/core/utils/request'
import {
checkWebhookPreprocessing,
findAllWebhooksForPath,
formatProviderErrorResponse,
handlePreDeploymentVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
@@ -82,7 +81,6 @@ export async function POST(
requestId
)
if (authError) {
// For multi-webhook, log and continue to next webhook
if (webhooksForPath.length > 1) {
logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
continue
@@ -92,39 +90,18 @@ export async function POST(
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
// Reachability test should return immediately for the first webhook
return reachabilityResponse
}
let preprocessError: NextResponse | null = null
try {
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessError) {
if (webhooksForPath.length > 1) {
logger.warn(
`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
)
continue
}
return preprocessError
}
} catch (error) {
logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
})
const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessResult.error) {
if (webhooksForPath.length > 1) {
logger.warn(
`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
)
continue
}
return formatProviderErrorResponse(
foundWebhook,
'An unexpected error occurred during preprocessing',
500
)
return preprocessResult.error
}
if (foundWebhook.blockId) {
@@ -152,6 +129,7 @@ export async function POST(
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
})
responses.push(response)
}

View File

@@ -22,7 +22,6 @@ import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/ev
import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
@@ -764,7 +763,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const encoder = new TextEncoder()
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
let isStreamClosed = false
let sseDecremented = false
const eventWriter = createExecutionEventWriter(executionId)
setExecutionMeta(executionId, {
@@ -775,7 +773,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
incrementSSEConnections('workflow-execute')
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
const sendEvent = (event: ExecutionEvent) => {
@@ -1159,10 +1156,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
if (executionId) {
await cleanupExecutionBase64Cache(executionId)
}
if (!sseDecremented) {
sseDecremented = true
decrementSSEConnections('workflow-execute')
}
if (!isStreamClosed) {
try {
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
@@ -1174,10 +1167,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
cancel() {
isStreamClosed = true
logger.info(`[${requestId}] Client disconnected from SSE stream`)
if (!sseDecremented) {
sseDecremented = true
decrementSSEConnections('workflow-execute')
}
},
})

View File

@@ -7,7 +7,6 @@ import {
getExecutionMeta,
readExecutionEvents,
} from '@/lib/execution/event-buffer'
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
import { formatSSEEvent } from '@/lib/workflows/executor/execution-events'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -74,10 +73,8 @@ export async function GET(
let closed = false
let sseDecremented = false
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
incrementSSEConnections('execution-stream-reconnect')
let lastEventId = fromEventId
const pollDeadline = Date.now() + MAX_POLL_DURATION_MS
@@ -145,20 +142,11 @@ export async function GET(
controller.close()
} catch {}
}
} finally {
if (!sseDecremented) {
sseDecremented = true
decrementSSEConnections('execution-stream-reconnect')
}
}
},
cancel() {
closed = true
logger.info('Client disconnected from reconnection stream', { executionId })
if (!sseDecremented) {
sseDecremented = true
decrementSSEConnections('execution-stream-reconnect')
}
},
})

View File

@@ -12,6 +12,7 @@ import {
} from '@/components/emails'
import { getSession } from '@/lib/auth'
import { decryptSecret } from '@/lib/core/security/encryption'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -135,18 +136,18 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
headers['sim-signature'] = `t=${timestamp},v1=${signature}`
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
try {
const response = await fetch(webhookConfig.url, {
method: 'POST',
headers,
body,
signal: controller.signal,
})
clearTimeout(timeoutId)
const response = await secureFetchWithValidation(
webhookConfig.url,
{
method: 'POST',
headers,
body,
timeout: 10000,
allowHttp: true,
},
'webhookUrl'
)
const responseBody = await response.text().catch(() => '')
return {
@@ -157,12 +158,10 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
timestamp: new Date().toISOString(),
}
} catch (error: unknown) {
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
if (err.name === 'AbortError') {
return { success: false, error: 'Request timeout after 10 seconds' }
}
return { success: false, error: err.message }
logger.warn('Webhook test failed', {
error: error instanceof Error ? error.message : String(error),
})
return { success: false, error: 'Failed to deliver webhook' }
}
}
@@ -268,13 +267,15 @@ async function testSlack(
return {
success: result.ok,
error: result.error,
error: result.ok ? undefined : `Slack error: ${result.error || 'unknown'}`,
channel: result.channel,
timestamp: new Date().toISOString(),
}
} catch (error: unknown) {
const err = error as Error
return { success: false, error: err.message }
logger.warn('Slack test notification failed', {
error: error instanceof Error ? error.message : String(error),
})
return { success: false, error: 'Failed to send Slack notification' }
}
}

View File

@@ -2,6 +2,7 @@
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type { SubBlockConfig } from '@/blocks/types'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
@@ -14,8 +15,7 @@ import { useDependsOnGate } from './use-depends-on-gate'
*
* Builds a `SelectorContext` by mapping each `dependsOn` entry through the
* canonical index to its `canonicalParamId`, which maps directly to
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `collectionId`).
* The one special case is `oauthCredential` which maps to `credentialId`.
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `oauthCredential`).
*
* @param blockId - The block containing the selector sub-block
* @param subBlock - The sub-block config (must have `selectorKey` set)
@@ -70,11 +70,8 @@ export function useSelectorSetup(
if (isReference(strValue)) continue
const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey
if (canonicalParamId === 'oauthCredential') {
context.credentialId = strValue
} else if (canonicalParamId in CONTEXT_FIELD_SET) {
;(context as Record<string, unknown>)[canonicalParamId] = strValue
if (SELECTOR_CONTEXT_FIELDS.has(canonicalParamId as keyof SelectorContext)) {
context[canonicalParamId as keyof SelectorContext] = strValue
}
}
@@ -89,19 +86,3 @@ export function useSelectorSetup(
dependencyValues: resolvedDependencyValues,
}
}
const CONTEXT_FIELD_SET: Record<string, true> = {
credentialId: true,
domain: true,
teamId: true,
projectId: true,
knowledgeBaseId: true,
planId: true,
siteId: true,
collectionId: true,
spreadsheetId: true,
fileId: true,
baseId: true,
datasetId: true,
serviceDeskId: true,
}

View File

@@ -57,9 +57,9 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management'
const SLACK_OVERRIDES: SelectorOverrides = {
transformContext: (context, deps) => {
const authMethod = deps.authMethod as string
const credentialId =
const oauthCredential =
authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '')
return { ...context, credentialId }
return { ...context, oauthCredential }
},
}

View File

@@ -578,7 +578,7 @@ const SubBlockRow = memo(function SubBlockRow({
subBlock,
value: rawValue,
workflowId,
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
oauthCredential: typeof credentialId === 'string' ? credentialId : undefined,
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
domain: domainValue,
teamId: teamIdValue,

View File

@@ -12,7 +12,7 @@ interface UseShiftSelectionLockResult {
/** Computed ReactFlow props based on current selection state */
selectionProps: {
selectionOnDrag: boolean
panOnDrag: [number, number] | false
panOnDrag: number[]
selectionKeyCode: string | null
}
}
@@ -55,7 +55,7 @@ export function useShiftSelectionLock({
const selectionProps = {
selectionOnDrag: !isHandMode || isShiftSelecting,
panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false,
panOnDrag: isHandMode && !isShiftSelecting ? [0, 1] : [1],
selectionKeyCode: isShiftSelecting ? null : 'Shift',
}

View File

@@ -1,18 +1,13 @@
import { db } from '@sim/db'
import { webhook, workflow as workflowTable } from '@sim/db/schema'
import { account, webhook } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { eq } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { getHighestPrioritySubscription } from '@/lib/billing'
import {
createTimeoutAbortController,
getExecutionTimeout,
getTimeoutErrorMessage,
} from '@/lib/core/execution-limits'
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { processExecutionFiles } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor'
@@ -20,7 +15,7 @@ import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webho
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { getWorkflowById } from '@/lib/workflows/utils'
import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getBlock } from '@/blocks'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
@@ -109,8 +104,8 @@ export type WebhookExecutionPayload = {
headers: Record<string, string>
path: string
blockId?: string
workspaceId?: string
credentialId?: string
credentialAccountUserId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
@@ -143,6 +138,22 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
)
}
/**
* Resolve the account userId for a credential
*/
async function resolveCredentialAccountUserId(credentialId: string): Promise<string | undefined> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return undefined
}
const [credentialRecord] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
return credentialRecord?.userId
}
async function executeWebhookJobInternal(
payload: WebhookExecutionPayload,
executionId: string,
@@ -155,17 +166,56 @@ async function executeWebhookJobInternal(
requestId
)
const userSubscription = await getHighestPrioritySubscription(payload.userId)
const asyncTimeout = getExecutionTimeout(
userSubscription?.plan as SubscriptionPlan | undefined,
'async'
)
// Resolve workflow record, billing actor, subscription, and timeout
const preprocessResult = await preprocessExecution({
workflowId: payload.workflowId,
userId: payload.userId,
triggerType: 'webhook',
executionId,
requestId,
checkRateLimit: false,
checkDeployment: false,
skipUsageLimits: true,
workspaceId: payload.workspaceId,
loggingSession,
})
if (!preprocessResult.success) {
throw new Error(preprocessResult.error?.message || 'Preprocessing failed in background job')
}
const { workflowRecord, executionTimeout } = preprocessResult
if (!workflowRecord) {
throw new Error(`Workflow ${payload.workflowId} not found during preprocessing`)
}
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
const workflowVariables = (workflowRecord.variables as Record<string, any>) || {}
const asyncTimeout = executionTimeout?.async ?? 120_000
const timeoutController = createTimeoutAbortController(asyncTimeout)
let deploymentVersionId: string | undefined
try {
const workflowData = await loadDeployedWorkflowState(payload.workflowId)
// Parallelize workflow state, webhook record, and credential resolution
const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([
loadDeployedWorkflowState(payload.workflowId, workspaceId),
db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1),
payload.credentialId
? resolveCredentialAccountUserId(payload.credentialId)
: Promise.resolve(undefined),
])
const credentialAccountUserId = resolvedCredentialUserId
if (payload.credentialId && !credentialAccountUserId) {
logger.warn(
`[${requestId}] Failed to resolve credential account for credential ${payload.credentialId}`
)
}
if (!workflowData) {
throw new Error(
'Workflow state not found. The workflow may not be deployed or the deployment data may be corrupted.'
@@ -178,28 +228,11 @@ async function executeWebhookJobInternal(
? (workflowData.deploymentVersionId as string)
: undefined
const wfRows = await db
.select({ workspaceId: workflowTable.workspaceId, variables: workflowTable.variables })
.from(workflowTable)
.where(eq(workflowTable.id, payload.workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
// Handle special Airtable case
if (payload.provider === 'airtable') {
logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`)
// Load the actual webhook record from database to get providerConfig
const [webhookRecord] = await db
.select()
.from(webhook)
.where(eq(webhook.id, payload.webhookId))
.limit(1)
const webhookRecord = webhookRows[0]
if (!webhookRecord) {
throw new Error(`Webhook record not found: ${payload.webhookId}`)
}
@@ -210,29 +243,20 @@ async function executeWebhookJobInternal(
providerConfig: webhookRecord.providerConfig,
}
// Create a mock workflow object for Airtable processing
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
}
// Get the processed Airtable input
const airtableInput = await fetchAndProcessAirtablePayloads(
webhookData,
mockWorkflow,
requestId
)
// If we got input (changes), execute the workflow like other providers
if (airtableInput) {
logger.info(`[${requestId}] Executing workflow with Airtable changes`)
// Get workflow for core execution
const workflow = await getWorkflowById(payload.workflowId)
if (!workflow) {
throw new Error(`Workflow ${payload.workflowId} not found`)
}
const metadata: ExecutionMetadata = {
requestId,
executionId,
@@ -240,13 +264,13 @@ async function executeWebhookJobInternal(
workspaceId,
userId: payload.userId,
sessionUserId: undefined,
workflowUserId: workflow.userId,
workflowUserId: workflowRecord.userId,
triggerType: payload.provider || 'webhook',
triggerBlockId: payload.blockId,
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
credentialAccountUserId: payload.credentialAccountUserId,
credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -258,7 +282,7 @@ async function executeWebhookJobInternal(
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
workflowRecord,
airtableInput,
workflowVariables,
[]
@@ -329,7 +353,6 @@ async function executeWebhookJobInternal(
// No changes to process
logger.info(`[${requestId}] No Airtable changes to process`)
// Start logging session so the complete call has a log entry to update
await loggingSession.safeStart({
userId: payload.userId,
workspaceId,
@@ -357,13 +380,6 @@ async function executeWebhookJobInternal(
}
// Format input for standard webhooks
// Load the actual webhook to get providerConfig (needed for Teams credentialId)
const webhookRows = await db
.select()
.from(webhook)
.where(eq(webhook.id, payload.webhookId))
.limit(1)
const actualWebhook =
webhookRows.length > 0
? webhookRows[0]
@@ -386,7 +402,6 @@ async function executeWebhookJobInternal(
if (!input && payload.provider === 'whatsapp') {
logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`)
// Start logging session so the complete call has a log entry to update
await loggingSession.safeStart({
userId: payload.userId,
workspaceId,
@@ -452,7 +467,6 @@ async function executeWebhookJobInternal(
}
} catch (error) {
logger.error(`[${requestId}] Error processing trigger file outputs:`, error)
// Continue without processing attachments rather than failing execution
}
}
@@ -499,18 +513,11 @@ async function executeWebhookJobInternal(
}
} catch (error) {
logger.error(`[${requestId}] Error processing generic webhook files:`, error)
// Continue without processing files rather than failing execution
}
}
logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`)
// Get workflow for core execution
const workflow = await getWorkflowById(payload.workflowId)
if (!workflow) {
throw new Error(`Workflow ${payload.workflowId} not found`)
}
const metadata: ExecutionMetadata = {
requestId,
executionId,
@@ -518,13 +525,13 @@ async function executeWebhookJobInternal(
workspaceId,
userId: payload.userId,
sessionUserId: undefined,
workflowUserId: workflow.userId,
workflowUserId: workflowRecord.userId,
triggerType: payload.provider || 'webhook',
triggerBlockId: payload.blockId,
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
credentialAccountUserId: payload.credentialAccountUserId,
credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -536,7 +543,13 @@ async function executeWebhookJobInternal(
const triggerInput = input || {}
const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, [])
const snapshot = new ExecutionSnapshot(
metadata,
workflowRecord,
triggerInput,
workflowVariables,
[]
)
const executionResult = await executeWorkflowCore({
snapshot,
@@ -611,23 +624,9 @@ async function executeWebhookJobInternal(
})
try {
const wfRow = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, payload.workflowId))
.limit(1)
const errorWorkspaceId = wfRow[0]?.workspaceId
if (!errorWorkspaceId) {
logger.warn(
`[${requestId}] Cannot log error: workflow ${payload.workflowId} has no workspace`
)
throw error
}
await loggingSession.safeStart({
userId: payload.userId,
workspaceId: errorWorkspaceId,
workspaceId,
variables: {},
triggerData: {
isTest: false,

View File

@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
@@ -207,18 +208,18 @@ async function deliverWebhook(
headers['sim-signature'] = `t=${payload.timestamp},v1=${signature}`
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
try {
const response = await fetch(webhookConfig.url, {
method: 'POST',
headers,
body,
signal: controller.signal,
})
clearTimeout(timeoutId)
const response = await secureFetchWithValidation(
webhookConfig.url,
{
method: 'POST',
headers,
body,
timeout: 30000,
allowHttp: true,
},
'webhookUrl'
)
return {
success: response.ok,
@@ -226,11 +227,13 @@ async function deliverWebhook(
error: response.ok ? undefined : `HTTP ${response.status}`,
}
} catch (error: unknown) {
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
logger.warn('Webhook delivery failed', {
error: error instanceof Error ? error.message : String(error),
webhookUrl: webhookConfig.url,
})
return {
success: false,
error: err.name === 'AbortError' ? 'Request timeout' : err.message,
error: 'Failed to deliver webhook',
}
}
}

View File

@@ -0,0 +1,308 @@
import { EvernoteIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const EvernoteBlock: BlockConfig = {
type: 'evernote',
name: 'Evernote',
description: 'Manage notes, notebooks, and tags in Evernote',
longDescription:
'Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags.',
docsLink: 'https://docs.sim.ai/tools/evernote',
category: 'tools',
bgColor: '#E0E0E0',
icon: EvernoteIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Create Note', id: 'create_note' },
{ label: 'Get Note', id: 'get_note' },
{ label: 'Update Note', id: 'update_note' },
{ label: 'Delete Note', id: 'delete_note' },
{ label: 'Copy Note', id: 'copy_note' },
{ label: 'Search Notes', id: 'search_notes' },
{ label: 'Get Notebook', id: 'get_notebook' },
{ label: 'Create Notebook', id: 'create_notebook' },
{ label: 'List Notebooks', id: 'list_notebooks' },
{ label: 'Create Tag', id: 'create_tag' },
{ label: 'List Tags', id: 'list_tags' },
],
value: () => 'create_note',
},
{
id: 'apiKey',
title: 'Developer Token',
type: 'short-input',
password: true,
placeholder: 'Enter your Evernote developer token',
required: true,
},
{
id: 'title',
title: 'Title',
type: 'short-input',
placeholder: 'Note title',
condition: { field: 'operation', value: 'create_note' },
required: { field: 'operation', value: 'create_note' },
},
{
id: 'content',
title: 'Content',
type: 'long-input',
placeholder: 'Note content (plain text or ENML)',
condition: { field: 'operation', value: 'create_note' },
required: { field: 'operation', value: 'create_note' },
},
{
id: 'noteGuid',
title: 'Note GUID',
type: 'short-input',
placeholder: 'Enter the note GUID',
condition: {
field: 'operation',
value: ['get_note', 'update_note', 'delete_note', 'copy_note'],
},
required: {
field: 'operation',
value: ['get_note', 'update_note', 'delete_note', 'copy_note'],
},
},
{
id: 'updateTitle',
title: 'New Title',
type: 'short-input',
placeholder: 'New title (leave empty to keep current)',
condition: { field: 'operation', value: 'update_note' },
},
{
id: 'updateContent',
title: 'New Content',
type: 'long-input',
placeholder: 'New content (leave empty to keep current)',
condition: { field: 'operation', value: 'update_note' },
},
{
id: 'toNotebookGuid',
title: 'Destination Notebook GUID',
type: 'short-input',
placeholder: 'GUID of the destination notebook',
condition: { field: 'operation', value: 'copy_note' },
required: { field: 'operation', value: 'copy_note' },
},
{
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: 'e.g., "tag:work intitle:meeting"',
condition: { field: 'operation', value: 'search_notes' },
required: { field: 'operation', value: 'search_notes' },
},
{
id: 'notebookGuid',
title: 'Notebook GUID',
type: 'short-input',
placeholder: 'Notebook GUID',
condition: {
field: 'operation',
value: ['create_note', 'update_note', 'search_notes', 'get_notebook'],
},
required: { field: 'operation', value: 'get_notebook' },
},
{
id: 'notebookName',
title: 'Notebook Name',
type: 'short-input',
placeholder: 'Name for the new notebook',
condition: { field: 'operation', value: 'create_notebook' },
required: { field: 'operation', value: 'create_notebook' },
},
{
id: 'stack',
title: 'Stack',
type: 'short-input',
placeholder: 'Stack name (optional)',
condition: { field: 'operation', value: 'create_notebook' },
mode: 'advanced',
},
{
id: 'tagName',
title: 'Tag Name',
type: 'short-input',
placeholder: 'Name for the new tag',
condition: { field: 'operation', value: 'create_tag' },
required: { field: 'operation', value: 'create_tag' },
},
{
id: 'parentGuid',
title: 'Parent Tag GUID',
type: 'short-input',
placeholder: 'Parent tag GUID (optional)',
condition: { field: 'operation', value: 'create_tag' },
mode: 'advanced',
},
{
id: 'tagNames',
title: 'Tags',
type: 'short-input',
placeholder: 'Comma-separated tags (e.g., "work, meeting, urgent")',
condition: { field: 'operation', value: ['create_note', 'update_note'] },
mode: 'advanced',
},
{
id: 'maxNotes',
title: 'Max Results',
type: 'short-input',
placeholder: '25',
condition: { field: 'operation', value: 'search_notes' },
mode: 'advanced',
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'search_notes' },
mode: 'advanced',
},
{
id: 'withContent',
title: 'Include Content',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
value: () => 'true',
condition: { field: 'operation', value: 'get_note' },
mode: 'advanced',
},
],
tools: {
access: [
'evernote_copy_note',
'evernote_create_note',
'evernote_create_notebook',
'evernote_create_tag',
'evernote_delete_note',
'evernote_get_note',
'evernote_get_notebook',
'evernote_list_notebooks',
'evernote_list_tags',
'evernote_search_notes',
'evernote_update_note',
],
config: {
tool: (params) => `evernote_${params.operation}`,
params: (params) => {
const { operation, apiKey, ...rest } = params
switch (operation) {
case 'create_note':
return {
apiKey,
title: rest.title,
content: rest.content,
notebookGuid: rest.notebookGuid || undefined,
tagNames: rest.tagNames || undefined,
}
case 'get_note':
return {
apiKey,
noteGuid: rest.noteGuid,
withContent: rest.withContent !== 'false',
}
case 'update_note':
return {
apiKey,
noteGuid: rest.noteGuid,
title: rest.updateTitle || undefined,
content: rest.updateContent || undefined,
notebookGuid: rest.notebookGuid || undefined,
tagNames: rest.tagNames || undefined,
}
case 'delete_note':
return {
apiKey,
noteGuid: rest.noteGuid,
}
case 'copy_note':
return {
apiKey,
noteGuid: rest.noteGuid,
toNotebookGuid: rest.toNotebookGuid,
}
case 'search_notes':
return {
apiKey,
query: rest.query,
notebookGuid: rest.notebookGuid || undefined,
offset: rest.offset ? Number(rest.offset) : 0,
maxNotes: rest.maxNotes ? Number(rest.maxNotes) : 25,
}
case 'get_notebook':
return {
apiKey,
notebookGuid: rest.notebookGuid,
}
case 'create_notebook':
return {
apiKey,
name: rest.notebookName,
stack: rest.stack || undefined,
}
case 'list_notebooks':
return { apiKey }
case 'create_tag':
return {
apiKey,
name: rest.tagName,
parentGuid: rest.parentGuid || undefined,
}
case 'list_tags':
return { apiKey }
default:
return { apiKey }
}
},
},
},
inputs: {
apiKey: { type: 'string', description: 'Evernote developer token' },
operation: { type: 'string', description: 'Operation to perform' },
title: { type: 'string', description: 'Note title' },
content: { type: 'string', description: 'Note content' },
noteGuid: { type: 'string', description: 'Note GUID' },
updateTitle: { type: 'string', description: 'New note title' },
updateContent: { type: 'string', description: 'New note content' },
toNotebookGuid: { type: 'string', description: 'Destination notebook GUID' },
query: { type: 'string', description: 'Search query' },
notebookGuid: { type: 'string', description: 'Notebook GUID' },
notebookName: { type: 'string', description: 'Notebook name' },
stack: { type: 'string', description: 'Notebook stack name' },
tagName: { type: 'string', description: 'Tag name' },
parentGuid: { type: 'string', description: 'Parent tag GUID' },
tagNames: { type: 'string', description: 'Comma-separated tag names' },
maxNotes: { type: 'string', description: 'Maximum number of results' },
offset: { type: 'string', description: 'Starting index for results' },
withContent: { type: 'string', description: 'Whether to include note content' },
},
outputs: {
note: { type: 'json', description: 'Note data' },
notebook: { type: 'json', description: 'Notebook data' },
notebooks: { type: 'json', description: 'List of notebooks' },
tag: { type: 'json', description: 'Tag data' },
tags: { type: 'json', description: 'List of tags' },
totalNotes: { type: 'number', description: 'Total number of matching notes' },
notes: { type: 'json', description: 'List of note metadata' },
success: { type: 'boolean', description: 'Whether the operation succeeded' },
noteGuid: { type: 'string', description: 'GUID of the affected note' },
},
}

View File

@@ -0,0 +1,211 @@
import { FathomIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { FathomResponse } from '@/tools/fathom/types'
import { getTrigger } from '@/triggers'
import { fathomTriggerOptions } from '@/triggers/fathom/utils'
export const FathomBlock: BlockConfig<FathomResponse> = {
type: 'fathom',
name: 'Fathom',
description: 'Access meeting recordings, transcripts, and summaries',
authMode: AuthMode.ApiKey,
triggerAllowed: true,
longDescription:
'Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.',
docsLink: 'https://docs.sim.ai/tools/fathom',
category: 'tools',
bgColor: '#181C1E',
icon: FathomIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'List Meetings', id: 'fathom_list_meetings' },
{ label: 'Get Summary', id: 'fathom_get_summary' },
{ label: 'Get Transcript', id: 'fathom_get_transcript' },
{ label: 'List Team Members', id: 'fathom_list_team_members' },
{ label: 'List Teams', id: 'fathom_list_teams' },
],
value: () => 'fathom_list_meetings',
},
{
id: 'recordingId',
title: 'Recording ID',
type: 'short-input',
required: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
placeholder: 'Enter the recording ID',
condition: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
},
{
id: 'includeSummary',
title: 'Include Summary',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeTranscript',
title: 'Include Transcript',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeActionItems',
title: 'Include Action Items',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeCrmMatches',
title: 'Include CRM Matches',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'createdAfter',
title: 'Created After',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'createdBefore',
title: 'Created Before',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g., 2025-12-31T23:59:59Z)',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'recordedBy',
title: 'Recorded By',
type: 'short-input',
placeholder: 'Filter by recorder email',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
},
{
id: 'teams',
title: 'Team',
type: 'short-input',
placeholder: 'Filter by team name',
condition: {
field: 'operation',
value: ['fathom_list_meetings', 'fathom_list_team_members'],
},
mode: 'advanced',
},
{
id: 'cursor',
title: 'Pagination Cursor',
type: 'short-input',
placeholder: 'Cursor from a previous response',
condition: {
field: 'operation',
value: ['fathom_list_meetings', 'fathom_list_team_members', 'fathom_list_teams'],
},
mode: 'advanced',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
required: true,
placeholder: 'Enter your Fathom API key',
password: true,
},
{
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
options: fathomTriggerOptions,
value: () => 'fathom_new_meeting',
required: true,
},
...getTrigger('fathom_new_meeting').subBlocks,
...getTrigger('fathom_webhook').subBlocks,
],
tools: {
access: [
'fathom_list_meetings',
'fathom_get_summary',
'fathom_get_transcript',
'fathom_list_team_members',
'fathom_list_teams',
],
config: {
tool: (params) => {
return params.operation || 'fathom_list_meetings'
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Fathom API key' },
recordingId: { type: 'string', description: 'Recording ID for summary or transcript' },
includeSummary: { type: 'string', description: 'Include summary in meetings response' },
includeTranscript: { type: 'string', description: 'Include transcript in meetings response' },
includeActionItems: {
type: 'string',
description: 'Include action items in meetings response',
},
includeCrmMatches: {
type: 'string',
description: 'Include linked CRM matches in meetings response',
},
createdAfter: { type: 'string', description: 'Filter meetings created after this timestamp' },
createdBefore: {
type: 'string',
description: 'Filter meetings created before this timestamp',
},
recordedBy: { type: 'string', description: 'Filter by recorder email' },
teams: { type: 'string', description: 'Filter by team name' },
cursor: { type: 'string', description: 'Pagination cursor for next page' },
},
outputs: {
meetings: { type: 'json', description: 'List of meetings' },
template_name: { type: 'string', description: 'Summary template name' },
markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' },
transcript: { type: 'json', description: 'Meeting transcript entries' },
members: { type: 'json', description: 'List of team members' },
teams: { type: 'json', description: 'List of teams' },
next_cursor: { type: 'string', description: 'Pagination cursor' },
},
triggers: {
enabled: true,
available: ['fathom_new_meeting', 'fathom_webhook'],
},
}

View File

@@ -18,6 +18,7 @@ export const GenericWebhookBlock: BlockConfig = {
bestPractices: `
- You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}'
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
- To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
`,
subBlocks: [...getTrigger('generic_webhook').subBlocks],

View File

@@ -47,6 +47,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
{ label: 'Add Watcher', id: 'add_watcher' },
{ label: 'Remove Watcher', id: 'remove_watcher' },
{ label: 'Get Users', id: 'get_users' },
{ label: 'Search Users', id: 'search_users' },
],
value: () => 'read',
},
@@ -673,6 +674,31 @@ Return ONLY the comment text - no explanations.`,
placeholder: 'Maximum users to return (default: 50)',
condition: { field: 'operation', value: 'get_users' },
},
// Search Users fields
{
id: 'searchUsersQuery',
title: 'Search Query',
type: 'short-input',
required: true,
placeholder: 'Enter email address or display name to search',
condition: { field: 'operation', value: 'search_users' },
},
{
id: 'searchUsersMaxResults',
title: 'Max Results',
type: 'short-input',
placeholder: 'Maximum users to return (default: 50)',
condition: { field: 'operation', value: 'search_users' },
mode: 'advanced',
},
{
id: 'searchUsersStartAt',
title: 'Start At',
type: 'short-input',
placeholder: 'Pagination start index (default: 0)',
condition: { field: 'operation', value: 'search_users' },
mode: 'advanced',
},
// Trigger SubBlocks
...getTrigger('jira_issue_created').subBlocks,
...getTrigger('jira_issue_updated').subBlocks,
@@ -707,6 +733,7 @@ Return ONLY the comment text - no explanations.`,
'jira_add_watcher',
'jira_remove_watcher',
'jira_get_users',
'jira_search_users',
],
config: {
tool: (params) => {
@@ -767,6 +794,8 @@ Return ONLY the comment text - no explanations.`,
return 'jira_remove_watcher'
case 'get_users':
return 'jira_get_users'
case 'search_users':
return 'jira_search_users'
default:
return 'jira_retrieve'
}
@@ -1023,6 +1052,18 @@ Return ONLY the comment text - no explanations.`,
: undefined,
}
}
case 'search_users': {
return {
...baseParams,
query: params.searchUsersQuery,
maxResults: params.searchUsersMaxResults
? Number.parseInt(params.searchUsersMaxResults)
: undefined,
startAt: params.searchUsersStartAt
? Number.parseInt(params.searchUsersStartAt)
: undefined,
}
}
default:
return baseParams
}
@@ -1102,6 +1143,13 @@ Return ONLY the comment text - no explanations.`,
},
usersStartAt: { type: 'string', description: 'Pagination start index for users' },
usersMaxResults: { type: 'string', description: 'Maximum users to return' },
// Search Users operation inputs
searchUsersQuery: {
type: 'string',
description: 'Search query (email address or display name)',
},
searchUsersMaxResults: { type: 'string', description: 'Maximum users to return from search' },
searchUsersStartAt: { type: 'string', description: 'Pagination start index for user search' },
},
outputs: {
// Common outputs across all Jira operations

View File

@@ -0,0 +1,270 @@
import { ObsidianIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const ObsidianBlock: BlockConfig = {
type: 'obsidian',
name: 'Obsidian',
description: 'Interact with your Obsidian vault via the Local REST API',
longDescription:
'Read, create, update, search, and delete notes in your Obsidian vault. Manage periodic notes, execute commands, and patch content at specific locations. Requires the Obsidian Local REST API plugin.',
docsLink: 'https://docs.sim.ai/tools/obsidian',
category: 'tools',
bgColor: '#0F0F0F',
icon: ObsidianIcon,
authMode: AuthMode.ApiKey,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'List Files', id: 'list_files' },
{ label: 'Get Note', id: 'get_note' },
{ label: 'Create Note', id: 'create_note' },
{ label: 'Append to Note', id: 'append_note' },
{ label: 'Patch Note', id: 'patch_note' },
{ label: 'Delete Note', id: 'delete_note' },
{ label: 'Search', id: 'search' },
{ label: 'Get Active File', id: 'get_active' },
{ label: 'Append to Active File', id: 'append_active' },
{ label: 'Patch Active File', id: 'patch_active' },
{ label: 'Open File', id: 'open_file' },
{ label: 'List Commands', id: 'list_commands' },
{ label: 'Execute Command', id: 'execute_command' },
{ label: 'Get Periodic Note', id: 'get_periodic_note' },
{ label: 'Append to Periodic Note', id: 'append_periodic_note' },
],
value: () => 'get_note',
},
{
id: 'baseUrl',
title: 'Base URL',
type: 'short-input',
placeholder: 'https://127.0.0.1:27124',
value: () => 'https://127.0.0.1:27124',
required: true,
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Obsidian Local REST API key',
password: true,
required: true,
},
{
id: 'path',
title: 'Directory Path',
type: 'short-input',
placeholder: 'Leave empty for vault root (e.g. "Projects/notes")',
condition: { field: 'operation', value: 'list_files' },
},
{
id: 'filename',
title: 'Note Path',
type: 'short-input',
placeholder: 'folder/note.md',
condition: {
field: 'operation',
value: ['get_note', 'create_note', 'append_note', 'patch_note', 'delete_note', 'open_file'],
},
required: {
field: 'operation',
value: ['get_note', 'create_note', 'append_note', 'patch_note', 'delete_note', 'open_file'],
},
},
{
id: 'content',
title: 'Content',
type: 'long-input',
placeholder: 'Markdown content',
condition: {
field: 'operation',
value: [
'create_note',
'append_note',
'patch_note',
'append_active',
'patch_active',
'append_periodic_note',
],
},
required: {
field: 'operation',
value: [
'create_note',
'append_note',
'patch_note',
'append_active',
'patch_active',
'append_periodic_note',
],
},
},
{
id: 'patchOperation',
title: 'Patch Operation',
type: 'dropdown',
options: [
{ label: 'Append', id: 'append' },
{ label: 'Prepend', id: 'prepend' },
{ label: 'Replace', id: 'replace' },
],
value: () => 'append',
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
required: { field: 'operation', value: ['patch_note', 'patch_active'] },
},
{
id: 'targetType',
title: 'Target Type',
type: 'dropdown',
options: [
{ label: 'Heading', id: 'heading' },
{ label: 'Block Reference', id: 'block' },
{ label: 'Frontmatter', id: 'frontmatter' },
],
value: () => 'heading',
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
required: { field: 'operation', value: ['patch_note', 'patch_active'] },
},
{
id: 'target',
title: 'Target',
type: 'short-input',
placeholder: 'Heading text, block ID, or frontmatter field',
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
required: { field: 'operation', value: ['patch_note', 'patch_active'] },
},
{
id: 'targetDelimiter',
title: 'Target Delimiter',
type: 'short-input',
placeholder: ':: (default)',
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
mode: 'advanced',
},
{
id: 'trimTargetWhitespace',
title: 'Trim Target Whitespace',
type: 'switch',
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
mode: 'advanced',
},
{
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: 'Text to search for',
condition: { field: 'operation', value: 'search' },
required: { field: 'operation', value: 'search' },
},
{
id: 'contextLength',
title: 'Context Length',
type: 'short-input',
placeholder: '100',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'commandId',
title: 'Command ID',
type: 'short-input',
placeholder: 'e.g. daily-notes:open-today',
condition: { field: 'operation', value: 'execute_command' },
required: { field: 'operation', value: 'execute_command' },
},
{
id: 'newLeaf',
title: 'Open in New Tab',
type: 'switch',
condition: { field: 'operation', value: 'open_file' },
mode: 'advanced',
},
{
id: 'period',
title: 'Period',
type: 'dropdown',
options: [
{ label: 'Daily', id: 'daily' },
{ label: 'Weekly', id: 'weekly' },
{ label: 'Monthly', id: 'monthly' },
{ label: 'Quarterly', id: 'quarterly' },
{ label: 'Yearly', id: 'yearly' },
],
value: () => 'daily',
condition: { field: 'operation', value: ['get_periodic_note', 'append_periodic_note'] },
required: { field: 'operation', value: ['get_periodic_note', 'append_periodic_note'] },
},
],
tools: {
access: [
'obsidian_append_active',
'obsidian_append_note',
'obsidian_append_periodic_note',
'obsidian_create_note',
'obsidian_delete_note',
'obsidian_execute_command',
'obsidian_get_active',
'obsidian_get_note',
'obsidian_get_periodic_note',
'obsidian_list_commands',
'obsidian_list_files',
'obsidian_open_file',
'obsidian_patch_active',
'obsidian_patch_note',
'obsidian_search',
],
config: {
tool: (params) => `obsidian_${params.operation}`,
params: (params) => {
const result: Record<string, unknown> = {}
if (params.contextLength) {
result.contextLength = Number(params.contextLength)
}
if (params.patchOperation) {
result.operation = params.patchOperation
}
return result
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
baseUrl: { type: 'string', description: 'Base URL for the Obsidian Local REST API' },
apiKey: { type: 'string', description: 'API key for authentication' },
filename: { type: 'string', description: 'Path to the note relative to vault root' },
content: { type: 'string', description: 'Markdown content for the note' },
path: { type: 'string', description: 'Directory path to list' },
query: { type: 'string', description: 'Text to search for' },
contextLength: { type: 'number', description: 'Characters of context around matches' },
commandId: { type: 'string', description: 'ID of the command to execute' },
patchOperation: { type: 'string', description: 'Patch operation: append, prepend, or replace' },
targetType: { type: 'string', description: 'Target type: heading, block, or frontmatter' },
target: { type: 'string', description: 'Target identifier for patch operations' },
targetDelimiter: { type: 'string', description: 'Delimiter for nested headings' },
trimTargetWhitespace: { type: 'boolean', description: 'Trim whitespace from target' },
newLeaf: { type: 'boolean', description: 'Open file in new tab' },
period: { type: 'string', description: 'Periodic note period type' },
},
outputs: {
content: { type: 'string', description: 'Markdown content of the note' },
filename: { type: 'string', description: 'Path to the note' },
files: { type: 'json', description: 'List of files and directories (path, type)' },
results: { type: 'json', description: 'Search results (filename, score, matches)' },
commands: { type: 'json', description: 'List of available commands (id, name)' },
created: { type: 'boolean', description: 'Whether the note was created' },
appended: { type: 'boolean', description: 'Whether content was appended' },
patched: { type: 'boolean', description: 'Whether content was patched' },
deleted: { type: 'boolean', description: 'Whether the note was deleted' },
executed: { type: 'boolean', description: 'Whether the command was executed' },
opened: { type: 'boolean', description: 'Whether the file was opened' },
commandId: { type: 'string', description: 'ID of the executed command' },
period: { type: 'string', description: 'Period type of the periodic note' },
},
}

View File

@@ -9,7 +9,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
authMode: AuthMode.ApiKey,
longDescription:
'Integrate Parallel AI into the workflow. Can search the web, extract information from URLs, and conduct deep research.',
docsLink: 'https://docs.parallel.ai/',
docsLink: 'https://docs.sim.ai/tools/parallel-ai',
category: 'tools',
bgColor: '#E0E0E0',
icon: ParallelIcon,
@@ -56,7 +56,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
title: 'Extract Objective',
type: 'long-input',
placeholder: 'What information to extract from the URLs?',
required: true,
required: false,
condition: { field: 'operation', value: 'extract' },
},
{
@@ -89,6 +89,37 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
required: true,
condition: { field: 'operation', value: 'deep_research' },
},
{
id: 'search_mode',
title: 'Search Mode',
type: 'dropdown',
options: [
{ label: 'One-Shot', id: 'one-shot' },
{ label: 'Agentic', id: 'agentic' },
{ label: 'Fast', id: 'fast' },
],
value: () => 'one-shot',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'search_include_domains',
title: 'Include Domains',
type: 'short-input',
placeholder: 'Comma-separated domains to include (e.g., .edu, example.com)',
required: false,
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'search_exclude_domains',
title: 'Exclude Domains',
type: 'short-input',
placeholder: 'Comma-separated domains to exclude',
required: false,
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'include_domains',
title: 'Include Domains',
@@ -96,6 +127,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
placeholder: 'Comma-separated domains to include',
required: false,
condition: { field: 'operation', value: 'deep_research' },
mode: 'advanced',
},
{
id: 'exclude_domains',
@@ -104,37 +136,37 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
placeholder: 'Comma-separated domains to exclude',
required: false,
condition: { field: 'operation', value: 'deep_research' },
mode: 'advanced',
},
{
id: 'processor',
title: 'Processor',
title: 'Research Processor',
type: 'dropdown',
options: [
{ label: 'Lite', id: 'lite' },
{ label: 'Base', id: 'base' },
{ label: 'Core', id: 'core' },
{ label: 'Core 2x', id: 'core2x' },
{ label: 'Pro', id: 'pro' },
{ label: 'Ultra', id: 'ultra' },
{ label: 'Ultra 2x', id: 'ultra2x' },
{ label: 'Ultra 4x', id: 'ultra4x' },
{ label: 'Pro Fast', id: 'pro-fast' },
{ label: 'Ultra Fast', id: 'ultra-fast' },
],
value: () => 'base',
condition: { field: 'operation', value: ['search', 'deep_research'] },
value: () => 'pro',
condition: { field: 'operation', value: 'deep_research' },
mode: 'advanced',
},
{
id: 'max_results',
title: 'Max Results',
type: 'short-input',
placeholder: '5',
placeholder: '10',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'max_chars_per_result',
title: 'Max Chars',
title: 'Max Chars Per Result',
type: 'short-input',
placeholder: '1500',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'apiKey',
@@ -149,8 +181,6 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
access: ['parallel_search', 'parallel_extract', 'parallel_deep_research'],
config: {
tool: (params) => {
if (params.extract_objective) params.objective = params.extract_objective
if (params.research_input) params.input = params.research_input
switch (params.operation) {
case 'search':
return 'parallel_search'
@@ -174,21 +204,30 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
.filter((query: string) => query.length > 0)
if (queries.length > 0) {
result.search_queries = queries
} else {
result.search_queries = undefined
}
}
if (params.search_mode && params.search_mode !== 'one-shot') {
result.mode = params.search_mode
}
if (params.max_results) result.max_results = Number(params.max_results)
if (params.max_chars_per_result) {
result.max_chars_per_result = Number(params.max_chars_per_result)
}
result.include_domains = params.search_include_domains || undefined
result.exclude_domains = params.search_exclude_domains || undefined
}
if (operation === 'extract') {
if (params.extract_objective) result.objective = params.extract_objective
result.excerpts = !(params.excerpts === 'false' || params.excerpts === false)
result.full_content = params.full_content === 'true' || params.full_content === true
}
if (operation === 'deep_research') {
if (params.research_input) result.input = params.research_input
if (params.processor) result.processor = params.processor
}
return result
},
},
@@ -202,29 +241,34 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
excerpts: { type: 'boolean', description: 'Include excerpts' },
full_content: { type: 'boolean', description: 'Include full content' },
research_input: { type: 'string', description: 'Deep research query' },
include_domains: { type: 'string', description: 'Domains to include' },
exclude_domains: { type: 'string', description: 'Domains to exclude' },
processor: { type: 'string', description: 'Processing method' },
include_domains: { type: 'string', description: 'Domains to include (deep research)' },
exclude_domains: { type: 'string', description: 'Domains to exclude (deep research)' },
search_include_domains: { type: 'string', description: 'Domains to include (search)' },
search_exclude_domains: { type: 'string', description: 'Domains to exclude (search)' },
search_mode: { type: 'string', description: 'Search mode (one-shot, agentic, fast)' },
processor: { type: 'string', description: 'Research processing tier' },
max_results: { type: 'number', description: 'Maximum number of results' },
max_chars_per_result: { type: 'number', description: 'Maximum characters per result' },
apiKey: { type: 'string', description: 'Parallel AI API key' },
},
outputs: {
results: { type: 'string', description: 'Search or extract results (JSON stringified)' },
results: {
type: 'json',
description: 'Search or extract results (array of url, title, excerpts)',
},
search_id: { type: 'string', description: 'Search request ID (for search)' },
extract_id: { type: 'string', description: 'Extract request ID (for extract)' },
status: { type: 'string', description: 'Task status (for deep research)' },
run_id: { type: 'string', description: 'Task run ID (for deep research)' },
message: { type: 'string', description: 'Status message (for deep research)' },
content: {
type: 'string',
description: 'Research content (for deep research, JSON stringified)',
type: 'json',
description: 'Research content (for deep research, structured based on output_schema)',
},
basis: {
type: 'string',
description: 'Citations and sources (for deep research, JSON stringified)',
},
metadata: {
type: 'string',
description: 'Task metadata (for deep research, JSON stringified)',
type: 'json',
description:
'Citations and sources with field, reasoning, citations, confidence (for deep research)',
},
},
}

View File

@@ -38,7 +38,9 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
import { EnrichBlock } from '@/blocks/blocks/enrich'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { EvernoteBlock } from '@/blocks/blocks/evernote'
import { ExaBlock } from '@/blocks/blocks/exa'
import { FathomBlock } from '@/blocks/blocks/fathom'
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies'
@@ -113,6 +115,7 @@ import { MySQLBlock } from '@/blocks/blocks/mysql'
import { Neo4jBlock } from '@/blocks/blocks/neo4j'
import { NoteBlock } from '@/blocks/blocks/note'
import { NotionBlock, NotionV2Block } from '@/blocks/blocks/notion'
import { ObsidianBlock } from '@/blocks/blocks/obsidian'
import { OneDriveBlock } from '@/blocks/blocks/onedrive'
import { OnePasswordBlock } from '@/blocks/blocks/onepassword'
import { OpenAIBlock } from '@/blocks/blocks/openai'
@@ -233,7 +236,9 @@ export const registry: Record<string, BlockConfig> = {
dynamodb: DynamoDBBlock,
elasticsearch: ElasticsearchBlock,
elevenlabs: ElevenLabsBlock,
fathom: FathomBlock,
enrich: EnrichBlock,
evernote: EvernoteBlock,
evaluator: EvaluatorBlock,
exa: ExaBlock,
file: FileBlock,
@@ -320,6 +325,7 @@ export const registry: Record<string, BlockConfig> = {
note: NoteBlock,
notion: NotionBlock,
notion_v2: NotionV2Block,
obsidian: ObsidianBlock,
onepassword: OnePasswordBlock,
onedrive: OneDriveBlock,
openai: OpenAIBlock,

View File

@@ -710,6 +710,155 @@ export function PerplexityIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function ObsidianIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const bl = `${id}-bl`
const tr = `${id}-tr`
const tl = `${id}-tl`
const br = `${id}-br`
const te = `${id}-te`
const le = `${id}-le`
const be = `${id}-be`
const me = `${id}-me`
const clip = `${id}-clip`
return (
<svg {...props} viewBox='0 0 512 512' fill='none' xmlns='http://www.w3.org/2000/svg'>
<radialGradient
id={bl}
cx='0'
cy='0'
gradientTransform='matrix(-59 -225 150 -39 161.4 470)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.4' />
<stop offset='1' stopOpacity='.1' />
</radialGradient>
<radialGradient
id={tr}
cx='0'
cy='0'
gradientTransform='matrix(50 -379 280 37 360 374.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.6' />
<stop offset='1' stopColor='#fff' stopOpacity='.1' />
</radialGradient>
<radialGradient
id={tl}
cx='0'
cy='0'
gradientTransform='matrix(69 -319 218 47 175.4 307)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.8' />
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
</radialGradient>
<radialGradient
id={br}
cx='0'
cy='0'
gradientTransform='matrix(-96 -163 187 -111 335.3 512.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.3' />
<stop offset='1' stopOpacity='.3' />
</radialGradient>
<radialGradient
id={te}
cx='0'
cy='0'
gradientTransform='matrix(-36 166 -112 -24 310 128.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='0' />
<stop offset='1' stopColor='#fff' stopOpacity='.2' />
</radialGradient>
<radialGradient
id={le}
cx='0'
cy='0'
gradientTransform='matrix(88 89 -190 187 111 220.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
</radialGradient>
<radialGradient
id={be}
cx='0'
cy='0'
gradientTransform='matrix(9 130 -276 20 215 284)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
</radialGradient>
<radialGradient
id={me}
cx='0'
cy='0'
gradientTransform='matrix(-198 -104 327 -623 400 399.2)'
gradientUnits='userSpaceOnUse'
r='1'
>
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
<stop offset='.5' stopColor='#fff' stopOpacity='.2' />
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
</radialGradient>
<clipPath id={clip}>
<path d='M.2.2h512v512H.2z' />
</clipPath>
<g clipPath={`url(#${clip})`}>
<path
d='M382.3 475.6c-3.1 23.4-26 41.6-48.7 35.3-32.4-8.9-69.9-22.8-103.6-25.4l-51.7-4a34 34 0 0 1-22-10.2l-89-91.7a34 34 0 0 1-6.7-37.7s55-121 57.1-127.3c2-6.3 9.6-61.2 14-90.6 1.2-7.9 5-15 11-20.3L248 8.9a34.1 34.1 0 0 1 49.6 4.3L386 125.6a37 37 0 0 1 7.6 22.4c0 21.3 1.8 65 13.6 93.2 11.5 27.3 32.5 57 43.5 71.5a17.3 17.3 0 0 1 1.3 19.2 1494 1494 0 0 1-44.8 70.6c-15 22.3-21.9 49.9-25 73.1z'
fill='#6c31e3'
/>
<path
d='M165.9 478.3c41.4-84 40.2-144.2 22.6-187-16.2-39.6-46.3-64.5-70-80-.6 2.3-1.3 4.4-2.2 6.5L60.6 342a34 34 0 0 0 6.6 37.7l89.1 91.7a34 34 0 0 0 9.6 7z'
fill={`url(#${bl})`}
/>
<path
d='M278.4 307.8c11.2 1.2 22.2 3.6 32.8 7.6 34 12.7 65 41.2 90.5 96.3 1.8-3.1 3.6-6.2 5.6-9.2a1536 1536 0 0 0 44.8-70.6 17 17 0 0 0-1.3-19.2c-11-14.6-32-44.2-43.5-71.5-11.8-28.2-13.5-72-13.6-93.2 0-8.1-2.6-16-7.6-22.4L297.6 13.2a34 34 0 0 0-1.5-1.7 96 96 0 0 1 2 54 198.3 198.3 0 0 1-17.6 41.3l-7.2 14.2a171 171 0 0 0-19.4 71c-1.2 29.4 4.8 66.4 24.5 115.8z'
fill={`url(#${tr})`}
/>
<path
d='M278.4 307.8c-19.7-49.4-25.8-86.4-24.5-115.9a171 171 0 0 1 19.4-71c2.3-4.8 4.8-9.5 7.2-14.1 7.1-13.9 14-27 17.6-41.4a96 96 0 0 0-2-54A34.1 34.1 0 0 0 248 9l-105.4 94.8a34.1 34.1 0 0 0-10.9 20.3l-12.8 85-.5 2.3c23.8 15.5 54 40.4 70.1 80a147 147 0 0 1 7.8 24.8c28-6.8 55.7-11 82.1-8.3z'
fill={`url(#${tl})`}
/>
<path
d='M333.6 511c22.7 6.2 45.6-12 48.7-35.4a187 187 0 0 1 19.4-63.9c-25.6-55-56.5-83.6-90.4-96.3-36-13.4-75.2-9-115 .7 8.9 40.4 3.6 93.3-30.4 162.2 4 1.8 8.1 3 12.5 3.3 0 0 24.4 2 53.6 4.1 29 2 72.4 17.1 101.6 25.2z'
fill={`url(#${br})`}
/>
<g clipRule='evenodd' fillRule='evenodd'>
<path
d='M254.1 190c-1.3 29.2 2.4 62.8 22.1 112.1l-6.2-.5c-17.7-51.5-21.5-78-20.2-107.6a174.7 174.7 0 0 1 20.4-72c2.4-4.9 8-14.1 10.5-18.8 7.1-13.7 11.9-21 16-33.6 5.7-17.5 4.5-25.9 3.8-34.1 4.6 29.9-12.7 56-25.7 82.4a177.1 177.1 0 0 0-20.7 72z'
fill={`url(#${te})`}
/>
<path
d='M194.3 293.4c2.4 5.4 4.6 9.8 6 16.5L195 311c-2.1-7.8-3.8-13.4-6.8-20-17.8-42-46.3-63.6-69.7-79.5 28.2 15.2 57.2 39 75.7 81.9z'
fill={`url(#${le})`}
/>
<path
d='M200.6 315.1c9.8 46-1.2 104.2-33.6 160.9 27.1-56.2 40.2-110.1 29.3-160z'
fill={`url(#${be})`}
/>
<path
d='M312.5 311c53.1 19.9 73.6 63.6 88.9 100-19-38.1-45.2-80.3-90.8-96-34.8-11.8-64.1-10.4-114.3 1l-1.1-5c53.2-12.1 81-13.5 117.3 0z'
fill={`url(#${me})`}
/>
</g>
</g>
</svg>
)
}
export function NotionIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='1em' height='1em' {...props}>
@@ -1806,6 +1955,14 @@ export function Mem0Icon(props: SVGProps<SVGSVGElement>) {
)
}
export function EvernoteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='#7fce2c'>
<path d='M29.343 16.818c.1 1.695-.08 3.368-.305 5.045-.225 1.712-.508 3.416-.964 5.084-.3 1.067-.673 2.1-1.202 3.074-.65 1.192-1.635 1.87-2.992 1.924l-3.832.036c-.636-.017-1.278-.146-1.9-.297-1.192-.3-1.862-1.1-2.06-2.3-.186-1.08-.173-2.187.04-3.264.252-1.23 1-1.96 2.234-2.103.817-.1 1.65-.077 2.476-.1.205-.007.275.098.203.287-.196.53-.236 1.07-.098 1.623.053.207-.023.307-.26.305a7.77 7.77 0 0 0-1.123.053c-.636.086-.96.47-.96 1.112 0 .205.026.416.066.622.103.507.45.78.944.837 1.123.127 2.247.138 3.37-.05.675-.114 1.08-.54 1.16-1.208.152-1.3.155-2.587-.228-3.845-.33-1.092-1.006-1.565-2.134-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.757-.013-.106-.074-.208-.1-.3-.04.1-.106.212-.117.326-.066.68-.053 1.373-.185 2.04-.16.8-.404 1.566-.67 2.33-.185.535-.616.837-1.205.8a37.76 37.76 0 0 1-7.123-1.353l-.64-.207c-.927-.26-1.487-.903-1.74-1.787l-1-3.853-.74-4.3c-.115-.755-.2-1.523-.083-2.293.154-1.112.914-1.903 2.04-1.964l3.558-.062c.127 0 .254.003.373-.026a1.23 1.23 0 0 0 1.01-1.255l-.05-3.036c-.048-1.576.8-2.38 2.156-2.622a10.58 10.58 0 0 1 4.91.26c.933.275 1.467.923 1.715 1.83.058.22.146.3.37.287l2.582.01 3.333.37c.686.095 1.364.25 2.032.42 1.165.298 1.793 1.112 1.962 2.256l.357 3.355.3 5.577.01 2.277zm-4.534-1.155c-.02-.666-.07-1.267-.444-1.784a1.66 1.66 0 0 0-2.469-.15c-.364.4-.494.88-.564 1.4-.008.034.106.126.16.126l.8-.053c.768.007 1.523.113 2.25.393.066.026.136.04.265.077zM8.787 1.154a3.82 3.82 0 0 0-.278 1.592l.05 2.934c.005.357-.075.45-.433.45L5.1 6.156c-.583 0-1.143.1-1.554.278l5.2-5.332c.02.013.04.033.06.053z' />
</svg>
)
}
export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -1822,6 +1979,24 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
<path
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
fill='#007299'
/>
<path
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
<path
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
</svg>
)
}
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>

View File

@@ -166,7 +166,8 @@ export class ConditionBlockHandler implements BlockHandler {
if (!output || typeof output !== 'object') {
return output
}
const { _pauseMetadata, error, ...rest } = output
const { _pauseMetadata, error, providerTiming, tokens, toolCalls, model, cost, ...rest } =
output
return rest
}

View File

@@ -22,7 +22,7 @@ export class TriggerBlockHandler implements BlockHandler {
}
const existingState = ctx.blockStates.get(block.id)
if (existingState?.output && Object.keys(existingState.output).length > 0) {
if (existingState?.output) {
return existingState.output
}

View File

@@ -39,10 +39,10 @@ type FolderResponse = { id: string; name: string }
type PlannerTask = { id: string; title: string }
const ensureCredential = (context: SelectorContext, key: SelectorKey): string => {
if (!context.credentialId) {
if (!context.oauthCredential) {
throw new Error(`Missing credential for selector ${key}`)
}
return context.credentialId
return context.oauthCredential
}
const ensureDomain = (context: SelectorContext, key: SelectorKey): string => {
@@ -66,9 +66,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'airtable.bases',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'airtable.bases')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -104,10 +104,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'airtable.tables',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.baseId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.baseId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.baseId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'airtable.tables')
if (!context.baseId) {
@@ -151,9 +151,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'asana.workspaces',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'asana.workspaces')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -182,9 +182,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'attio.objects',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'attio.objects')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -216,9 +216,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'attio.lists',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'attio.lists')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -250,10 +250,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bigquery.datasets',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.projectId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.projectId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'bigquery.datasets')
if (!context.projectId) throw new Error('Missing project ID for bigquery.datasets selector')
@@ -298,12 +298,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bigquery.tables',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
context.datasetId ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.credentialId && context.projectId && context.datasetId),
Boolean(context.oauthCredential && context.projectId && context.datasetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'bigquery.tables')
if (!context.projectId) throw new Error('Missing project ID for bigquery.tables selector')
@@ -347,9 +347,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'calcom.eventTypes',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'calcom.eventTypes')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -381,9 +381,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'calcom.schedules',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'calcom.schedules')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -415,10 +415,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'confluence.spaces',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.spaces')
const domain = ensureDomain(context, 'confluence.spaces')
@@ -460,10 +460,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'jsm.serviceDesks',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jsm.serviceDesks')
const domain = ensureDomain(context, 'jsm.serviceDesks')
@@ -505,12 +505,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'jsm.requestTypes',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
context.serviceDeskId ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.credentialId && context.domain && context.serviceDeskId),
Boolean(context.oauthCredential && context.domain && context.serviceDeskId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jsm.requestTypes')
const domain = ensureDomain(context, 'jsm.requestTypes')
@@ -556,9 +556,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.tasks.lists',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.tasks.lists')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -587,9 +587,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.planner.plans',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.planner.plans')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -618,9 +618,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'notion.databases',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'notion.databases')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -652,9 +652,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'notion.pages',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'notion.pages')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -686,9 +686,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'pipedrive.pipelines',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'pipedrive.pipelines')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -720,10 +720,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sharepoint.lists',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.siteId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'sharepoint.lists')
if (!context.siteId) throw new Error('Missing site ID for sharepoint.lists selector')
@@ -761,9 +761,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'trello.boards',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'trello.boards')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -794,9 +794,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'zoom.meetings',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'zoom.meetings')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -828,12 +828,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'slack.channels',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({
credential: context.credentialId,
credential: context.oauthCredential,
workflowId: context.workflowId,
})
const data = await fetchJson<{ channels: SlackChannel[] }>('/api/tools/slack/channels', {
@@ -852,12 +852,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'slack.users',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({
credential: context.credentialId,
credential: context.oauthCredential,
workflowId: context.workflowId,
})
const data = await fetchJson<{ users: SlackUser[] }>('/api/tools/slack/users', {
@@ -876,12 +876,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'gmail.labels',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', {
searchParams: { credentialId: context.credentialId },
searchParams: { credentialId: context.oauthCredential },
})
return (data.labels || []).map((label) => ({
id: label.id,
@@ -895,12 +895,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'outlook.folders',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ folders: FolderResponse[] }>('/api/tools/outlook/folders', {
searchParams: { credentialId: context.credentialId },
searchParams: { credentialId: context.oauthCredential },
})
return (data.folders || []).map((folder) => ({
id: folder.id,
@@ -914,13 +914,13 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.calendar',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>(
'/api/tools/google_calendar/calendars',
{ searchParams: { credentialId: context.credentialId } }
{ searchParams: { credentialId: context.oauthCredential } }
)
return (data.calendars || []).map((calendar) => ({
id: calendar.id,
@@ -934,11 +934,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.teams',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({ credential: context.credentialId })
const body = JSON.stringify({ credential: context.oauthCredential })
const data = await fetchJson<{ teams: { id: string; displayName: string }[] }>(
'/api/tools/microsoft-teams/teams',
{ method: 'POST', body }
@@ -955,11 +955,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.chats',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({ credential: context.credentialId })
const body = JSON.stringify({ credential: context.oauthCredential })
const data = await fetchJson<{ chats: { id: string; displayName: string }[] }>(
'/api/tools/microsoft-teams/chats',
{ method: 'POST', body }
@@ -976,13 +976,13 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.channels',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.teamId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.teamId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({
credential: context.credentialId,
credential: context.oauthCredential,
teamId: context.teamId,
})
const data = await fetchJson<{ channels: { id: string; displayName: string }[] }>(
@@ -1001,14 +1001,14 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'wealthbox.contacts',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ items: { id: string; name: string }[] }>(
'/api/tools/wealthbox/items',
{
searchParams: { credentialId: context.credentialId, type: 'contact' },
searchParams: { credentialId: context.oauthCredential, type: 'contact' },
}
)
return (data.items || []).map((item) => ({
@@ -1023,9 +1023,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sharepoint.sites',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'sharepoint.sites')
const body = JSON.stringify({
@@ -1069,10 +1069,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.planner',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.planId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.planId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.planId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.planner')
const body = JSON.stringify({
@@ -1112,11 +1112,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.projects',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
@@ -1171,12 +1171,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.issues',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
context.projectId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
@@ -1235,9 +1235,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.teams',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.teams')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -1260,10 +1260,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.projects',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.teamId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.teamId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.projects')
const body = JSON.stringify({
@@ -1290,11 +1290,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'confluence.pages',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
@@ -1343,9 +1343,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.files',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.files')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1366,9 +1366,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.folders',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.folders')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1389,12 +1389,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'google.drive',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.mimeType ?? 'any',
context.fileId ?? 'root',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.drive')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1438,10 +1438,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.sheets',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.spreadsheetId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.sheets')
if (!context.spreadsheetId) {
@@ -1469,10 +1469,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel.sheets',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.spreadsheetId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel.sheets')
if (!context.spreadsheetId) {
@@ -1500,10 +1500,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1528,10 +1528,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.word',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.word')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1596,9 +1596,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'webflow.sites',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.sites')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -1621,10 +1621,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'webflow.collections',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.siteId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.collections')
if (!context.siteId) {
@@ -1654,11 +1654,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'webflow.items',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.collectionId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.collectionId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.collectionId),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.items')
if (!context.collectionId) {

View File

@@ -7,46 +7,16 @@ export interface SelectorResolution {
allowSearch: boolean
}
export interface SelectorResolutionArgs {
workflowId?: string
credentialId?: string
domain?: string
projectId?: string
planId?: string
teamId?: string
knowledgeBaseId?: string
siteId?: string
collectionId?: string
spreadsheetId?: string
fileId?: string
baseId?: string
datasetId?: string
serviceDeskId?: string
}
export function resolveSelectorForSubBlock(
subBlock: SubBlockConfig,
args: SelectorResolutionArgs
context: SelectorContext
): SelectorResolution | null {
if (!subBlock.selectorKey) return null
return {
key: subBlock.selectorKey,
context: {
workflowId: args.workflowId,
credentialId: args.credentialId,
domain: args.domain,
projectId: args.projectId,
planId: args.planId,
teamId: args.teamId,
knowledgeBaseId: args.knowledgeBaseId,
siteId: args.siteId,
collectionId: args.collectionId,
spreadsheetId: args.spreadsheetId,
fileId: args.fileId,
baseId: args.baseId,
datasetId: args.datasetId,
serviceDeskId: args.serviceDeskId,
mimeType: subBlock.mimeType,
...context,
mimeType: subBlock.mimeType ?? context.mimeType,
},
allowSearch: subBlock.selectorAllowSearch ?? true,
}

View File

@@ -61,7 +61,7 @@ export interface SelectorOption {
export interface SelectorContext {
workspaceId?: string
workflowId?: string
credentialId?: string
oauthCredential?: string
serviceId?: string
domain?: string
teamId?: string

View File

@@ -12,7 +12,7 @@ interface SelectorDisplayNameArgs {
subBlock?: SubBlockConfig
value: unknown
workflowId?: string
credentialId?: string
oauthCredential?: string
domain?: string
projectId?: string
planId?: string
@@ -31,7 +31,7 @@ export function useSelectorDisplayName({
subBlock,
value,
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,
@@ -51,7 +51,7 @@ export function useSelectorDisplayName({
if (!subBlock || !detailId) return null
return resolveSelectorForSubBlock(subBlock, {
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,
@@ -69,7 +69,7 @@ export function useSelectorDisplayName({
subBlock,
detailId,
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,

View File

@@ -7,6 +7,7 @@ import {
ClientFactoryOptions,
} from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
import { A2A_TERMINAL_STATES } from './constants'
@@ -43,6 +44,11 @@ class ApiKeyInterceptor implements CallInterceptor {
* Tries standard path first, falls back to root URL for compatibility.
*/
export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> {
const validation = await validateUrlWithDNS(agentUrl, 'agentUrl')
if (!validation.isValid) {
throw new Error(validation.error || 'Agent URL validation failed')
}
const factoryOptions = apiKey
? ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
clientConfig: {

View File

@@ -8,6 +8,7 @@ import {
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'
import { env } from '@/lib/core/config/env'
import { safeCompare } from '@/lib/core/security/encryption'
const logger = createLogger('ApiKeyAuth')
@@ -39,7 +40,7 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
if (isEncryptedKey(storedKey)) {
try {
const { decrypted } = await decryptApiKey(storedKey)
return inputKey === decrypted
return safeCompare(inputKey, decrypted)
} catch (decryptError) {
logger.error('Failed to decrypt stored API key:', { error: decryptError })
return false
@@ -54,27 +55,27 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
if (isEncryptedKey(storedKey)) {
try {
const { decrypted } = await decryptApiKey(storedKey)
return inputKey === decrypted
return safeCompare(inputKey, decrypted)
} catch (decryptError) {
logger.error('Failed to decrypt stored API key:', { error: decryptError })
// Fall through to plain text comparison if decryption fails
}
}
// Legacy format can match against plain text storage
return inputKey === storedKey
return safeCompare(inputKey, storedKey)
}
// If no recognized prefix, fall back to original behavior
if (isEncryptedKey(storedKey)) {
try {
const { decrypted } = await decryptApiKey(storedKey)
return inputKey === decrypted
return safeCompare(inputKey, decrypted)
} catch (decryptError) {
logger.error('Failed to decrypt stored API key:', { error: decryptError })
}
}
return inputKey === storedKey
return safeCompare(inputKey, storedKey)
} catch (error) {
logger.error('API key authentication error:', { error })
return false

View File

@@ -492,7 +492,7 @@ export const auth = betterAuth({
'google-meet',
'google-tasks',
'vertex-ai',
'github-repo',
'microsoft-dataverse',
'microsoft-teams',
'microsoft-excel',
@@ -754,83 +754,6 @@ export const auth = betterAuth({
}),
genericOAuth({
config: [
{
providerId: 'github-repo',
clientId: env.GITHUB_REPO_CLIENT_ID as string,
clientSecret: env.GITHUB_REPO_CLIENT_SECRET as string,
authorizationUrl: 'https://github.com/login/oauth/authorize',
accessType: 'offline',
prompt: 'consent',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: getCanonicalScopesForProvider('github-repo'),
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/github-repo`,
getUserInfo: async (tokens) => {
try {
const profileResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'User-Agent': 'sim-studio',
},
})
if (!profileResponse.ok) {
await profileResponse.text().catch(() => {})
logger.error('Failed to fetch GitHub profile', {
status: profileResponse.status,
statusText: profileResponse.statusText,
})
throw new Error(`Failed to fetch GitHub profile: ${profileResponse.statusText}`)
}
const profile = await profileResponse.json()
if (!profile.email) {
const emailsResponse = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'User-Agent': 'sim-studio',
},
})
if (emailsResponse.ok) {
const emails = await emailsResponse.json()
const primaryEmail =
emails.find(
(email: { primary: boolean; email: string; verified: boolean }) =>
email.primary
) || emails[0]
if (primaryEmail) {
profile.email = primaryEmail.email
profile.emailVerified = primaryEmail.verified || false
}
} else {
logger.warn('Failed to fetch GitHub emails', {
status: emailsResponse.status,
statusText: emailsResponse.statusText,
})
}
}
const now = new Date()
return {
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
name: profile.name || profile.login,
email: profile.email,
image: profile.avatar_url,
emailVerified: profile.emailVerified || false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in GitHub getUserInfo', { error })
throw error
}
},
},
// Google providers
{
providerId: 'google-email',

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { safeCompare } from '@/lib/core/security/encryption'
const logger = createLogger('CronAuth')
@@ -81,7 +82,8 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
const authHeader = request.headers.get('authorization')
const expectedAuth = `Bearer ${env.CRON_SECRET}`
if (authHeader !== expectedAuth) {
const isValid = authHeader !== null && safeCompare(authHeader, expectedAuth)
if (!isValid) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
providedAuth: authHeader,

View File

@@ -1,5 +1,6 @@
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
import { safeCompare } from '@/lib/core/security/encryption'
export function checkInternalApiKey(req: NextRequest) {
const apiKey = req.headers.get('x-api-key')
@@ -13,7 +14,7 @@ export function checkInternalApiKey(req: NextRequest) {
return { success: false, error: 'API key required' }
}
if (apiKey !== expectedApiKey) {
if (!safeCompare(apiKey, expectedApiKey)) {
return { success: false, error: 'Invalid API key' }
}

View File

@@ -7,6 +7,7 @@ const logger = createLogger('AsyncJobsConfig')
let cachedBackend: JobQueueBackend | null = null
let cachedBackendType: AsyncBackendType | null = null
let cachedInlineBackend: JobQueueBackend | null = null
/**
* Determines which async backend to use based on environment configuration.
@@ -71,6 +72,31 @@ export function getCurrentBackendType(): AsyncBackendType | null {
return cachedBackendType
}
/**
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
* Used for non-polling webhooks that should always execute inline.
*/
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
if (cachedInlineBackend) {
return cachedInlineBackend
}
const redis = getRedisClient()
let type: string
if (redis) {
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
cachedInlineBackend = new RedisJobQueue(redis)
type = 'redis'
} else {
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
cachedInlineBackend = new DatabaseJobQueue()
type = 'database'
}
logger.info(`Inline job backend initialized: ${type}`)
return cachedInlineBackend
}
/**
* Checks if jobs should be executed inline (fire-and-forget).
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
@@ -85,4 +111,5 @@ export function shouldExecuteInline(): boolean {
export function resetJobQueueCache(): void {
cachedBackend = null
cachedBackendType = null
cachedInlineBackend = null
}

View File

@@ -1,6 +1,7 @@
export {
getAsyncBackendType,
getCurrentBackendType,
getInlineJobQueue,
getJobQueue,
resetJobQueueCache,
shouldExecuteInline,

View File

@@ -230,8 +230,7 @@ export const env = createEnv({
GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret
GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
GITHUB_REPO_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for repo access
GITHUB_REPO_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret for repo access
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID

View File

@@ -413,6 +413,7 @@ export class IdempotencyService {
: undefined
const webhookIdHeader =
normalizedHeaders?.['x-sim-idempotency-key'] ||
normalizedHeaders?.['webhook-id'] ||
normalizedHeaders?.['x-webhook-id'] ||
normalizedHeaders?.['x-shopify-webhook-id'] ||

View File

@@ -81,7 +81,9 @@ export function setDeploymentAuthCookie(
}
/**
* Adds CORS headers to allow cross-origin requests for embedded deployments
* Adds CORS headers to allow cross-origin requests for embedded deployments.
* Embedded chat widgets and forms are designed to run on any customer domain,
* so we reflect the requesting origin rather than restricting to an allowlist.
*/
export function addCorsHeaders(response: NextResponse, request: NextRequest): NextResponse {
const origin = request.headers.get('origin') || ''

View File

@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto'
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
@@ -91,8 +91,8 @@ export function generatePassword(length = 24): string {
* @returns True if strings are equal, false otherwise
*/
export function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) {
return false
}
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
const key = 'safeCompare'
const ha = createHmac('sha256', key).update(a).digest()
const hb = createHmac('sha256', key).update(b).digest()
return timingSafeEqual(ha, hb)
}

View File

@@ -54,9 +54,10 @@ function isPrivateOrReservedIP(ip: string): boolean {
*/
export async function validateUrlWithDNS(
url: string | null | undefined,
paramName = 'url'
paramName = 'url',
options: { allowHttp?: boolean } = {}
): Promise<AsyncValidationResult> {
const basicValidation = validateExternalUrl(url, paramName)
const basicValidation = validateExternalUrl(url, paramName, options)
if (!basicValidation.isValid) {
return basicValidation
}
@@ -88,7 +89,10 @@ export async function validateUrlWithDNS(
return ip === '127.0.0.1' || ip === '::1'
})()
if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback)) {
if (
isPrivateOrReservedIP(address) &&
!(isLocalhost && resolvedIsLoopback && !options.allowHttp)
) {
logger.warn('URL resolves to blocked IP address', {
paramName,
hostname,
@@ -118,6 +122,70 @@ export async function validateUrlWithDNS(
}
}
/**
* Validates a database hostname by resolving DNS and checking the resolved IP
* against private/reserved ranges to prevent SSRF via database connections.
*
* Unlike validateHostname (which enforces strict RFC hostname format), this
* function is permissive about hostname format to avoid breaking legitimate
* database hostnames (e.g. underscores in Docker/K8s service names). It only
* blocks localhost and private/reserved IPs.
*
* @param host - The database hostname to validate
* @param paramName - Name of the parameter for error messages
* @returns AsyncValidationResult with resolved IP
*/
export async function validateDatabaseHost(
host: string | null | undefined,
paramName = 'host'
): Promise<AsyncValidationResult> {
if (!host) {
return { isValid: false, error: `${paramName} is required` }
}
const lowerHost = host.toLowerCase()
if (lowerHost === 'localhost') {
return { isValid: false, error: `${paramName} cannot be localhost` }
}
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
return { isValid: false, error: `${paramName} cannot be a private IP address` }
}
try {
const { address } = await dns.lookup(host, { verbatim: true })
if (isPrivateOrReservedIP(address)) {
logger.warn('Database host resolves to blocked IP address', {
paramName,
hostname: host,
resolvedIP: address,
})
return {
isValid: false,
error: `${paramName} resolves to a blocked IP address`,
}
}
return {
isValid: true,
resolvedIP: address,
originalHostname: host,
}
} catch (error) {
logger.warn('DNS lookup failed for database host', {
paramName,
hostname: host,
error: error instanceof Error ? error.message : String(error),
})
return {
isValid: false,
error: `${paramName} hostname could not be resolved`,
}
}
}
export interface SecureFetchOptions {
method?: string
headers?: Record<string, string>
@@ -183,7 +251,7 @@ function resolveRedirectUrl(baseUrl: string, location: string): string {
export async function secureFetchWithPinnedIP(
url: string,
resolvedIP: string,
options: SecureFetchOptions = {},
options: SecureFetchOptions & { allowHttp?: boolean } = {},
redirectCount = 0
): Promise<SecureFetchResponse> {
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
@@ -231,7 +299,7 @@ export async function secureFetchWithPinnedIP(
res.resume()
const redirectUrl = resolveRedirectUrl(url, location)
validateUrlWithDNS(redirectUrl, 'redirectUrl')
validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp })
.then((validation) => {
if (!validation.isValid) {
reject(new Error(`Redirect blocked: ${validation.error}`))
@@ -340,10 +408,12 @@ export async function secureFetchWithPinnedIP(
*/
export async function secureFetchWithValidation(
url: string,
options: SecureFetchOptions = {},
options: SecureFetchOptions & { allowHttp?: boolean } = {},
paramName = 'url'
): Promise<SecureFetchResponse> {
const validation = await validateUrlWithDNS(url, paramName)
const validation = await validateUrlWithDNS(url, paramName, {
allowHttp: options.allowHttp,
})
if (!validation.isValid) {
throw new Error(validation.error)
}

View File

@@ -676,7 +676,8 @@ export function validateJiraIssueKey(
*/
export function validateExternalUrl(
url: string | null | undefined,
paramName = 'url'
paramName = 'url',
options: { allowHttp?: boolean } = {}
): ValidationResult {
if (!url || typeof url !== 'string') {
return {
@@ -709,7 +710,20 @@ export function validateExternalUrl(
}
}
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
if (options.allowHttp) {
if (protocol !== 'https:' && protocol !== 'http:') {
return {
isValid: false,
error: `${paramName} must use http:// or https:// protocol`,
}
}
if (isLocalhost) {
return {
isValid: false,
error: `${paramName} cannot point to localhost`,
}
}
} else if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
return {
isValid: false,
error: `${paramName} must use https:// protocol`,

View File

@@ -1,6 +1,10 @@
import { createLogger } from '@sim/logger'
import type { ToolCall, TraceSpan } from '@/lib/logs/types'
import { isWorkflowBlockType, stripCustomToolPrefix } from '@/executor/constants'
import {
isConditionBlockType,
isWorkflowBlockType,
stripCustomToolPrefix,
} from '@/executor/constants'
import type { ExecutionResult } from '@/executor/types'
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
@@ -109,6 +113,7 @@ export function buildTraceSpans(result: ExecutionResult): {
if (!log.blockId || !log.blockType) return
const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}`
const isCondition = isConditionBlockType(log.blockType)
const duration = log.durationMs || 0
@@ -164,7 +169,7 @@ export function buildTraceSpans(result: ExecutionResult): {
...(log.parentIterations?.length && { parentIterations: log.parentIterations }),
}
if (log.output?.providerTiming) {
if (!isCondition && log.output?.providerTiming) {
const providerTiming = log.output.providerTiming as {
duration: number
startTime: string
@@ -186,7 +191,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (log.output?.cost) {
if (!isCondition && log.output?.cost) {
span.cost = log.output.cost as {
input?: number
output?: number
@@ -194,7 +199,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (log.output?.tokens) {
if (!isCondition && log.output?.tokens) {
const t = log.output.tokens as
| number
| {
@@ -224,12 +229,13 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (log.output?.model) {
if (!isCondition && log.output?.model) {
span.model = log.output.model as string
}
if (
!isWorkflowBlockType(log.blockType) &&
!isCondition &&
log.output?.providerTiming?.timeSegments &&
Array.isArray(log.output.providerTiming.timeSegments)
) {
@@ -317,7 +323,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
)
} else {
} else if (!isCondition) {
let toolCallsList = null
try {

View File

@@ -246,7 +246,7 @@ describe('categorizeError', () => {
const error = new Error('Server not accessible')
const result = categorizeError(error)
expect(result.status).toBe(404)
expect(result.message).toBe('Server not accessible')
expect(result.message).toBe('Resource not found')
})
it.concurrent('returns 401 for authentication errors', () => {
@@ -267,28 +267,28 @@ describe('categorizeError', () => {
const error = new Error('Invalid parameter provided')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid parameter provided')
expect(result.message).toBe('Invalid request parameters')
})
it.concurrent('returns 400 for missing required errors', () => {
const error = new Error('Missing required field: name')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Missing required field: name')
expect(result.message).toBe('Invalid request parameters')
})
it.concurrent('returns 400 for validation errors', () => {
const error = new Error('Validation failed for input')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Validation failed for input')
expect(result.message).toBe('Invalid request parameters')
})
it.concurrent('returns 500 for generic errors', () => {
const error = new Error('Something went wrong')
const result = categorizeError(error)
expect(result.status).toBe(500)
expect(result.message).toBe('Something went wrong')
expect(result.message).toBe('Internal server error')
})
it.concurrent('returns 500 for non-Error objects', () => {

View File

@@ -49,18 +49,18 @@ export const MCP_CLIENT_CONSTANTS = {
} as const
/**
* Create standardized MCP error response
* Create standardized MCP error response.
* Always returns the defaultMessage to clients to prevent leaking internal error details.
* Callers are responsible for logging the original error before calling this function.
*/
export function createMcpErrorResponse(
error: unknown,
_error: unknown,
defaultMessage: string,
status = 500
): NextResponse {
const errorMessage = error instanceof Error ? error.message : defaultMessage
const response: McpApiResponse = {
success: false,
error: errorMessage,
error: defaultMessage,
}
return NextResponse.json(response, { status })
@@ -115,36 +115,33 @@ export function validateRequiredFields(
}
/**
* Enhanced error categorization for more specific HTTP status codes
* Enhanced error categorization for more specific HTTP status codes.
* Returns safe, generic messages to prevent leaking internal details.
*/
export function categorizeError(error: unknown): { message: string; status: number } {
if (!(error instanceof Error)) {
return { message: 'Unknown error occurred', status: 500 }
}
const message = error.message.toLowerCase()
const msg = error.message.toLowerCase()
if (message.includes('timeout')) {
if (msg.includes('timeout')) {
return { message: 'Request timed out', status: 408 }
}
if (message.includes('not found') || message.includes('not accessible')) {
return { message: error.message, status: 404 }
if (msg.includes('not found') || msg.includes('not accessible')) {
return { message: 'Resource not found', status: 404 }
}
if (message.includes('authentication') || message.includes('unauthorized')) {
if (msg.includes('authentication') || msg.includes('unauthorized')) {
return { message: 'Authentication required', status: 401 }
}
if (
message.includes('invalid') ||
message.includes('missing required') ||
message.includes('validation')
) {
return { message: error.message, status: 400 }
if (msg.includes('invalid') || msg.includes('missing required') || msg.includes('validation')) {
return { message: 'Invalid request parameters', status: 400 }
}
return { message: error.message, status: 500 }
return { message: 'Internal server error', status: 500 }
}
/**

View File

@@ -1,16 +1,10 @@
/**
* Periodic memory telemetry for diagnosing heap growth in production.
* Logs process.memoryUsage(), V8 heap stats, and active SSE connection
* counts every 60s, enabling correlation between connection leaks and
* memory spikes.
* Periodic memory telemetry for monitoring heap growth in production.
* Logs process.memoryUsage() and V8 heap stats every 60s.
*/
import v8 from 'node:v8'
import { createLogger } from '@sim/logger'
import {
getActiveSSEConnectionCount,
getActiveSSEConnectionsByRoute,
} from '@/lib/monitoring/sse-connections'
const logger = createLogger('MemoryTelemetry', { logLevel: 'INFO' })
@@ -23,16 +17,6 @@ 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()
@@ -49,8 +33,6 @@ export function startMemoryTelemetry(intervalMs = 60_000) {
? process.getActiveResourcesInfo().length
: -1,
uptimeMin: Math.round(process.uptime() / 60),
activeSSEConnections: getActiveSSEConnectionCount(),
sseByRoute: getActiveSSEConnectionsByRoute(),
})
}, intervalMs)
timer.unref()

View File

@@ -1,27 +0,0 @@
/**
* Tracks active SSE connections by route for memory leak diagnostics.
* Logged alongside periodic memory telemetry to correlate connection
* counts with heap growth.
*/
const connections = new Map<string, number>()
export function incrementSSEConnections(route: string) {
connections.set(route, (connections.get(route) ?? 0) + 1)
}
export function decrementSSEConnections(route: string) {
const count = (connections.get(route) ?? 0) - 1
if (count <= 0) connections.delete(route)
else connections.set(route, count)
}
export function getActiveSSEConnectionCount(): number {
let total = 0
for (const count of connections.values()) total += count
return total
}
export function getActiveSSEConnectionsByRoute(): Record<string, number> {
return Object.fromEntries(connections)
}

View File

@@ -170,11 +170,6 @@ describe('OAuth Token Refresh', () => {
describe('Body Credential Providers', () => {
const bodyCredentialProviders = [
{ name: 'Google', providerId: 'google', endpoint: 'https://oauth2.googleapis.com/token' },
{
name: 'GitHub',
providerId: 'github',
endpoint: 'https://github.com/login/oauth/access_token',
},
{
name: 'Microsoft',
providerId: 'microsoft',
@@ -279,19 +274,6 @@ describe('OAuth Token Refresh', () => {
)
})
it.concurrent('should include Accept header for GitHub requests', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
await withMockFetch(mockFetch, () => refreshOAuthToken('github', refreshToken))
const [, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
expect(requestOptions.headers.Accept).toBe('application/json')
})
it.concurrent('should include User-Agent header for Reddit requests', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'

View File

@@ -6,7 +6,6 @@ import {
CalComIcon,
ConfluenceIcon,
DropboxIcon,
GithubIcon,
GmailIcon,
GoogleBigQueryIcon,
GoogleCalendarIcon,
@@ -340,21 +339,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'outlook',
},
github: {
name: 'GitHub',
icon: GithubIcon,
services: {
github: {
name: 'GitHub',
description: 'Manage repositories, issues, and pull requests.',
providerId: 'github-repo',
icon: GithubIcon,
baseProviderIcon: GithubIcon,
scopes: ['repo', 'user:email', 'read:user', 'workflow'],
},
},
defaultService: 'github',
},
x: {
name: 'X',
icon: xIcon,
@@ -474,6 +458,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'read:comment:jira',
'delete:comment:jira',
'read:attachment:jira',
'write:attachment:jira',
'delete:attachment:jira',
'write:issue-worklog:jira',
'read:issue-worklog:jira',
@@ -639,6 +624,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'im:history',
'im:read',
'users:read',
// TODO: Add 'users:read.email' once Slack app review is approved
'files:write',
'files:read',
'canvases:write',
@@ -987,19 +973,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
useBasicAuth: false,
}
}
case 'github': {
const { clientId, clientSecret } = getCredentials(
env.GITHUB_CLIENT_ID,
env.GITHUB_CLIENT_SECRET
)
return {
tokenEndpoint: 'https://github.com/login/oauth/access_token',
clientId,
clientSecret,
useBasicAuth: false,
additionalHeaders: { Accept: 'application/json' },
}
}
case 'x': {
const { clientId, clientSecret } = getCredentials(env.X_CLIENT_ID, env.X_CLIENT_SECRET)
return {

View File

@@ -15,8 +15,6 @@ export type OAuthProvider =
| 'google-groups'
| 'google-meet'
| 'vertex-ai'
| 'github'
| 'github-repo'
| 'x'
| 'confluence'
| 'airtable'
@@ -64,7 +62,6 @@ export type OAuthService =
| 'google-groups'
| 'google-meet'
| 'vertex-ai'
| 'github'
| 'x'
| 'confluence'
| 'airtable'

View File

@@ -66,11 +66,6 @@ describe('getAllOAuthServices', () => {
it.concurrent('should include single-service providers', () => {
const services = getAllOAuthServices()
const githubService = services.find((s) => s.providerId === 'github-repo')
expect(githubService).toBeDefined()
expect(githubService?.name).toBe('GitHub')
expect(githubService?.baseProvider).toBe('github')
const slackService = services.find((s) => s.providerId === 'slack')
expect(slackService).toBeDefined()
expect(slackService?.name).toBe('Slack')
@@ -145,14 +140,6 @@ describe('getServiceByProviderAndId', () => {
expect(service.name).toBe('Microsoft Excel')
})
it.concurrent('should work with single-service providers', () => {
const service = getServiceByProviderAndId('github')
expect(service).toBeDefined()
expect(service.providerId).toBe('github-repo')
expect(service.name).toBe('GitHub')
})
it.concurrent('should include scopes in returned service config', () => {
const service = getServiceByProviderAndId('google', 'gmail')
@@ -182,12 +169,6 @@ describe('getProviderIdFromServiceId', () => {
expect(providerId).toBe('outlook')
})
it.concurrent('should return correct providerId for GitHub', () => {
const providerId = getProviderIdFromServiceId('github')
expect(providerId).toBe('github-repo')
})
it.concurrent('should return correct providerId for Microsoft Excel', () => {
const providerId = getProviderIdFromServiceId('microsoft-excel')
@@ -262,14 +243,6 @@ describe('getServiceConfigByProviderId', () => {
expect(excelService?.name).toBe('Microsoft Excel')
})
it.concurrent('should work for GitHub', () => {
const service = getServiceConfigByProviderId('github-repo')
expect(service).toBeDefined()
expect(service?.providerId).toBe('github-repo')
expect(service?.name).toBe('GitHub')
})
it.concurrent('should work for Slack', () => {
const service = getServiceConfigByProviderId('slack')
@@ -338,14 +311,6 @@ describe('getCanonicalScopesForProvider', () => {
expect(excelScopes).toContain('Files.Read')
})
it.concurrent('should return scopes for GitHub', () => {
const scopes = getCanonicalScopesForProvider('github-repo')
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('repo')
expect(scopes).toContain('user:email')
})
it.concurrent('should handle providers with empty scopes array', () => {
const scopes = getCanonicalScopesForProvider('notion')
@@ -397,13 +362,6 @@ describe('parseProvider', () => {
expect(teamsConfig.featureType).toBe('microsoft-teams')
})
it.concurrent('should parse GitHub provider', () => {
const config = parseProvider('github-repo' as OAuthProvider)
expect(config.baseProvider).toBe('github')
expect(config.featureType).toBe('github')
})
it.concurrent('should parse Slack provider', () => {
const config = parseProvider('slack' as OAuthProvider)

View File

@@ -157,6 +157,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
'read:comment:jira': 'Read comments on Jira issues',
'delete:comment:jira': 'Delete comments from Jira issues',
'read:attachment:jira': 'Read attachments from Jira issues',
'write:attachment:jira': 'Add attachments to Jira issues',
'delete:attachment:jira': 'Delete attachments from Jira issues',
'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues',
'read:issue-worklog:jira': 'Read worklog entries from Jira issues',
@@ -269,6 +270,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
'im:history': 'Read direct message history',
'im:read': 'View direct message channels',
'users:read': 'View workspace users',
'users:read.email': 'View user email addresses',
'files:write': 'Upload files',
'files:read': 'Download and read files',
'canvases:write': 'Create canvas documents',

View File

@@ -1,12 +1,13 @@
import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db'
import { account, credentialSet, subscription } from '@sim/db/schema'
import { credentialSet, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { isProd } from '@/lib/core/config/feature-flags'
import { safeCompare } from '@/lib/core/security/encryption'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
@@ -25,11 +26,10 @@ import {
validateTypeformSignature,
verifyProviderWebhook,
} from '@/lib/webhooks/utils.server'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { executeWebhookJob } from '@/background/webhook-execution'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { isConfluencePayloadMatch } from '@/triggers/confluence/utils'
import { isPollingWebhookProvider } from '@/triggers/constants'
import { isGitHubEventMatch } from '@/triggers/github/utils'
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
import { isJiraEventMatch } from '@/triggers/jira/utils'
@@ -40,6 +40,12 @@ export interface WebhookProcessorOptions {
requestId: string
path?: string
webhookId?: string
actorUserId?: string
}
export interface WebhookPreprocessingResult {
error: NextResponse | null
actorUserId?: string
}
function getExternalUrl(request: NextRequest): string {
@@ -800,14 +806,14 @@ export async function verifyProviderAuth(
if (secretHeaderName) {
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
if (headerValue === configToken) {
if (headerValue && safeCompare(headerValue, configToken)) {
isTokenValid = true
}
} else {
const authHeader = request.headers.get('authorization')
if (authHeader?.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7)
if (token === configToken) {
if (safeCompare(token, configToken)) {
isTokenValid = true
}
}
@@ -835,7 +841,7 @@ export async function checkWebhookPreprocessing(
foundWorkflow: any,
foundWebhook: any,
requestId: string
): Promise<NextResponse | null> {
): Promise<WebhookPreprocessingResult> {
try {
const executionId = uuidv4()
@@ -848,6 +854,7 @@ export async function checkWebhookPreprocessing(
checkRateLimit: true,
checkDeployment: true,
workspaceId: foundWorkflow.workspaceId,
workflowRecord: foundWorkflow,
})
if (!preprocessResult.success) {
@@ -859,33 +866,39 @@ export async function checkWebhookPreprocessing(
})
if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
{
type: 'message',
text: error.message,
},
{ status: error.statusCode }
)
return {
error: NextResponse.json(
{
type: 'message',
text: error.message,
},
{ status: error.statusCode }
),
}
}
return NextResponse.json({ error: error.message }, { status: error.statusCode })
return { error: NextResponse.json({ error: error.message }, { status: error.statusCode }) }
}
return null
return { error: null, actorUserId: preprocessResult.actorUserId }
} catch (preprocessError) {
logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError)
if (foundWebhook.provider === 'microsoft-teams') {
return NextResponse.json(
{
type: 'message',
text: 'Internal error during preprocessing',
},
{ status: 500 }
)
return {
error: NextResponse.json(
{
type: 'message',
text: 'Internal error during preprocessing',
},
{ status: 500 }
),
}
}
return NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 })
return {
error: NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 }),
}
}
}
@@ -1037,7 +1050,7 @@ export async function queueWebhookExecution(
}
}
const headers = Object.fromEntries(request.headers.entries())
const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries())
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
if (
@@ -1055,26 +1068,22 @@ export async function queueWebhookExecution(
}
}
// Extract credentialId from webhook config
// Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const credentialId = providerConfig.credentialId as string | undefined
let credentialAccountUserId: string | undefined
if (credentialId) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.error(
`[${options.requestId}] Failed to resolve OAuth account for credential ${credentialId}`
)
return formatProviderErrorResponse(foundWebhook, 'Failed to resolve credential', 500)
if (foundWebhook.provider === 'generic') {
const idempotencyField = providerConfig.idempotencyField as string | undefined
if (idempotencyField && body) {
const value = idempotencyField
.split('.')
.reduce((acc: any, key: string) => acc?.[key], body)
if (value !== undefined && value !== null && typeof value !== 'object') {
headers['x-sim-idempotency-key'] = String(value)
}
}
const [credentialRecord] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
credentialAccountUserId = credentialRecord?.userId
}
const credentialId = providerConfig.credentialId as string | undefined
// credentialSetId is a direct field on webhook table, not in providerConfig
const credentialSetId = foundWebhook.credentialSetId as string | undefined
@@ -1089,16 +1098,9 @@ export async function queueWebhookExecution(
}
}
if (!foundWorkflow.workspaceId) {
logger.error(`[${options.requestId}] Workflow ${foundWorkflow.id} has no workspaceId`)
return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
}
const actorUserId = await getWorkspaceBilledAccountUserId(foundWorkflow.workspaceId)
const actorUserId = options.actorUserId
if (!actorUserId) {
logger.error(
`[${options.requestId}] No billing account for workspace ${foundWorkflow.workspaceId}`
)
logger.error(`[${options.requestId}] No actorUserId provided for webhook ${foundWebhook.id}`)
return NextResponse.json({ error: 'Unable to resolve billing account' }, { status: 500 })
}
@@ -1111,19 +1113,28 @@ export async function queueWebhookExecution(
headers,
path: options.path || foundWebhook.path,
blockId: foundWebhook.blockId,
workspaceId: foundWorkflow.workspaceId,
...(credentialId ? { credentialId } : {}),
...(credentialAccountUserId ? { credentialAccountUserId } : {}),
}
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
})
logger.info(
`[${options.requestId}] Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook`
)
const isPolling = isPollingWebhookProvider(payload.provider)
if (shouldExecuteInline()) {
if (isPolling && !shouldExecuteInline()) {
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
})
logger.info(
`[${options.requestId}] Queued polling webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue`
)
} else {
const jobQueue = await getInlineJobQueue()
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
})
logger.info(
`[${options.requestId}] Executing ${foundWebhook.provider} webhook ${jobId} inline`
)
void (async () => {
try {
await jobQueue.startJob(jobId)
@@ -1166,6 +1177,12 @@ export async function queueWebhookExecution(
})
}
// Slack requires an empty 200 for interactive payloads (view_submission, block_actions, etc.)
// A JSON body like {"message":"..."} is not a recognized response format and causes modal errors
if (foundWebhook.provider === 'slack') {
return new NextResponse(null, { status: 200 })
}
// Twilio Voice requires TwiML XML response
if (foundWebhook.provider === 'twilio_voice') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
@@ -1197,6 +1214,26 @@ export async function queueWebhookExecution(
})
}
if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') {
const rawCode = Number(providerConfig.responseStatusCode) || 200
const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200
const responseBody = (providerConfig.responseBody as string | undefined)?.trim()
if (!responseBody) {
return new NextResponse(null, { status: statusCode })
}
try {
const parsed = JSON.parse(responseBody)
return NextResponse.json(parsed, { status: statusCode })
} catch {
return new NextResponse(responseBody, {
status: statusCode,
headers: { 'Content-Type': 'text/plain' },
})
}
}
return NextResponse.json({ message: 'Webhook processed' })
} catch (error: any) {
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)
@@ -1211,6 +1248,12 @@ export async function queueWebhookExecution(
)
}
if (foundWebhook.provider === 'slack') {
// Return empty 200 to avoid Slack showing an error dialog to the user,
// even though processing failed. The error is already logged above.
return new NextResponse(null, { status: 200 })
}
if (foundWebhook.provider === 'twilio_voice') {
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>

View File

@@ -17,6 +17,7 @@ const airtableLogger = createLogger('AirtableWebhook')
const typeformLogger = createLogger('TypeformWebhook')
const calendlyLogger = createLogger('CalendlyWebhook')
const grainLogger = createLogger('GrainWebhook')
const fathomLogger = createLogger('FathomWebhook')
const lemlistLogger = createLogger('LemlistWebhook')
const webflowLogger = createLogger('WebflowWebhook')
const attioLogger = createLogger('AttioWebhook')
@@ -792,6 +793,60 @@ export async function deleteGrainWebhook(webhook: any, requestId: string): Promi
}
}
/**
* Delete a Fathom webhook
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteFathomWebhook(webhook: any, requestId: string): Promise<void> {
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
fathomLogger.warn(
`[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
if (!externalId) {
fathomLogger.warn(
`[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100)
if (!idValidation.isValid) {
fathomLogger.warn(
`[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}`
const fathomResponse = await fetch(fathomApiUrl, {
method: 'DELETE',
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
},
})
if (!fathomResponse.ok && fathomResponse.status !== 404) {
fathomLogger.warn(
`[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}`
)
} else {
fathomLogger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`)
}
} catch (error) {
fathomLogger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error)
}
}
/**
* Delete a Lemlist webhook
* Don't fail webhook deletion if cleanup fails
@@ -1314,6 +1369,116 @@ export async function createGrainWebhookSubscription(
}
}
export async function createFathomWebhookSubscription(
_request: NextRequest,
webhookData: any,
requestId: string
): Promise<{ id: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const {
apiKey,
triggerId,
triggeredFor,
includeSummary,
includeTranscript,
includeActionItems,
includeCrmMatches,
} = providerConfig || {}
if (!apiKey) {
fathomLogger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Fathom API Key is required. Please provide your API key in the trigger configuration.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const triggeredForValue = triggeredFor || 'my_recordings'
const toBool = (val: unknown, fallback: boolean): boolean => {
if (val === undefined) return fallback
return val === true || val === 'true'
}
const requestBody: Record<string, any> = {
destination_url: notificationUrl,
triggered_for: [triggeredForValue],
include_summary: toBool(includeSummary, true),
include_transcript: toBool(includeTranscript, false),
include_action_items: toBool(includeActionItems, false),
include_crm_matches: toBool(includeCrmMatches, false),
}
fathomLogger.info(`[${requestId}] Creating Fathom webhook`, {
triggerId,
triggeredFor: triggeredForValue,
webhookId: webhookData.id,
})
const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', {
method: 'POST',
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const responseBody = await fathomResponse.json().catch(() => ({}))
if (!fathomResponse.ok) {
const errorMessage =
(responseBody as Record<string, string>).message ||
(responseBody as Record<string, string>).error ||
'Unknown Fathom API error'
fathomLogger.error(
`[${requestId}] Failed to create webhook in Fathom for webhook ${webhookData.id}. Status: ${fathomResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Fathom'
if (fathomResponse.status === 401) {
userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.'
} else if (fathomResponse.status === 400) {
userFriendlyMessage = `Fathom error: ${errorMessage}`
} else if (errorMessage && errorMessage !== 'Unknown Fathom API error') {
userFriendlyMessage = `Fathom error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
if (!responseBody.id) {
fathomLogger.error(
`[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhookData.id}.`
)
throw new Error('Fathom webhook created but no ID returned. Please try again.')
}
fathomLogger.info(
`[${requestId}] Successfully created webhook in Fathom for webhook ${webhookData.id}.`,
{
fathomWebhookId: responseBody.id,
}
)
return { id: responseBody.id }
} catch (error: any) {
fathomLogger.error(
`[${requestId}] Exception during Fathom webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}
export async function createLemlistWebhookSubscription(
webhookData: any,
requestId: string
@@ -1811,6 +1976,7 @@ const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([
'airtable',
'attio',
'calendly',
'fathom',
'webflow',
'typeform',
'grain',
@@ -1923,6 +2089,12 @@ export async function createExternalWebhookSubscription(
updatedProviderConfig = { ...updatedProviderConfig, webhookTag: usedTag }
}
externalSubscriptionCreated = true
} else if (provider === 'fathom') {
const result = await createFathomWebhookSubscription(request, webhookData, requestId)
if (result) {
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
externalSubscriptionCreated = true
}
} else if (provider === 'grain') {
const result = await createGrainWebhookSubscription(request, webhookData, requestId)
if (result) {
@@ -1968,6 +2140,8 @@ export async function cleanupExternalWebhook(
await deleteCalendlyWebhook(webhook, requestId)
} else if (webhook.provider === 'webflow') {
await deleteWebflowWebhook(webhook, workflow, requestId)
} else if (webhook.provider === 'fathom') {
await deleteFathomWebhook(webhook, requestId)
} else if (webhook.provider === 'grain') {
await deleteGrainWebhook(webhook, requestId)
} else if (webhook.provider === 'lemlist') {

View File

@@ -19,6 +19,7 @@ import {
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { isPollingWebhookProvider } from '@/triggers/constants'
const logger = createLogger('WebhookUtils')
@@ -2222,10 +2223,7 @@ export async function syncWebhooksForCredentialSet(params: {
`[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}`
)
// Polling providers get unique paths per credential (for independent state)
// External webhook providers share the same path (external service sends to one URL)
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
const useUniquePaths = pollingProviders.includes(provider)
const useUniquePaths = isPollingWebhookProvider(provider)
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)

View File

@@ -433,7 +433,7 @@ describe('hasWorkflowChanged', () => {
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should detect subBlock type changes', () => {
it.concurrent('should ignore subBlock type changes', () => {
const state1 = createWorkflowState({
blocks: {
block1: createBlock('block1', {
@@ -448,7 +448,7 @@ describe('hasWorkflowChanged', () => {
}),
},
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
it.concurrent('should handle null/undefined subBlock values consistently', () => {

Some files were not shown because too many files have changed in this diff Show More