diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 58d2124d7b3..00db61dd498 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1742,6 +1742,21 @@ export function AmplitudeIcon(props: SVGProps) { ) } +export function GoogleAppsheetIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function GoogleBooksIcon(props: SVGProps) { return ( @@ -7985,18 +8000,16 @@ export function LeadMagicIcon(props: SVGProps) { ) } -/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */ +/** Dropcontact brand icon: the teal swirl mark from the official wordmark. */ export function DropcontactIcon(props: SVGProps) { return ( - - + - ) } diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 243cb5ef066..f5f3a71a108 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -78,6 +78,7 @@ import { GmailIcon, GongIcon, GoogleAdsIcon, + GoogleAppsheetIcon, GoogleBigQueryIcon, GoogleBooksIcon, GoogleCalendarIcon, @@ -321,6 +322,7 @@ export const blockTypeToIconMap: Record = { gmail_v2: GmailIcon, gong: GongIcon, google_ads: GoogleAdsIcon, + google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, google_books: GoogleBooksIcon, google_calendar: GoogleCalendarIcon, diff --git a/apps/docs/content/docs/en/integrations/ahrefs.mdx b/apps/docs/content/docs/en/integrations/ahrefs.mdx index c05bf0c8ebd..92d278e9fab 100644 --- a/apps/docs/content/docs/en/integrations/ahrefs.mdx +++ b/apps/docs/content/docs/en/integrations/ahrefs.mdx @@ -52,6 +52,34 @@ Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating sh | `domainRating` | number | Domain Rating score \(0-100\) | | `ahrefsRank` | number | Ahrefs Rank - global ranking based on backlink profile strength | +### `ahrefs_metrics` + +Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `metrics` | object | Organic and paid search overview | +| ↳ `organicTraffic` | number | Estimated monthly organic traffic | +| ↳ `organicKeywords` | number | Number of organic keywords ranked | +| ↳ `organicKeywordsTop3` | number | Number of organic keywords ranking in positions 1-3 | +| ↳ `organicCost` | number | Estimated monthly cost to replicate organic traffic via ads \(USD\) | +| ↳ `paidTraffic` | number | Estimated monthly paid search traffic | +| ↳ `paidKeywords` | number | Number of paid keywords targeted | +| ↳ `paidPages` | number | Number of pages receiving paid traffic | +| ↳ `paidCost` | number | Estimated monthly paid search spend \(USD\) | + ### `ahrefs_backlinks` Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating. @@ -61,10 +89,9 @@ Get a list of backlinks pointing to a target domain or URL. Returns details abou | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `history` | string | No | Historical scope: "live" \(currently live backlinks\), "all_time" \(default, includes lost backlinks\), or "since:YYYY-MM-DD" \(backlinks found since a date\). | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -82,28 +109,26 @@ Get a list of backlinks pointing to a target domain or URL. Returns details abou ### `ahrefs_backlinks_stats` -Get backlink statistics for a target domain or URL. Returns totals for different backlink types including dofollow, nofollow, text, image, and redirect links. +Get backlink and referring domain totals for a target domain or URL, both currently live and across all time. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `stats` | object | Backlink statistics summary | -| ↳ `total` | number | Total number of live backlinks | -| ↳ `dofollow` | number | Number of dofollow backlinks | -| ↳ `nofollow` | number | Number of nofollow backlinks | -| ↳ `text` | number | Number of text backlinks | -| ↳ `image` | number | Number of image backlinks | -| ↳ `redirect` | number | Number of redirect backlinks | +| `stats` | object | Backlink and referring domain totals | +| ↳ `liveBacklinks` | number | Number of currently live backlinks | +| ↳ `liveReferringDomains` | number | Number of currently live referring domains | +| ↳ `allTimeBacklinks` | number | Total backlinks ever discovered, including lost ones | +| ↳ `allTimeReferringDomains` | number | Total referring domains ever discovered, including lost ones | ### `ahrefs_referring_domains` @@ -114,10 +139,9 @@ Get a list of domains that link to a target domain or URL. Returns unique referr | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `history` | string | No | Historical scope: "live" \(currently live\), "all_time" \(default, includes lost domains\), or "since:YYYY-MM-DD" \(domains found since a date\). | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -127,10 +151,34 @@ Get a list of domains that link to a target domain or URL. Returns unique referr | `referringDomains` | array | List of domains linking to the target | | ↳ `domain` | string | The referring domain | | ↳ `domainRating` | number | Domain Rating of the referring domain | -| ↳ `backlinks` | number | Total number of backlinks from this domain | +| ↳ `backlinks` | number | Total number of backlinks from this domain to the target | | ↳ `dofollowBacklinks` | number | Number of dofollow backlinks from this domain | | ↳ `firstSeen` | string | When the domain was first seen linking | -| ↳ `lastVisited` | string | When the domain was last checked | +| ↳ `lastVisited` | string | When the domain was last seen linking \(null if never re-crawled\) | + +### `ahrefs_broken_backlinks` + +Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `brokenBacklinks` | array | List of broken backlinks | +| ↳ `urlFrom` | string | The URL of the page containing the broken link | +| ↳ `urlTo` | string | The broken URL being linked to | +| ↳ `httpCode` | number | HTTP status code of the broken target URL \(e.g., 404, 410\) | +| ↳ `anchor` | string | The anchor text of the link | +| ↳ `domainRatingSource` | number | Domain Rating of the linking domain | ### `ahrefs_organic_keywords` @@ -142,10 +190,9 @@ Get organic keywords that a target domain or URL ranks for in Google search resu | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | | `country` | string | No | Country code for search results. Example: "us", "gb", "de" \(default: "us"\) | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -155,11 +202,38 @@ Get organic keywords that a target domain or URL ranks for in Google search resu | `keywords` | array | List of organic keywords the target ranks for | | ↳ `keyword` | string | The keyword | | ↳ `volume` | number | Monthly search volume | -| ↳ `position` | number | Current ranking position | -| ↳ `url` | string | The URL that ranks for this keyword | +| ↳ `position` | number | Best ranking position for this keyword | +| ↳ `url` | string | The URL that ranks at the best position for this keyword | | ↳ `traffic` | number | Estimated monthly organic traffic | | ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +### `ahrefs_organic_competitors` + +Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `country` | string | No | Country code for search results. Example: "us", "gb", "de" \(default: "us"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `competitors` | array | List of organic search competitors ranked by keyword overlap | +| ↳ `domain` | string | The competitor domain | +| ↳ `domainRating` | number | Domain Rating of the competitor | +| ↳ `commonKeywords` | number | Number of keywords the competitor and target both rank for | +| ↳ `targetKeywords` | number | Number of keywords the target ranks for | +| ↳ `competitorKeywords` | number | Number of keywords the competitor ranks for | +| ↳ `traffic` | number | Estimated monthly organic traffic for the competitor | + ### `ahrefs_top_pages` Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value. @@ -170,11 +244,9 @@ Get the top pages of a target domain sorted by organic traffic. Returns page URL | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain to analyze. Example: "example.com" | | `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | -| `select` | string | No | Comma-separated list of fields to return \(e.g., url,traffic,keywords,top_keyword,value\). Default: url,traffic,keywords,top_keyword,value | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -210,34 +282,15 @@ Get detailed metrics for a keyword including search volume, keyword difficulty, | ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | | ↳ `cpc` | number | Cost per click in USD | | ↳ `clicks` | number | Estimated clicks per month | -| ↳ `clicksPercentage` | number | Percentage of searches that result in clicks | +| ↳ `clicksPercentage` | number | Percentage of searches that result in an organic click | | ↳ `parentTopic` | string | The parent topic for this keyword | | ↳ `trafficPotential` | number | Estimated traffic potential if ranking #1 | - -### `ahrefs_broken_backlinks` - -Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities. - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | -| `apiKey` | string | Yes | Ahrefs API Key | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `brokenBacklinks` | array | List of broken backlinks | -| ↳ `urlFrom` | string | The URL of the page containing the broken link | -| ↳ `urlTo` | string | The broken URL being linked to | -| ↳ `httpCode` | number | HTTP status code \(e.g., 404, 410\) | -| ↳ `anchor` | string | The anchor text of the link | -| ↳ `domainRatingSource` | number | Domain Rating of the linking domain | +| ↳ `intents` | object | Search intent flags \(informational, navigational, commercial, transactional, branded, local\) | +| ↳ `informational` | boolean | Query seeks information | +| ↳ `navigational` | boolean | Query seeks a specific site or page | +| ↳ `commercial` | boolean | Query researches a purchase decision | +| ↳ `transactional` | boolean | Query intends to complete a purchase | +| ↳ `branded` | boolean | Query references a specific brand | +| ↳ `local` | boolean | Query seeks local results | diff --git a/apps/docs/content/docs/en/integrations/algolia.mdx b/apps/docs/content/docs/en/integrations/algolia.mdx index 29c6725e6e7..72a4039b141 100644 --- a/apps/docs/content/docs/en/integrations/algolia.mdx +++ b/apps/docs/content/docs/en/integrations/algolia.mdx @@ -50,6 +50,12 @@ Search an Algolia index | `page` | number | No | Page number to retrieve \(default: 0\) | | `filters` | string | No | Filter string \(e.g., "category:electronics AND price < 100"\) | | `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve | +| `facets` | string | No | Comma-separated list of facet attribute names to retrieve counts for \(use "*" for all\) | +| `getRankingInfo` | boolean | No | Whether to include detailed ranking information in each hit | +| `aroundLatLng` | string | No | Coordinates for geo-search \(e.g., "40.71,-74.01"\) | +| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited | +| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search | +| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search | #### Output @@ -200,6 +206,10 @@ Browse and iterate over all records in an Algolia index using cursor pagination | `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve | | `hitsPerPage` | number | No | Number of hits per page \(default: 1000, max: 1000\) | | `cursor` | string | No | Cursor from a previous browse response for pagination | +| `aroundLatLng` | string | No | Coordinates for geo-search \(e.g., "40.71,-74.01"\) | +| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited | +| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search | +| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search | #### Output @@ -225,7 +235,7 @@ Perform batch add, update, partial update, or delete operations on records in an | `applicationId` | string | Yes | Algolia Application ID | | `apiKey` | string | Yes | Algolia Admin API Key | | `indexName` | string | Yes | Name of the Algolia index | -| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject\) and "body" \(the record data, must include objectID for update/delete\) | +| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear\) and "body" \(the record data; must include objectID for update/delete; use an empty object \{\} for the index-level delete/clear actions\) | #### Output @@ -390,7 +400,7 @@ Delete all records matching a filter from an Algolia index | `numericFilters` | json | No | Array of numeric filters \(e.g., \["price > 100"\]\) | | `tagFilters` | json | No | Array of tag filters using the _tags attribute \(e.g., \["published"\]\) | | `aroundLatLng` | string | No | Coordinates for geo-search filter \(e.g., "40.71,-74.01"\) | -| `aroundRadius` | number | No | Maximum radius in meters for geo-search, or "all" for unlimited | +| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited | | `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search filter | | `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search filter | @@ -401,4 +411,23 @@ Delete all records matching a filter from an Algolia index | `taskID` | number | Algolia task ID for tracking the delete-by-filter operation | | `updatedAt` | string | Timestamp when the operation was performed | +### `algolia_get_task_status` + +Check whether an Algolia indexing task has finished publishing + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `applicationId` | string | Yes | Algolia Application ID | +| `apiKey` | string | Yes | Algolia API Key | +| `indexName` | string | Yes | Name of the Algolia index the task ran against | +| `taskID` | number | Yes | The taskID returned by a previous write operation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | Task status: "published" once the operation has been applied, "notPublished" while still pending | + diff --git a/apps/docs/content/docs/en/integrations/amplitude.mdx b/apps/docs/content/docs/en/integrations/amplitude.mdx index 45a806b31f6..53d0b88dc78 100644 --- a/apps/docs/content/docs/en/integrations/amplitude.mdx +++ b/apps/docs/content/docs/en/integrations/amplitude.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} @@ -31,7 +31,7 @@ In Sim, the Amplitude integration enables powerful analytics automation scenario ## Usage Instructions -Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data. +Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data. @@ -64,6 +64,7 @@ Track an event in Amplitude using the HTTP V2 API. | `revenue` | string | No | Revenue amount | | `productId` | string | No | Product identifier | | `revenueType` | string | No | Revenue type \(e.g., "purchase", "refund"\) | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -86,6 +87,7 @@ Set user properties in Amplitude using the Identify API. Supports $set, $setOnce | `userId` | string | No | User ID \(required if no device_id\) | | `deviceId` | string | No | Device ID \(required if no user_id\) | | `userProperties` | string | Yes | JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset. | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -106,6 +108,7 @@ Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, | `groupType` | string | Yes | Group classification \(e.g., "company", "org_id"\) | | `groupValue` | string | Yes | Specific group identifier \(e.g., "Acme Corp"\) | | `groupProperties` | string | Yes | JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset. | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -125,6 +128,7 @@ Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard RES | `apiKey` | string | Yes | Amplitude API Key | | `secretKey` | string | Yes | Amplitude Secret Key | | `user` | string | Yes | User ID, Device ID, or Amplitude ID to search for | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -149,6 +153,7 @@ Get the event stream for a specific user by their Amplitude ID. | `offset` | string | No | Offset for pagination \(default 0\) | | `limit` | string | No | Maximum number of events to return \(default 1000, max 1000\) | | `direction` | string | No | Sort direction: "latest" or "earliest" \(default: latest\) | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -170,10 +175,12 @@ Get the event stream for a specific user by their Amplitude ID. | ↳ `numSessions` | number | Total session count | | ↳ `platform` | string | Primary platform | | ↳ `country` | string | Country | +| ↳ `firstUsed` | string | Date the user first appeared | +| ↳ `lastUsed` | string | Date of most recent user activity | ### `amplitude_user_profile` -Get a user profile including properties, cohort memberships, and computed properties. +Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects. #### Input @@ -212,7 +219,12 @@ Query event analytics data with segmentation. Get event counts, uniques, average | `metric` | string | No | Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula \(default: uniques\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | | `groupBy` | string | No | Property name to group by \(prefix custom user properties with "gp:"\) | +| `groupBy2` | string | No | Second property name to group by \(prefix custom user properties with "gp:"\) | | `limit` | string | No | Maximum number of group-by values \(max 1000\) | +| `filters` | string | No | JSON array of filter objects applied to the event, e.g. \[\{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":\["San Francisco"\]\}\] | +| `formula` | string | No | Required when metric is "formula", e.g. "UNIQUES\(A\)/UNIQUES\(B\)" | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -237,6 +249,9 @@ Get active or new user counts over a date range from the Dashboard REST API. | `end` | string | Yes | End date in YYYYMMDD format | | `metric` | string | No | Metric type: "active" or "new" \(default: active\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `groupBy` | string | No | Property name to group by | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -256,6 +271,7 @@ Get real-time active user counts at 5-minute granularity for the last 2 days. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Amplitude API Key | | `secretKey` | string | Yes | Amplitude Secret Key | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -275,6 +291,7 @@ List all event types in the Amplitude project with their weekly totals and uniqu | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Amplitude API Key | | `secretKey` | string | Yes | Amplitude Secret Key | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -286,6 +303,8 @@ List all event types in the Amplitude project with their weekly totals and uniqu | ↳ `totals` | number | Weekly total count | | ↳ `hidden` | boolean | Whether the event is hidden | | ↳ `deleted` | boolean | Whether the event is deleted | +| ↳ `nonActive` | boolean | Whether the event is excluded from active user calculations | +| ↳ `flowHidden` | boolean | Whether the event is hidden from user flow charts | ### `amplitude_get_revenue` @@ -301,13 +320,83 @@ Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user count | `end` | string | Yes | End date in YYYYMMDD format | | `metric` | string | No | Metric: 0 \(ARPU\), 1 \(ARPPU\), 2 \(Total Revenue\), 3 \(Paying Users\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `groupBy` | string | No | Property name to group by \(limit: one\) | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `series` | json | Array of revenue data series | +| `series` | array | Revenue data series \[\{dates: \[YYYY-MM-DD\], values: \{<date>: \{r1d..r90d, count, paid, total_amount\}\}\}\] | +| ↳ `dates` | array | Dates covered by this series | +| ↳ `values` | json | Per-date metric values keyed by date \(r1d..r90d, count, paid, total_amount\) | | `seriesLabels` | array | Labels for each data series | -| `xValues` | array | Date values for the x-axis | + +### `amplitude_funnels` + +Analyze conversion rates and drop-off between a sequence of events. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Amplitude API Key | +| `secretKey` | string | Yes | Amplitude Secret Key | +| `events` | string | Yes | JSON array of event objects, one per funnel step in order, e.g. \[\{"event_type":"signup"\},\{"event_type":"purchase"\}\] | +| `start` | string | Yes | Start date in YYYYMMDD format | +| `end` | string | Yes | End date in YYYYMMDD format | +| `mode` | string | No | Funnel ordering: "ordered", "unordered", or "sequential" \(default: ordered\) | +| `userType` | string | No | User type: "new" or "active" \(default: active\) | +| `interval` | string | No | Time interval: -300000 \(real-time\), -3600000 \(hourly\), 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `conversionWindowSeconds` | string | No | Conversion window in seconds \(default: 2592000, i.e. 30 days\) | +| `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) | +| `limit` | string | No | Maximum number of group-by values \(default: 100, max: 1000\) | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `funnels` | array | Funnel results, one entry per segment | +| ↳ `stepByStep` | json | Conversion count at each step | +| ↳ `cumulative` | json | Cumulative conversion percentage at each step | +| ↳ `cumulativeRaw` | json | Cumulative conversion count at each step | +| ↳ `medianTransTimes` | json | Median transition time between steps \(ms\) | +| ↳ `avgTransTimes` | json | Average transition time between steps \(ms\) | +| ↳ `events` | json | Event names for each funnel step | +| ↳ `dayFunnels` | json | Daily funnel breakdown \{series, xValues\} | + +### `amplitude_retention` + +Measure how many users return to perform an action after a starting action. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Amplitude API Key | +| `secretKey` | string | Yes | Amplitude Secret Key | +| `startEvent` | string | Yes | JSON starting event object, e.g. \{"event_type":"_new"\} or \{"event_type":"_active"\} | +| `returnEvent` | string | Yes | JSON returning event object, e.g. \{"event_type":"_all"\} or \{"event_type":"_active"\} | +| `start` | string | Yes | Start date in YYYYMMDD format | +| `end` | string | Yes | End date in YYYYMMDD format | +| `retentionMode` | string | No | Retention type: "bracket", "rolling", or "n-day" \(default: n-day\) | +| `retentionBrackets` | string | No | Required when Retention Mode is "bracket". Day ranges, e.g. \[\[0,4\]\] | +| `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `series` | array | Retention data series \[\{dates, values: \{<date>: \[\{count, outof, incomplete\}\]\}, combined: \[\{count, outof, incomplete\}\]\}\] | +| ↳ `dates` | array | Cohort dates | +| ↳ `values` | json | Per-cohort-date retention counts keyed by date | +| ↳ `combined` | json | Deduplicated aggregate retention across all cohorts | +| `seriesMeta` | array | Segment/event index metadata for each series entry | diff --git a/apps/docs/content/docs/en/integrations/brex.mdx b/apps/docs/content/docs/en/integrations/brex.mdx index 09b49faf55b..cd23dc79a51 100644 --- a/apps/docs/content/docs/en/integrations/brex.mdx +++ b/apps/docs/content/docs/en/integrations/brex.mdx @@ -724,6 +724,8 @@ List spend limits in the Brex account, optionally filtered by member user | ↳ `status` | string | Spend limit status | | ↳ `period_recurrence_type` | string | Period recurrence \(PER_WEEK, PER_MONTH, PER_QUARTER, PER_YEAR, ONE_TIME\) | | ↳ `spend_type` | string | Spend type of the limit | +| ↳ `start_date` | string | Spend limit start date | +| ↳ `end_date` | string | Spend limit end date | | ↳ `owner_user_ids` | array | User IDs of the spend limit owners | | ↳ `member_user_ids` | array | User IDs of the spend limit members | | ↳ `current_period_balance` | json | Spend and rollover amounts for the current period | @@ -737,6 +739,7 @@ List spend limits in the Brex account, optionally filtered by member user | ↳ `rollover_amount` | json | Amount rolled over from previous periods | | ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) | | ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) | +| ↳ `authorization_settings` | json | Authorization settings \(base limit, authorization type, rollover refresh\) | | `nextCursor` | string | Cursor for fetching the next page of results | ### `brex_get_spend_limit` @@ -858,6 +861,7 @@ List money transfers in the Brex account | ↳ `created_at` | string | Creation timestamp | | ↳ `display_name` | string | Transfer display name | | ↳ `external_memo` | string | External memo | +| ↳ `is_ppro_enabled` | boolean | Whether Principal Protection \(PPRO\) is enabled | | `nextCursor` | string | Cursor for fetching the next page of results | ### `brex_get_transfer` @@ -891,5 +895,6 @@ Get a Brex money transfer by its ID | `createdAt` | string | Creation timestamp | | `displayName` | string | Transfer display name | | `externalMemo` | string | External memo | +| `isPproEnabled` | boolean | Whether Principal Protection \(PPRO\) is enabled | diff --git a/apps/docs/content/docs/en/integrations/clerk.mdx b/apps/docs/content/docs/en/integrations/clerk.mdx index 7a9df1a41fd..cfeba8ac3ae 100644 --- a/apps/docs/content/docs/en/integrations/clerk.mdx +++ b/apps/docs/content/docs/en/integrations/clerk.mdx @@ -28,7 +28,7 @@ The integration enables real-time, auditable management of your user base—all ## Usage Instructions -Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions. +Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens. @@ -251,6 +251,132 @@ Delete a user from your Clerk application | `deleted` | boolean | Whether the user was deleted | | `success` | boolean | Operation success status | +### `clerk_ban_user` + +Ban a user, preventing them from signing in + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to ban \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_unban_user` + +Remove a ban from a user, allowing them to sign in again + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to unban \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_lock_user` + +Lock a user account, blocking sign-in attempts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to lock \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_unlock_user` + +Unlock a previously locked user account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to unlock \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_get_user_oauth_token` + +Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `provider` | string | Yes | OAuth provider slug, e.g. google, github, microsoft, discord \(without the oauth_ prefix\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessTokens` | array | OAuth access tokens for the connected provider | +| ↳ `externalAccountId` | string | External account ID | +| ↳ `token` | string | OAuth access token | +| ↳ `expiresAt` | number | Expiration timestamp | +| ↳ `provider` | string | OAuth provider slug | +| ↳ `label` | string | Token label | +| ↳ `scopes` | array | OAuth scopes granted to the token | +| ↳ `publicMetadata` | json | Public metadata associated with the token | +| `success` | boolean | Operation success status | + ### `clerk_list_organizations` List all organizations in your Clerk application with optional filtering @@ -352,6 +478,278 @@ Create a new organization in your Clerk application | `publicMetadata` | json | Public metadata | | `success` | boolean | Operation success status | +### `clerk_update_organization` + +Update an existing organization in your Clerk application + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization to update \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `name` | string | No | Name of the organization | +| `slug` | string | No | Slug identifier for the organization | +| `maxAllowedMemberships` | number | No | Maximum member capacity \(0 for unlimited\) | +| `adminDeleteEnabled` | boolean | No | Whether admins can delete the organization | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Organization ID | +| `name` | string | Organization name | +| `slug` | string | Organization slug | +| `imageUrl` | string | Organization image URL | +| `hasImage` | boolean | Whether organization has an image | +| `membersCount` | number | Number of members | +| `pendingInvitationsCount` | number | Number of pending invitations | +| `maxAllowedMemberships` | number | Max allowed memberships | +| `adminDeleteEnabled` | boolean | Whether admin delete is enabled | +| `createdBy` | string | Creator user ID | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `publicMetadata` | json | Public metadata | +| `success` | boolean | Operation success status | + +### `clerk_delete_organization` + +Delete an organization from your Clerk application + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization to delete \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Deleted organization ID | +| `object` | string | Object type \(organization\) | +| `deleted` | boolean | Whether the organization was deleted | +| `success` | boolean | Operation success status | + +### `clerk_list_organization_memberships` + +List members of a Clerk organization with optional filtering and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) | +| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) | +| `orderBy` | string | No | Sort field \(e.g., created_at\) with +/- prefix for direction | +| `role` | string | No | Filter by role, comma-separated for multiple \(e.g., org:admin,org:member\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberships` | array | Array of Clerk organization membership objects | +| ↳ `id` | string | Membership ID | +| ↳ `role` | string | Member role | +| ↳ `roleName` | string | Human-readable role name | +| ↳ `permissions` | array | Permissions granted by the role | +| ↳ `organizationId` | string | Organization ID | +| ↳ `userId` | string | Member user ID | +| ↳ `firstName` | string | Member first name | +| ↳ `lastName` | string | Member last name | +| ↳ `imageUrl` | string | Member profile image URL | +| ↳ `identifier` | string | Member identifier \(e.g., email\) | +| ↳ `username` | string | Member username | +| ↳ `banned` | boolean | Whether the member is banned | +| ↳ `publicMetadata` | json | Public metadata | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of memberships | +| `success` | boolean | Operation success status | + +### `clerk_add_organization_member` + +Add a user as a member of a Clerk organization with a given role + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `userId` | string | Yes | ID of the user to add as a member | +| `role` | string | Yes | Role to assign, e.g. org:admin or org:member | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Membership ID | +| `role` | string | Member role | +| `roleName` | string | Human-readable role name | +| `permissions` | array | Permissions granted by the role | +| `organizationId` | string | Organization ID | +| `userId` | string | Member user ID | +| `firstName` | string | Member first name | +| `lastName` | string | Member last name | +| `imageUrl` | string | Member profile image URL | +| `identifier` | string | Member identifier \(e.g., email\) | +| `username` | string | Member username | +| `banned` | boolean | Whether the member is banned | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_update_organization_membership` + +Change a member's role within a Clerk organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `userId` | string | Yes | ID of the member whose role is being changed | +| `role` | string | Yes | New role to assign, e.g. org:admin or org:member | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Membership ID | +| `role` | string | Member role | +| `roleName` | string | Human-readable role name | +| `permissions` | array | Permissions granted by the role | +| `organizationId` | string | Organization ID | +| `userId` | string | Member user ID | +| `firstName` | string | Member first name | +| `lastName` | string | Member last name | +| `imageUrl` | string | Member profile image URL | +| `identifier` | string | Member identifier \(e.g., email\) | +| `username` | string | Member username | +| `banned` | boolean | Whether the member is banned | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_remove_organization_member` + +Remove a member from a Clerk organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `userId` | string | Yes | ID of the member to remove | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Membership ID | +| `role` | string | Member role | +| `roleName` | string | Human-readable role name | +| `permissions` | array | Permissions granted by the role | +| `organizationId` | string | Organization ID | +| `userId` | string | Member user ID | +| `firstName` | string | Member first name | +| `lastName` | string | Member last name | +| `imageUrl` | string | Member profile image URL | +| `identifier` | string | Member identifier \(e.g., email\) | +| `username` | string | Member username | +| `banned` | boolean | Whether the member is banned | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_create_organization_invitation` + +Invite a user by email to join a Clerk organization with a given role + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `emailAddress` | string | Yes | Email address of the user to invite | +| `role` | string | Yes | Role to assign on acceptance, e.g. org:admin or org:member | +| `inviterUserId` | string | No | User ID of the inviter | +| `redirectUrl` | string | No | URL to redirect to after the invitation is accepted | +| `expiresInDays` | number | No | Days until the invitation expires \(1-365, default 30\) | +| `publicMetadata` | json | No | Public metadata \(JSON object\) | +| `privateMetadata` | json | No | Private metadata \(JSON object\) | +| `notify` | boolean | No | Whether Clerk sends the invitation email \(default true\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Invitation ID | +| `emailAddress` | string | Invited email address | +| `role` | string | Role to assign on acceptance | +| `roleName` | string | Human-readable role name | +| `organizationId` | string | Organization ID | +| `inviterId` | string | User ID of the inviter | +| `inviterEmail` | string | Inviter's email address | +| `inviterFirstName` | string | Inviter's first name | +| `inviterLastName` | string | Inviter's last name | +| `status` | string | Invitation status | +| `url` | string | Invitation URL | +| `expiresAt` | number | Expiration timestamp | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_list_organization_invitations` + +List pending and past invitations for a Clerk organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `status` | string | No | Filter by status: pending, accepted, revoked, or expired | +| `emailAddress` | string | No | Filter by invited email address | +| `orderBy` | string | No | Sort field \(created_at, email_address\) with +/- prefix \(default: -created_at\) | +| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) | +| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitations` | array | Array of Clerk organization invitation objects | +| ↳ `id` | string | Invitation ID | +| ↳ `emailAddress` | string | Invited email address | +| ↳ `role` | string | Role to assign on acceptance | +| ↳ `roleName` | string | Human-readable role name | +| ↳ `organizationId` | string | Organization ID | +| ↳ `inviterId` | string | User ID of the inviter | +| ↳ `inviterEmail` | string | Inviter's email address | +| ↳ `inviterFirstName` | string | Inviter's first name | +| ↳ `inviterLastName` | string | Inviter's last name | +| ↳ `status` | string | Invitation status | +| ↳ `url` | string | Invitation URL | +| ↳ `expiresAt` | number | Expiration timestamp | +| ↳ `publicMetadata` | json | Public metadata | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of invitations | +| `success` | boolean | Operation success status | + ### `clerk_list_sessions` List sessions for a user or client in your Clerk application @@ -439,27 +837,268 @@ Revoke a session to immediately invalidate it | `updatedAt` | number | Last update timestamp | | `success` | boolean | Operation success status | +### `clerk_list_allowlist_identifiers` +List email/phone/web3-wallet identifiers on your Clerk instance allowlist -## Triggers - -A **Trigger** is a block that starts a workflow when an event happens in this service. - -### Clerk Organization Created - -Trigger workflow when a Clerk organization is created - -#### Configuration +#### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) | +| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `type` | string | Event type \(e.g., user.created, session.created\) | +| `identifiers` | array | Array of Clerk allowlist identifier objects | +| ↳ `id` | string | Allowlist identifier ID | +| ↳ `identifier` | string | Email, phone, or web3 wallet identifier | +| ↳ `identifierType` | string | Type of identifier | +| ↳ `invitationId` | string | Associated invitation ID | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of allowlist identifiers | +| `success` | boolean | Operation success status | + +### `clerk_create_allowlist_identifier` + +Add an email, phone number, or web3 wallet to your Clerk instance allowlist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifier` | string | Yes | Email address, phone number, or web3 wallet to allow \(wildcards like *@example.com supported for email\) | +| `notify` | boolean | No | Whether to notify the identifier owner by email \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Allowlist identifier ID | +| `identifier` | string | Email, phone, or web3 wallet identifier | +| `identifierType` | string | Type of identifier | +| `invitationId` | string | Associated invitation ID | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_delete_allowlist_identifier` + +Remove an identifier from your Clerk instance allowlist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifierId` | string | Yes | ID of the allowlist identifier to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Deleted allowlist identifier ID | +| `object` | string | Object type \(allowlist_identifier\) | +| `deleted` | boolean | Whether the identifier was deleted | +| `success` | boolean | Operation success status | + +### `clerk_list_blocklist_identifiers` + +List email/phone/web3-wallet identifiers on your Clerk instance blocklist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `identifiers` | array | Array of Clerk blocklist identifier objects | +| ↳ `id` | string | Blocklist identifier ID | +| ↳ `identifier` | string | Email, phone, or web3 wallet identifier | +| ↳ `identifierType` | string | Type of identifier | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of blocklist identifiers | +| `success` | boolean | Operation success status | + +### `clerk_create_blocklist_identifier` + +Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifier` | string | Yes | Email address, phone number, or web3 wallet to block | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Blocklist identifier ID | +| `identifier` | string | Email, phone, or web3 wallet identifier | +| `identifierType` | string | Type of identifier | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_delete_blocklist_identifier` + +Remove an identifier from your Clerk instance blocklist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifierId` | string | Yes | ID of the blocklist identifier to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Deleted blocklist identifier ID | +| `object` | string | Object type \(blocklist_identifier\) | +| `deleted` | boolean | Whether the identifier was deleted | +| `success` | boolean | Operation success status | + +### `clerk_list_jwt_templates` + +List custom JWT templates configured on your Clerk instance + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `templates` | array | Array of Clerk JWT template objects | +| ↳ `id` | string | JWT template ID | +| ↳ `name` | string | JWT template name | +| ↳ `claims` | json | Custom claims defined on the template | +| ↳ `lifetime` | number | Token lifetime in seconds | +| ↳ `allowedClockSkew` | number | Allowed clock skew in seconds | +| ↳ `customSigningKey` | boolean | Whether a custom signing key is configured | +| ↳ `signingAlgorithm` | string | Signing algorithm used | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of JWT templates | +| `success` | boolean | Operation success status | + +### `clerk_get_jwt_template` + +Retrieve a single custom JWT template by ID from Clerk + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `templateId` | string | Yes | ID of the JWT template to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | JWT template ID | +| `name` | string | JWT template name | +| `claims` | json | Custom claims defined on the template | +| `lifetime` | number | Token lifetime in seconds | +| `allowedClockSkew` | number | Allowed clock skew in seconds | +| `customSigningKey` | boolean | Whether a custom signing key is configured | +| `signingAlgorithm` | string | Signing algorithm used | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_create_actor_token` + +Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | ID of the user to impersonate | +| `actor` | json | Yes | Actor JSON object identifying who is impersonating, must include a "sub" field, e.g. \{"sub": "user_support_agent_id"\} | +| `expiresInSeconds` | number | No | Seconds until the token expires \(default 3600\) | +| `sessionMaxDurationInSeconds` | number | No | Max duration in seconds for sessions created with this token \(default 1800\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Actor token ID | +| `status` | string | Actor token status | +| `userId` | string | ID of the impersonated user | +| `actor` | json | Actor object identifying who is impersonating | +| `token` | string | Signed actor token \(JWT\) | +| `url` | string | Sign-in URL for the actor token | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_revoke_actor_token` + +Revoke an actor token before it is used or expires + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `actorTokenId` | string | Yes | ID of the actor token to revoke | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Actor token ID | +| `status` | string | Actor token status \(should be revoked\) | +| `userId` | string | ID of the impersonated user | +| `actor` | json | Actor object identifying who is impersonating | +| `token` | string | Signed actor token \(JWT\) | +| `url` | string | Sign-in URL for the actor token | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + + + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Clerk Organization Created + +Trigger workflow when a Clerk organization is created + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | | `object` | string | Always "event" | | `timestamp` | number | Timestamp in milliseconds when the event occurred | | `instance_id` | string | Identifier of your Clerk instance | @@ -473,6 +1112,31 @@ Trigger workflow when a Clerk organization is created | `createdAt` | number | Organization creation timestamp \(data.created_at\) | +--- + +### Clerk Organization Deleted + +Trigger workflow when a Clerk organization is deleted + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `organizationId` | string | Deleted Clerk organization ID \(data.id\) | +| `deleted` | boolean | Whether the organization was deleted \(data.deleted\) | + + --- ### Clerk Organization Membership Created @@ -501,6 +1165,89 @@ Trigger workflow when a Clerk organization membership is created | `createdAt` | number | Membership creation timestamp \(data.created_at\) | +--- + +### Clerk Organization Membership Deleted + +Trigger workflow when a Clerk organization membership is deleted + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `membershipId` | string | Deleted membership ID \(data.id\) | +| `deleted` | boolean | Whether the membership was deleted \(data.deleted\) | + + +--- + +### Clerk Organization Membership Updated + +Trigger workflow when a Clerk organization membership is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `membershipId` | string | Membership ID \(data.id\) | +| `role` | string | Membership role, e.g. org:admin \(data.role\) | +| `organizationId` | string | Organization ID \(data.organization.id\) | +| `userId` | string | User ID of the member \(data.public_user_data.user_id\) | +| `createdAt` | number | Membership creation timestamp \(data.created_at\) | + + +--- + +### Clerk Organization Updated + +Trigger workflow when a Clerk organization is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `organizationId` | string | Clerk organization ID \(data.id\) | +| `name` | string | Organization name \(data.name\) | +| `slug` | string | Organization slug \(data.slug\) | +| `createdBy` | string | User ID of the creator \(data.created_by\) | +| `membersCount` | number | Number of members \(data.members_count\) | +| `maxAllowedMemberships` | number | Maximum allowed memberships \(data.max_allowed_memberships\) | +| `createdAt` | number | Organization creation timestamp \(data.created_at\) | + + --- ### Clerk Session Created @@ -529,6 +1276,90 @@ Trigger workflow when a Clerk session is created | `createdAt` | number | Session creation timestamp \(data.created_at\) | +--- + +### Clerk Session Ended + +Trigger workflow when a Clerk session ends + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `sessionId` | string | Clerk session ID \(data.id\) | +| `userId` | string | User the session belongs to \(data.user_id\) | +| `clientId` | string | Client ID for the session \(data.client_id\) | +| `status` | string | Session status \(data.status\) | +| `createdAt` | number | Session creation timestamp \(data.created_at\) | + + +--- + +### Clerk Session Removed + +Trigger workflow when a Clerk session is removed + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `sessionId` | string | Clerk session ID \(data.id\) | +| `userId` | string | User the session belongs to \(data.user_id\) | +| `clientId` | string | Client ID for the session \(data.client_id\) | +| `status` | string | Session status \(data.status\) | +| `createdAt` | number | Session creation timestamp \(data.created_at\) | + + +--- + +### Clerk Session Revoked + +Trigger workflow when a Clerk session is revoked + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `sessionId` | string | Clerk session ID \(data.id\) | +| `userId` | string | User the session belongs to \(data.user_id\) | +| `clientId` | string | Client ID for the session \(data.client_id\) | +| `status` | string | Session status \(data.status\) | +| `createdAt` | number | Session creation timestamp \(data.created_at\) | + + --- ### Clerk User Created diff --git a/apps/docs/content/docs/en/integrations/dropcontact.mdx b/apps/docs/content/docs/en/integrations/dropcontact.mdx index 1f5cf1c8f29..b4ec2126cb2 100644 --- a/apps/docs/content/docs/en/integrations/dropcontact.mdx +++ b/apps/docs/content/docs/en/integrations/dropcontact.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/docs/content/docs/en/integrations/fathom.mdx b/apps/docs/content/docs/en/integrations/fathom.mdx index 16270807b0e..75d2f2b1104 100644 --- a/apps/docs/content/docs/en/integrations/fathom.mdx +++ b/apps/docs/content/docs/en/integrations/fathom.mdx @@ -46,10 +46,14 @@ List recent meetings recorded by the user or shared to their team. | `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\) | +| `includeHighlights` | string | No | Include meeting highlights \(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 | +| `meetingType` | string | No | Filter by meeting type name | +| `calendarInviteesDomains` | string | No | Filter by calendar invitee company domain \(exact match\) | +| `calendarInviteesDomainsType` | string | No | Filter by invitee domain type: all, only_internal, or one_or_more_external | | `cursor` | string | No | Pagination cursor from a previous response | #### Output @@ -58,11 +62,90 @@ List recent meetings recorded by the user or shared to their team. | --------- | ---- | ----------- | | `meetings` | array | List of meetings | | ↳ `title` | string | Meeting title | +| ↳ `meeting_title` | string | Calendar event title | +| ↳ `meeting_type` | string | Meeting type name | | ↳ `recording_id` | number | Unique recording ID | | ↳ `url` | string | URL to view the meeting | +| ↳ `meeting_url` | string | URL of the underlying video call \(Zoom, Meet, Teams, etc.\) | | ↳ `share_url` | string | Shareable URL | | ↳ `created_at` | string | Creation timestamp | +| ↳ `scheduled_start_time` | string | Scheduled start time | +| ↳ `scheduled_end_time` | string | Scheduled end time | +| ↳ `recording_start_time` | string | Recording start time | +| ↳ `recording_end_time` | string | Recording end time | | ↳ `transcript_language` | string | Transcript language | +| ↳ `calendar_invitees_domains_type` | string | Invitee domain type: only_internal or one_or_more_external | +| ↳ `shared_with` | string | Sharing scope: no_teams, single_team, multiple_teams, or all_teams | +| ↳ `recorded_by` | object | Recorder details | +| ↳ `name` | string | Name of the recorder | +| ↳ `email` | string | Email of the recorder | +| ↳ `email_domain` | string | Email domain of the recorder | +| ↳ `team` | string | Recorder team name | +| ↳ `calendar_invitees` | array | Calendar invitees for the meeting | +| ↳ `name` | string | Invitee name | +| ↳ `email` | string | Invitee email | +| ↳ `email_domain` | string | Invitee email domain | +| ↳ `is_external` | boolean | Whether the invitee is external | +| ↳ `matched_speaker_display_name` | string | Matched transcript speaker display name | +| ↳ `default_summary` | object | Meeting summary | +| ↳ `template_name` | string | Summary template name | +| ↳ `markdown_formatted` | string | Markdown-formatted summary | +| ↳ `transcript` | array | 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\) | +| ↳ `action_items` | array | Action items extracted from the meeting | +| ↳ `description` | string | Action item description | +| ↳ `user_generated` | boolean | Whether the action item was user-generated | +| ↳ `completed` | boolean | Whether the action item is completed | +| ↳ `recording_timestamp` | string | Timestamp in the recording \(HH:MM:SS\) | +| ↳ `recording_playback_url` | string | Playback URL for the action item moment | +| ↳ `assignee` | object | Assignee details | +| ↳ `name` | string | Assignee name | +| ↳ `email` | string | Assignee email | +| ↳ `team` | string | Assignee team | +| ↳ `highlights` | array | Meeting highlights with type, summary, text, and start/end time | +| ↳ `type` | string | Highlight type | +| ↳ `summary` | string | Highlight summary | +| ↳ `text` | string | Highlight text | +| ↳ `start_time` | number | Start time in seconds | +| ↳ `end_time` | number | End time in seconds | +| ↳ `crm_matches` | object | Matched CRM contacts, companies, and deals | +| ↳ `contacts` | array | Matched CRM contacts | +| ↳ `name` | string | Contact name | +| ↳ `email` | string | Contact email | +| ↳ `record_url` | string | CRM record URL | +| ↳ `companies` | array | Matched CRM companies | +| ↳ `name` | string | Company name | +| ↳ `record_url` | string | CRM record URL | +| ↳ `deals` | array | Matched CRM deals | +| ↳ `name` | string | Deal name | +| ↳ `amount` | number | Deal amount | +| ↳ `record_url` | string | CRM record URL | +| ↳ `error` | string | CRM match error, if any | +| `next_cursor` | string | Pagination cursor for next page | + +### `fathom_list_meeting_types` + +List meeting types configured 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 | +| --------- | ---- | ----------- | +| `meetingTypes` | array | List of meeting types | +| ↳ `name` | string | Meeting type name | +| ↳ `status` | string | Meeting type status: active or inactive | +| ↳ `created_at` | string | Date the meeting type was created | | `next_cursor` | string | Pagination cursor for next page | ### `fathom_get_summary` diff --git a/apps/docs/content/docs/en/integrations/gong.mdx b/apps/docs/content/docs/en/integrations/gong.mdx index 55b2007411b..6d111ddc52c 100644 --- a/apps/docs/content/docs/en/integrations/gong.mdx +++ b/apps/docs/content/docs/en/integrations/gong.mdx @@ -769,6 +769,71 @@ List Gong Engage flows (sales engagement sequences). | `currentPageNumber` | number | Current page number | | `cursor` | string | Pagination cursor for retrieving the next page of records | +### `gong_assign_flow_prospects` + +Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `flowId` | string | Yes | The Gong Engage flow ID to assign the prospects to | +| `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to assign | +| `flowInstanceOwnerEmail` | string | Yes | Email of the Gong user who owns the flow instance and its to-dos | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | +| `prospectsAssigned` | array | Prospects successfully assigned to the flow | +| ↳ `flowId` | string | The flow ID | +| ↳ `flowName` | string | The flow name | +| ↳ `crmProspectId` | string | The CRM prospect ID | +| ↳ `flowInstanceId` | string | The created flow instance ID | +| ↳ `flowInstanceOwnerEmail` | string | Email of the flow instance owner | +| ↳ `flowInstanceOwnerFullName` | string | Full name of the flow instance owner | +| ↳ `flowInstanceCreateDate` | string | Creation time of the flow instance in ISO-8601 format | +| ↳ `flowInstanceStatus` | string | Status of the flow instance | +| ↳ `workspaceId` | string | Workspace ID | +| ↳ `exclusive` | boolean | Whether this prospect can be added to other flows | +| `prospectsNotAssigned` | array | Prospects that failed to be assigned to the flow | +| ↳ `flowId` | string | The flow ID | +| ↳ `crmProspectId` | string | The CRM prospect ID | +| ↳ `errorCode` | string | Failure reason: InvalidArgument, InvalidState, or UnexpectedError | +| ↳ `errorMessage` | string | Human-readable failure message | + +### `gong_get_prospect_flows` + +Get the Gong Engage flows currently assigned to the given CRM prospects. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to look up | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | +| `prospectsAssigned` | array | Flows currently assigned to the requested prospects | +| ↳ `flowId` | string | The flow ID | +| ↳ `flowName` | string | The flow name | +| ↳ `crmProspectId` | string | The CRM prospect ID | +| ↳ `flowInstanceId` | string | The flow instance ID | +| ↳ `flowInstanceOwnerEmail` | string | Email of the flow instance owner | +| ↳ `flowInstanceOwnerFullName` | string | Full name of the flow instance owner | +| ↳ `flowInstanceCreateDate` | string | Creation time of the flow instance in ISO-8601 format | +| ↳ `flowInstanceStatus` | string | Status of the flow instance | +| ↳ `workspaceId` | string | Workspace ID | +| ↳ `exclusive` | boolean | Whether this prospect can be added to other flows | + ### `gong_get_coaching` Retrieve coaching metrics for a manager from Gong. @@ -904,6 +969,42 @@ Find all references to a phone number in Gong (calls, email messages, meetings, | ↳ `name` | string | Field name | | ↳ `value` | json | Field value | +### `gong_purge_email_address` + +Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `emailAddress` | string | Yes | Email address whose associated data should be permanently erased from Gong | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | + +### `gong_purge_phone_number` + +Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `phoneNumber` | string | Yes | Phone number whose associated data should be permanently erased from Gong. Must include a leading "+" and country code \(e.g., +14255552671\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/google_appsheet.mdx b/apps/docs/content/docs/en/integrations/google_appsheet.mdx new file mode 100644 index 00000000000..3b34ac1095b --- /dev/null +++ b/apps/docs/content/docs/en/integrations/google_appsheet.mdx @@ -0,0 +1,135 @@ +--- +title: Google AppSheet +description: Read, add, edit, and delete rows in a Google AppSheet table +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Google AppSheet](https://about.appsheet.com/) is Google's no-code app development platform that lets teams turn spreadsheets and databases into mobile and web apps, backed by data sources like Google Sheets, Excel, and cloud databases. + +With the Google AppSheet integration in Sim, you can: + +- **Find rows**: Query a table with an optional Selector expression (`Filter`, `OrderBy`, `Top`, and more) to narrow, sort, and limit the rows returned +- **Add rows**: Insert new rows into a table, letting AppSheet generate the key column automatically or providing it explicitly +- **Edit rows**: Update existing rows by key column, changing only the fields that need to change +- **Delete rows**: Remove rows from a table by key column + +In Sim, the Google AppSheet integration enables your agents to read and write AppSheet app data as part of automated workflows — syncing order intake, routing leads, escalating tickets, or keeping a table in sync with another system, all without touching the AppSheet editor. + +### Getting Your Application Access Key + +Google AppSheet authenticates with a static Application Access Key rather than OAuth: + +1. Open your app in the [AppSheet editor](https://www.appsheet.com/) +2. Go to **Settings > Integrations** +3. Enable **IN: from cloud services to your app** +4. Under **Application Access Keys**, create a key (or use an existing one) and copy it +5. Use the Application Access Key, along with your App ID and table name, in the Sim block configuration + +The AppSheet API requires an Enterprise plan. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key. + + + +## Actions + +### `google_appsheet_find_rows` + +Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to read from | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `selector` | string | No | Optional AppSheet expression to filter/sort/limit rows, e.g. Filter\(TableName, \[Age\] >= 21\) or Top\(OrderBy\(Filter\(TableName, true\), \[LastName\], true\), 10\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Matching rows returned by AppSheet | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows returned | + +### `google_appsheet_add_rows` + +Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID()). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to add rows to | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `rows` | json | Yes | Array of row objects to add, each a column-name/value map, e.g. \[\{ "FirstName": "Jan", "LastName": "Jones" \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Rows added by AppSheet, including any generated key values | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows added | + +### `google_appsheet_edit_rows` + +Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to update rows in | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `rows` | json | Yes | Array of row objects to update, each including the key column and the columns to change, e.g. \[\{ "RowID": "123", "Status": "Done" \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Rows updated by AppSheet | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows updated | + +### `google_appsheet_delete_rows` + +Delete rows from an AppSheet table. Each row only needs to include the key column name and value. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to delete rows from | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `rows` | json | Yes | Array of row objects identifying rows to delete by key column, e.g. \[\{ "RowID": "123" \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Rows deleted by AppSheet | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows deleted | + + diff --git a/apps/docs/content/docs/en/integrations/grafana.mdx b/apps/docs/content/docs/en/integrations/grafana.mdx index 103d69dc265..5bb28062b23 100644 --- a/apps/docs/content/docs/en/integrations/grafana.mdx +++ b/apps/docs/content/docs/en/integrations/grafana.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/docs/content/docs/en/integrations/hex.mdx b/apps/docs/content/docs/en/integrations/hex.mdx index e728ab635ff..392e9935f23 100644 --- a/apps/docs/content/docs/en/integrations/hex.mdx +++ b/apps/docs/content/docs/en/integrations/hex.mdx @@ -34,7 +34,7 @@ Whether you’re empowering analysts, automating reporting, or embedding actiona ## Usage Instructions -Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token. +Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token. @@ -83,6 +83,62 @@ Create a new collection in the Hex workspace to organize projects. | ↳ `email` | string | Creator email | | ↳ `id` | string | Creator UUID | +### `hex_create_group` + +Create a new group in the Hex workspace, optionally with initial members. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `name` | string | Yes | Name for the new group | +| `memberUserIds` | json | No | JSON array of user UUIDs to add as initial group members \(e.g., \["uuid1", "uuid2"\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Newly created group UUID | +| `name` | string | Group name | +| `createdAt` | string | Creation timestamp | + +### `hex_deactivate_user` + +Deactivate a user in the Hex workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `userId` | string | Yes | The UUID of the user to deactivate | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was successfully deactivated | +| `userId` | string | User UUID that was deactivated | + +### `hex_delete_group` + +Delete a group from the Hex workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `groupId` | string | Yes | The UUID of the group to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the group was successfully deleted | +| `groupId` | string | Group UUID that was deleted | + ### `hex_get_collection` Retrieve details for a specific Hex collection by its ID. @@ -194,6 +250,7 @@ Retrieve API-triggered runs for a Hex project with optional filtering by status | `limit` | number | No | Maximum number of runs to return \(1-100, default: 25\) | | `offset` | number | No | Offset for paginated results \(default: 0\) | | `statusFilter` | string | No | Filter by run status: PENDING, RUNNING, ERRORED, COMPLETED, KILLED, UNABLE_TO_ALLOCATE_KERNEL | +| `runTriggerFilter` | string | No | Filter by how the run was triggered: ALL, API, SCHEDULED, or APP_REFRESH | #### Output @@ -211,6 +268,8 @@ Retrieve API-triggered runs for a Hex project with optional filtering by status | ↳ `projectVersion` | number | Project version number | | `total` | number | Total number of runs returned | | `traceId` | string | Top-level trace ID | +| `nextPage` | string | Cursor for the next page of runs | +| `previousPage` | string | Cursor for the previous page of runs | ### `hex_get_queried_tables` @@ -271,6 +330,8 @@ List all collections in the Hex workspace. | `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | | `limit` | number | No | Maximum number of collections to return \(1-500, default: 25\) | | `sortBy` | string | No | Sort by field: NAME | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -284,6 +345,8 @@ List all collections in the Hex workspace. | ↳ `email` | string | Creator email | | ↳ `id` | string | Creator UUID | | `total` | number | Total number of collections returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_data_connections` @@ -297,6 +360,8 @@ List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, Big | `limit` | number | No | Maximum number of connections to return \(1-500, default: 25\) | | `sortBy` | string | No | Sort by field: CREATED_AT or NAME | | `sortDirection` | string | No | Sort direction: ASC or DESC | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -311,6 +376,8 @@ List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, Big | ↳ `includeMagic` | boolean | Whether Magic AI features are enabled | | ↳ `allowWritebackCells` | boolean | Whether writeback cells are allowed | | `total` | number | Total number of connections returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_groups` @@ -324,6 +391,8 @@ List all groups in the Hex workspace with optional sorting. | `limit` | number | No | Maximum number of groups to return \(1-500, default: 25\) | | `sortBy` | string | No | Sort by field: CREATED_AT or NAME | | `sortDirection` | string | No | Sort direction: ASC or DESC | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -334,6 +403,8 @@ List all groups in the Hex workspace with optional sorting. | ↳ `name` | string | Group name | | ↳ `createdAt` | string | Creation timestamp | | `total` | number | Total number of groups returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_projects` @@ -347,6 +418,16 @@ List all projects in your Hex workspace with optional filtering by status. | `limit` | number | No | Maximum number of projects to return \(1-100\) | | `includeArchived` | boolean | No | Include archived projects in results | | `statusFilter` | string | No | Filter by status: PUBLISHED, DRAFT, or ALL | +| `includeComponents` | boolean | No | Include components in results | +| `includeTrashed` | boolean | No | Include trashed projects in results | +| `creatorEmail` | string | No | Filter by creator email | +| `ownerEmail` | string | No | Filter by owner email | +| `collectionId` | string | No | Filter by collection UUID | +| `categories` | json | No | JSON array of category names to filter by \(e.g., \["Marketing", "Finance"\]\) | +| `sortBy` | string | No | Sort by field: CREATED_AT, LAST_EDITED_AT, or LAST_PUBLISHED_AT | +| `sortDirection` | string | No | Sort direction: ASC or DESC | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -368,6 +449,8 @@ List all projects in your Hex workspace with optional filtering by status. | ↳ `createdAt` | string | Creation timestamp | | ↳ `archivedAt` | string | Archived timestamp | | `total` | number | Total number of projects returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_users` @@ -382,6 +465,9 @@ List all users in the Hex workspace with optional filtering and sorting. | `sortBy` | string | No | Sort by field: NAME or EMAIL | | `sortDirection` | string | No | Sort direction: ASC or DESC | | `groupId` | string | No | Filter users by group UUID | +| `userIds` | string | No | Comma-separated list of user UUIDs to filter by | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -392,7 +478,10 @@ List all users in the Hex workspace with optional filtering and sorting. | ↳ `name` | string | User name | | ↳ `email` | string | User email | | ↳ `role` | string | User role \(ADMIN, MANAGER, EDITOR, EXPLORER, MEMBER, GUEST, EMBEDDED_USER, ANONYMOUS\) | +| ↳ `lastLoginDate` | string | Last login timestamp | | `total` | number | Total number of users returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_run_project` @@ -409,6 +498,8 @@ Execute a published Hex project. Optionally pass input parameters and control ca | `updateCache` | boolean | No | \(Deprecated\) If true, update the cached results after execution | | `updatePublishedResults` | boolean | No | If true, update the published app results after execution | | `useCachedSqlResults` | boolean | No | If true, use cached SQL results instead of re-running queries | +| `viewId` | string | No | Optional SavedView ID to use for the project run | +| `notifications` | json | No | JSON array of notification details to deliver once the run completes \(e.g., \[\{"type": "FAILURE", "slackChannelIds": \["C0123456789"\], "userIds": \[\], "groupIds": \[\], "includeSuccessScreenshot": false\}\]\). type is ALL, SUCCESS, or FAILURE. | #### Output @@ -421,6 +512,52 @@ Execute a published Hex project. Optionally pass input parameters and control ca | `traceId` | string | Trace ID for debugging | | `projectVersion` | number | Project version number | +### `hex_update_collection` + +Update the name or description of an existing Hex collection. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `collectionId` | string | Yes | The UUID of the collection to update | +| `name` | string | No | New name for the collection | +| `description` | string | No | New description for the collection | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Collection UUID | +| `name` | string | Collection name | +| `description` | string | Collection description | +| `creator` | object | Collection creator | +| ↳ `email` | string | Creator email | +| ↳ `id` | string | Creator UUID | + +### `hex_update_group` + +Rename a Hex group or add/remove members from it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `groupId` | string | Yes | The UUID of the group to update | +| `name` | string | No | New name for the group | +| `addUserIds` | json | No | JSON array of user UUIDs to add to the group \(e.g., \["uuid1", "uuid2"\]\) | +| `removeUserIds` | json | No | JSON array of user UUIDs to remove from the group \(e.g., \["uuid1", "uuid2"\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Group UUID | +| `name` | string | Group name | +| `createdAt` | string | Creation timestamp | + ### `hex_update_project` Update a Hex project status label (e.g., endorsement or custom workspace statuses). diff --git a/apps/docs/content/docs/en/integrations/langsmith.mdx b/apps/docs/content/docs/en/integrations/langsmith.mdx index caf4577f8a8..7e83f3d1eb4 100644 --- a/apps/docs/content/docs/en/integrations/langsmith.mdx +++ b/apps/docs/content/docs/en/integrations/langsmith.mdx @@ -91,4 +91,81 @@ Forward multiple runs to LangSmith in a single batch. | `message` | string | Response message from LangSmith | | `messages` | array | Per-run response messages, when provided | +### `langsmith_update_run` + +Patch an existing LangSmith run with outputs, status, or timing once it completes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | LangSmith API key | +| `runId` | string | Yes | ID of the run to update | +| `name` | string | No | Corrected run name | + +#### Output + +This tool does not produce any outputs. + +### `langsmith_get_run` + +Retrieve a single LangSmith run by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | LangSmith API key | +| `runId` | string | Yes | ID of the run to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Run ID | +| `runId` | string | Run ID \(alias of id, for consistency with other operations\) | +| `name` | string | Run name | +| `runType` | string | Run type \(tool, chain, llm, retriever, embedding, prompt, parser\) | +| `status` | string | Run status | +| `startTime` | string | Run start time \(ISO\) | +| `endTime` | string | Run end time \(ISO\) | +| `inputs` | json | Run inputs payload | +| `outputs` | json | Run outputs payload | +| `error` | string | Error details, if the run failed | +| `tags` | array | Tags attached to the run | +| `sessionId` | string | Project \(session\) ID the run belongs to | +| `traceId` | string | Trace ID | +| `parentRunId` | string | Parent run ID | +| `totalTokens` | number | Total tokens consumed by the run | +| `totalCost` | string | Total cost of the run | + +### `langsmith_create_feedback` + +Attach a score, correction, or comment to a LangSmith run. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | LangSmith API key | +| `runId` | string | Yes | ID of the run to attach feedback to | +| `key` | string | Yes | Feedback metric name \(e.g. "correctness", "user_score"\) | +| `score` | number | No | Numeric score for the feedback metric | +| `value` | string | No | Categorical value for the feedback metric | +| `comment` | string | No | Free-text comment explaining the feedback | +| `correction` | json | No | Corrected output for the run | +| `feedbackSourceType` | string | No | Origin of the feedback \(api, app, or model\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Feedback ID | +| `key` | string | Feedback metric name | +| `runId` | string | ID of the run the feedback was attached to | +| `score` | number | Score recorded for the feedback | +| `value` | string | Categorical value recorded for the feedback | +| `comment` | string | Comment recorded for the feedback | +| `createdAt` | string | When the feedback was created \(ISO\) | + diff --git a/apps/docs/content/docs/en/integrations/loops.mdx b/apps/docs/content/docs/en/integrations/loops.mdx index d46ec807111..1a37c87b872 100644 --- a/apps/docs/content/docs/en/integrations/loops.mdx +++ b/apps/docs/content/docs/en/integrations/loops.mdx @@ -205,7 +205,7 @@ Retrieve all mailing lists from your Loops account. Returns each list with its I ### `loops_list_transactional_emails` -Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables. +Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables. #### Input @@ -222,7 +222,9 @@ Retrieve a list of published transactional email templates from your Loops accou | `transactionalEmails` | array | Array of published transactional email templates | | ↳ `id` | string | The transactional email template ID | | ↳ `name` | string | The template name | -| ↳ `lastUpdated` | string | Last updated timestamp | +| ↳ `createdAt` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updatedAt` | string | Last updated timestamp \(ISO 8601\) | +| ↳ `lastUpdated` | string | Deprecated alias of updatedAt, kept for backwards compatibility | | ↳ `dataVariables` | array | Template data variable names | | `pagination` | object | Pagination information | | ↳ `totalResults` | number | Total number of results | @@ -270,6 +272,74 @@ Retrieve a list of contact properties from your Loops account. Returns each prop | ↳ `label` | string | The property display label | | ↳ `type` | string | The property data type \(string, number, boolean, date\) | +### `loops_check_contact_suppression` + +Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address to check \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId to check \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contactId` | string | The Loops-assigned contact ID | +| `email` | string | The contact email address | +| `userId` | string | The contact userId | +| `isSuppressed` | boolean | Whether the contact is on the suppression list | +| `removalQuotaLimit` | number | Total suppression-removal quota for the team | +| `removalQuotaRemaining` | number | Remaining suppression-removal quota for the team | + +### `loops_remove_contact_suppression` + +Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address to remove from suppression \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId to remove from suppression \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was removed from suppression successfully | +| `message` | string | Status message from the API | +| `removalQuotaLimit` | number | Total suppression-removal quota for the team | +| `removalQuotaRemaining` | number | Remaining suppression-removal quota for the team | + +### `loops_get_transactional_email` + +Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `transactionalId` | string | Yes | The ID of the transactional email template to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | The transactional email template ID | +| `name` | string | The template name | +| `draftEmailMessageId` | string | ID of the draft email message, if any | +| `publishedEmailMessageId` | string | ID of the published email message, if any | +| `transactionalGroupId` | string | ID of the transactional group this template belongs to, if any | +| `createdAt` | string | Creation timestamp \(ISO 8601\) | +| `updatedAt` | string | Last updated timestamp \(ISO 8601\) | +| `dataVariables` | array | Template data variable names | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index f9f8e3a7c26..ae36529830d 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -77,6 +77,7 @@ "gong", "google-service-account", "google_ads", + "google_appsheet", "google_bigquery", "google_books", "google_calendar", diff --git a/apps/docs/content/docs/en/integrations/onepassword.mdx b/apps/docs/content/docs/en/integrations/onepassword.mdx index 33f1adea356..3cb2659076a 100644 --- a/apps/docs/content/docs/en/integrations/onepassword.mdx +++ b/apps/docs/content/docs/en/integrations/onepassword.mdx @@ -29,7 +29,7 @@ By connecting Sim with 1Password, you empower your agents to securely manage sec ## Usage Instructions -Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references. +Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references. @@ -59,7 +59,7 @@ List all vaults accessible by the Connect token or Service Account | ↳ `description` | string | Vault description | | ↳ `attributeVersion` | number | Vault attribute version | | ↳ `contentVersion` | number | Vault content version | -| ↳ `type` | string | Vault type \(USER_CREATED, PERSONAL, EVERYONE, TRANSFER\) | +| ↳ `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | | ↳ `createdAt` | string | Creation timestamp | | ↳ `updatedAt` | string | Last update timestamp | @@ -87,7 +87,7 @@ Get details of a specific vault by ID | `attributeVersion` | number | Vault attribute version | | `contentVersion` | number | Vault content version | | `items` | number | Number of items in the vault | -| `type` | string | Vault type \(USER_CREATED, PERSONAL, EVERYONE, TRANSFER\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | | `createdAt` | string | Creation timestamp | | `updatedAt` | string | Last update timestamp | @@ -123,7 +123,7 @@ List items in a vault. Returns summaries without field values. | ↳ `favorite` | boolean | Whether the item is favorited | | ↳ `tags` | array | Item tags | | ↳ `version` | number | Item version number | -| ↳ `state` | string | Item state \(ARCHIVED or DELETED\) | +| ↳ `state` | string | Item state \(ARCHIVED, or absent/null when active\) | | ↳ `createdAt` | string | Creation timestamp | | ↳ `updatedAt` | string | Last update timestamp | | ↳ `lastEditedBy` | string | ID of the last editor | @@ -147,7 +147,53 @@ Get full details of an item including all fields and secrets | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | + +### `onepassword_get_item_file` + +Download the content of a file attached to an item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionMode` | string | No | Connection mode: "service_account" or "connect" | +| `serviceAccountToken` | string | No | 1Password Service Account token \(for Service Account mode\) | +| `apiKey` | string | No | 1Password Connect API token \(for Connect Server mode\) | +| `serverUrl` | string | No | 1Password Connect server URL \(for Connect Server mode\) | +| `vaultId` | string | Yes | The vault UUID | +| `itemId` | string | Yes | The item UUID the file is attached to | +| `fileId` | string | Yes | The file ID \(from the item\'s "files" array, e.g. via Get Item\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded file attachment | ### `onepassword_create_item` @@ -165,13 +211,37 @@ Create a new item in a vault | `category` | string | Yes | Item category \(e.g., LOGIN, PASSWORD, API_CREDENTIAL, SECURE_NOTE, SERVER, DATABASE\) | | `title` | string | No | Item title | | `tags` | string | No | Comma-separated list of tags | -| `fields` | string | No | JSON array of field objects \(e.g., \[\{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"\}\]\) | +| `fields` | string | No | JSON array of field objects \(e.g., \[\{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"\}\]\). "purpose" is honored in Connect Server mode; in Service Account mode 1Password infers it from the field label/type instead. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | ### `onepassword_replace_item` @@ -193,7 +263,31 @@ Replace an entire item with new data (full update) | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | ### `onepassword_update_item` @@ -215,7 +309,31 @@ Update an existing item using JSON Patch operations (RFC6902) | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | ### `onepassword_delete_item` diff --git a/apps/docs/content/docs/en/integrations/sendgrid.mdx b/apps/docs/content/docs/en/integrations/sendgrid.mdx index 0b32ebc50af..a8cb7010e7e 100644 --- a/apps/docs/content/docs/en/integrations/sendgrid.mdx +++ b/apps/docs/content/docs/en/integrations/sendgrid.mdx @@ -206,13 +206,15 @@ Get all contact lists from SendGrid | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | SendGrid API key | -| `pageSize` | number | No | Number of lists to return per page \(default: 100\) | +| `pageSize` | number | No | Number of lists to return per page \(default: 100, max: 1000\) | +| `pageToken` | string | No | Page token from a previous response \(nextPageToken\) to fetch the next page | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `lists` | json | Array of lists | +| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, if more results exist | ### `sendgrid_delete_list` @@ -321,13 +323,17 @@ Get all email templates from SendGrid | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | SendGrid API key | | `generations` | string | No | Filter by generation \(legacy, dynamic, or both\) | -| `pageSize` | number | No | Number of templates to return per page \(default: 20\) | +| `pageSize` | number | No | Number of templates to return per page \(default: 20, max: 200\). ' + + 'When paginating with pageToken, pass the same pageSize used on the first request ' + + 'to keep page boundaries consistent. | +| `pageToken` | string | No | Page token from a previous response \(nextPageToken\) to fetch the next page | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `templates` | json | Array of templates | +| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, if more results exist | ### `sendgrid_delete_template` @@ -365,6 +371,7 @@ Delete an email template from SendGrid | `templates` | json | Array of templates | | `generation` | string | Template generation | | `versions` | json | Array of template versions | +| `nextPageToken` | string | Token for the next page of results \(list_all_lists, list_templates\) | | `templateId` | string | Template ID | | `active` | boolean | Whether template version is active | | `htmlContent` | string | HTML content | diff --git a/apps/docs/content/docs/en/integrations/sharepoint.mdx b/apps/docs/content/docs/en/integrations/sharepoint.mdx index bb9bc4df74b..9dcf1db6596 100644 --- a/apps/docs/content/docs/en/integrations/sharepoint.mdx +++ b/apps/docs/content/docs/en/integrations/sharepoint.mdx @@ -107,6 +107,71 @@ Read a specific page from a SharePoint site | `totalPages` | number | Total number of pages found | | `nextPageUrl` | string | Full Microsoft Graph @odata.nextLink URL for the next page of results | +### `sharepoint_update_page` + +Update the title and/or content of a SharePoint page + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `siteSelector` | string | No | Select the SharePoint site | +| `pageId` | string | Yes | The ID of the page to update. Example: a GUID like 12345678-1234-1234-1234-123456789012 | +| `pageTitle` | string | No | The new title of the page | +| `pageContent` | string | No | The new text content of the page. Replaces the entire canvas layout of the page. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `page` | object | Updated SharePoint page information | +| ↳ `id` | string | The unique ID of the page | +| ↳ `name` | string | The name of the page | +| ↳ `title` | string | The title of the page | +| ↳ `webUrl` | string | The URL to access the page | +| ↳ `pageLayout` | string | The layout type of the page | +| ↳ `createdDateTime` | string | When the page was created | +| ↳ `lastModifiedDateTime` | string | When the page was last modified | + +### `sharepoint_publish_page` + +Publish the latest version of a SharePoint page, making it available to all users + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `pageId` | string | Yes | The ID of the page to publish. Example: a GUID like 12345678-1234-1234-1234-123456789012 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `published` | boolean | Whether the page was published | +| `pageId` | string | The ID of the published page | + +### `sharepoint_delete_page` + +Delete a page from a SharePoint site + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `pageId` | string | Yes | The ID of the page to delete. Example: a GUID like 12345678-1234-1234-1234-123456789012 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the page was deleted | +| `pageId` | string | The ID of the deleted page | + ### `sharepoint_list_sites` List details of all SharePoint sites @@ -133,8 +198,7 @@ List details of all SharePoint sites | ↳ `createdDateTime` | string | When the site was created | | ↳ `lastModifiedDateTime` | string | When the site was last modified | | ↳ `isPersonalSite` | boolean | Whether this is a personal site | -| ↳ `root` | object | root output from the tool | -| ↳ `serverRelativeUrl` | string | Server relative URL | +| ↳ `root` | object | Present \(as an empty object\) only when this site is the root of its site collection | | ↳ `siteCollection` | object | siteCollection output from the tool | | ↳ `hostname` | string | Site collection hostname | | `sites` | array | List of all accessible SharePoint sites | @@ -252,6 +316,47 @@ Add a new item to a SharePoint list | ↳ `id` | string | Item ID | | ↳ `fields` | object | Field values for the new item | +### `sharepoint_get_list_item` + +Get a single item (with field values) from a SharePoint list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `listId` | string | Yes | The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 | +| `itemId` | string | Yes | The ID of the list item to retrieve. Example: 1, 42, or 123 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | object | SharePoint list item with field values | +| ↳ `id` | string | Item ID | +| ↳ `fields` | object | Field values for the item | + +### `sharepoint_delete_list_item` + +Delete an item from a SharePoint list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `listId` | string | Yes | The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 | +| `itemId` | string | Yes | The ID of the list item to delete. Example: 1, 42, or 123 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the list item was deleted | +| `itemId` | string | The ID of the deleted list item | + ### `sharepoint_upload_file` Upload files to a SharePoint document library @@ -289,4 +394,66 @@ Upload files to a SharePoint document library | ↳ `error` | string | Error message | | ↳ `status` | number | HTTP status from Microsoft Graph | +### `sharepoint_download_file` + +Download a file from a SharePoint document library + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 | +| `driveItemId` | string | Yes | The ID of the file \(drive item\) to download | +| `fileName` | string | No | Optional filename override \(e.g., "report.pdf", "data.xlsx"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded file stored in execution files | + +### `sharepoint_get_drive_item` + +Get metadata for a file or folder in a SharePoint document library + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 | +| `driveItemId` | string | Yes | The ID of the file or folder \(drive item\) to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `driveItem` | object | Metadata for the SharePoint file or folder | +| ↳ `id` | string | The unique ID of the drive item | +| ↳ `name` | string | The name of the file or folder | +| ↳ `webUrl` | string | The URL to access the item | +| ↳ `size` | number | The size of the item in bytes | +| ↳ `createdDateTime` | string | When the item was created | +| ↳ `lastModifiedDateTime` | string | When the item was last modified | +| ↳ `file` | object | Present if the item is a file \(contains mimeType\) | +| ↳ `folder` | object | Present if the item is a folder \(contains childCount\) | +| ↳ `parentReference` | object | Reference to the parent folder/drive | + +### `sharepoint_delete_file` + +Delete a file (or folder) from a SharePoint document library + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 | +| `driveItemId` | string | Yes | The ID of the file \(drive item\) to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the file was deleted | +| `itemId` | string | The ID of the deleted file | + diff --git a/apps/docs/content/docs/en/integrations/similarweb.mdx b/apps/docs/content/docs/en/integrations/similarweb.mdx index cbdf82ddc28..b3091f7e947 100644 --- a/apps/docs/content/docs/en/integrations/similarweb.mdx +++ b/apps/docs/content/docs/en/integrations/similarweb.mdx @@ -180,4 +180,32 @@ Get average desktop visit duration over time (in seconds) | ↳ `date` | string | Date \(YYYY-MM-DD\) | | ↳ `durationSeconds` | number | Average visit duration in seconds | +### `similarweb_page_views` + +Get total page views over time (desktop and mobile combined) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | SimilarWeb API key | +| `domain` | string | Yes | Website domain to analyze \(e.g., "example.com" without www or protocol\) | +| `country` | string | Yes | 2-letter ISO country code \(e.g., "us", "gb", "de"\) or "world" for worldwide data | +| `granularity` | string | Yes | Data granularity: daily, weekly, or monthly | +| `startDate` | string | No | Start date in YYYY-MM format \(e.g., "2024-01"\) | +| `endDate` | string | No | End date in YYYY-MM format \(e.g., "2024-12"\) | +| `mainDomainOnly` | boolean | No | Exclude subdomains from results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `domain` | string | Analyzed domain | +| `country` | string | Country filter applied | +| `granularity` | string | Data granularity | +| `lastUpdated` | string | Data last updated timestamp | +| `pageViews` | array | Page view data over time | +| ↳ `date` | string | Date \(YYYY-MM-DD\) | +| ↳ `pageViews` | number | Total page views | + diff --git a/apps/docs/content/docs/en/integrations/supabase.mdx b/apps/docs/content/docs/en/integrations/supabase.mdx index 1741ceb55a1..e5c339c3dc6 100644 --- a/apps/docs/content/docs/en/integrations/supabase.mdx +++ b/apps/docs/content/docs/en/integrations/supabase.mdx @@ -288,7 +288,7 @@ Invoke a Supabase Edge Function over HTTP ### `supabase_introspect` -Introspect Supabase database schema to get table structures, columns, and relationships +Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection) #### Input @@ -309,11 +309,11 @@ Introspect Supabase database schema to get table structures, columns, and relati | ↳ `columns` | array | Array of column definitions | | ↳ `name` | string | Column name | | ↳ `type` | string | Column data type | -| ↳ `nullable` | boolean | Whether the column allows null values | +| ↳ `nullable` | boolean | Whether the column allows null values — a NOT NULL column that has a default value is misreported as nullable, since the OpenAPI spec this is derived from omits it from the required list in that case | | ↳ `default` | string | Default value for the column | -| ↳ `isPrimaryKey` | boolean | Whether the column is a primary key | -| ↳ `isForeignKey` | boolean | Whether the column is a foreign key | -| ↳ `references` | object | Foreign key reference details | +| ↳ `isPrimaryKey` | boolean | Best-effort guess based on the column being named "id" \(not authoritative\) | +| ↳ `isForeignKey` | boolean | True only if the column has a "references table.column" SQL comment; most databases will show false even for real foreign keys | +| ↳ `references` | object | Foreign key reference details, when detected via SQL comment | | ↳ `table` | string | Referenced table name | | ↳ `column` | string | Referenced column name | | ↳ `primaryKey` | array | Array of primary key column names | @@ -321,7 +321,7 @@ Introspect Supabase database schema to get table structures, columns, and relati | ↳ `column` | string | Local column name | | ↳ `referencesTable` | string | Referenced table name | | ↳ `referencesColumn` | string | Referenced column name | -| ↳ `indexes` | array | Array of index definitions | +| ↳ `indexes` | array | Always empty — index definitions are not exposed by the OpenAPI spec this tool reads | | ↳ `name` | string | Index name | | ↳ `columns` | array | Columns included in the index | | ↳ `unique` | boolean | Whether the index enforces uniqueness | @@ -512,6 +512,49 @@ Create a new storage bucket in Supabase | `results` | object | Created bucket result \(name\) | | ↳ `name` | string | Created bucket name | +### `supabase_storage_update_bucket` + +Update the configuration of an existing Supabase storage bucket + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) | +| `bucket` | string | Yes | The name of the bucket to update | +| `isPublic` | boolean | No | Whether the bucket should be publicly accessible \(leave unset to keep the current value\) | +| `fileSizeLimit` | number | No | Maximum file size in bytes \(leave unset to keep the current value\) | +| `allowedMimeTypes` | array | No | Array of allowed MIME types \(e.g., \["image/png", "image/jpeg"\]\) — leave unset to keep the current value | +| `apiKey` | string | Yes | Your Supabase service role secret key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `results` | object | Update operation result | +| ↳ `message` | string | Operation status message | + +### `supabase_storage_empty_bucket` + +Delete all objects inside a Supabase storage bucket without deleting the bucket itself + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) | +| `bucket` | string | Yes | The name of the bucket to empty | +| `apiKey` | string | Yes | Your Supabase service role secret key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `results` | object | Empty bucket operation result | +| ↳ `message` | string | Operation status message | + ### `supabase_storage_list_buckets` List all storage buckets in Supabase @@ -570,7 +613,6 @@ Get the public URL for a file in a Supabase storage bucket | `bucket` | string | Yes | The name of the storage bucket | | `path` | string | Yes | The path to the file \(e.g., "folder/file.jpg"\) | | `download` | boolean | No | If true, forces download instead of inline display \(default: false\) | -| `output` | string | No | No description | #### Output @@ -601,4 +643,27 @@ Create a temporary signed URL for a file in a Supabase storage bucket | `message` | string | Operation status message | | `signedUrl` | string | The temporary signed URL to access the file | +### `supabase_storage_create_signed_upload_url` + +Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) | +| `bucket` | string | Yes | The name of the storage bucket | +| `path` | string | Yes | The destination path for the uploaded file \(e.g., "folder/file.jpg"\) | +| `upsert` | boolean | No | If true, allows overwriting an existing file at this path \(default: false\) | +| `apiKey` | string | Yes | Your Supabase service role secret key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `signedUrl` | string | The temporary signed URL a client can PUT the file to | +| `path` | string | The destination object path | +| `token` | string | The upload token embedded in the signed URL | + diff --git a/apps/docs/content/docs/en/integrations/tailscale.mdx b/apps/docs/content/docs/en/integrations/tailscale.mdx index e5e97f6b7fe..e1dfc2dbc01 100644 --- a/apps/docs/content/docs/en/integrations/tailscale.mdx +++ b/apps/docs/content/docs/en/integrations/tailscale.mdx @@ -67,7 +67,8 @@ List all devices in the tailnet | Parameter | Type | Description | | --------- | ---- | ----------- | | `devices` | array | List of devices in the tailnet | -| ↳ `id` | string | Device ID | +| ↳ `id` | string | Legacy device ID | +| ↳ `nodeId` | string | Preferred device ID | | ↳ `name` | string | Device name | | ↳ `hostname` | string | Device hostname | | ↳ `user` | string | Associated user | @@ -77,6 +78,8 @@ List all devices in the tailnet | ↳ `tags` | array | Device tags | | ↳ `authorized` | boolean | Whether the device is authorized | | ↳ `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections | +| ↳ `keyExpiryDisabled` | boolean | Whether the device key is exempt from expiring | +| ↳ `expires` | string | The device's auth key expiration timestamp | | ↳ `lastSeen` | string | Last seen timestamp | | ↳ `created` | string | Creation timestamp | | `count` | number | Total number of devices | @@ -97,7 +100,8 @@ Get details of a specific device by ID | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Device ID | +| `id` | string | Legacy device ID | +| `nodeId` | string | Preferred device ID | | `name` | string | Device name | | `hostname` | string | Device hostname | | `user` | string | Associated user | @@ -107,6 +111,8 @@ Get details of a specific device by ID | `tags` | array | Device tags | | `authorized` | boolean | Whether the device is authorized | | `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections | +| `keyExpiryDisabled` | boolean | Whether the device key is exempt from expiring | +| `expires` | string | The device's auth key expiration timestamp | | `lastSeen` | string | Last seen timestamp | | `created` | string | Creation timestamp | | `isExternal` | boolean | Whether the device is external | @@ -235,6 +241,25 @@ Enable or disable key expiry on a device | `deviceId` | string | Device ID | | `keyExpiryDisabled` | boolean | Whether key expiry is now disabled | +### `tailscale_expire_device_key` + +Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `deviceId` | string | Yes | Device ID to expire the key for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the device's key was successfully expired | +| `deviceId` | string | Device ID | + ### `tailscale_list_dns_nameservers` Get the DNS nameservers configured for the tailnet @@ -251,7 +276,6 @@ Get the DNS nameservers configured for the tailnet | Parameter | Type | Description | | --------- | ---- | ----------- | | `dns` | array | List of DNS nameserver addresses | -| `magicDNS` | boolean | Whether MagicDNS is enabled | ### `tailscale_set_dns_nameservers` @@ -370,6 +394,44 @@ List all users in the tailnet | ↳ `deviceCount` | number | Number of devices owned by user | | `count` | number | Total number of users | +### `tailscale_suspend_user` + +Suspend a user's access to the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `userId` | string | Yes | User ID to suspend | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was successfully suspended | +| `userId` | string | ID of the suspended user | + +### `tailscale_delete_user` + +Delete a user from the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `userId` | string | Yes | User ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was successfully deleted | +| `userId` | string | ID of the deleted user | + ### `tailscale_create_auth_key` Create a new auth key for the tailnet to pre-authorize devices @@ -495,4 +557,24 @@ Get the current ACL policy for the tailnet | `acl` | string | ACL policy as JSON string | | `etag` | string | ETag for the current ACL version \(use with If-Match header for updates\) | +### `tailscale_set_acl` + +Replace the ACL policy file for the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `acl` | string | Yes | The new ACL policy file, as a JSON string | +| `ifMatch` | string | No | ETag from a prior Get ACL call to avoid overwriting concurrent updates. Use "ts-default" to only replace an untouched default policy file. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `acl` | string | Updated ACL policy as JSON string | +| `etag` | string | ETag for the new ACL version \(use with If-Match header for future updates\) | + diff --git a/apps/docs/content/docs/en/integrations/trello.mdx b/apps/docs/content/docs/en/integrations/trello.mdx index 86cdef85b99..c1f4b806418 100644 --- a/apps/docs/content/docs/en/integrations/trello.mdx +++ b/apps/docs/content/docs/en/integrations/trello.mdx @@ -1,6 +1,6 @@ --- title: Trello -description: Manage Trello lists, cards, and activity +description: Manage Trello lists, cards, checklists, and activity --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -37,7 +37,7 @@ Trello's authorization flow redirects back to Sim using a `return_url`. If your {/* MANUAL-CONTENT-END */} -Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments. +Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments. @@ -52,6 +52,7 @@ List all lists on a Trello board | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `boardId` | string | Yes | Trello board ID \(24-character hex string\) | +| `filter` | string | No | Which lists to return: open, closed, or all \(defaults to open\) | #### Output @@ -75,6 +76,7 @@ List cards from a Trello board or list | --------- | ---- | -------- | ----------- | | `boardId` | string | No | Trello board ID to list open cards from. Provide either boardId or listId | | `listId` | string | No | Trello list ID to list cards from. Provide either boardId or listId | +| `filter` | string | No | Which cards to return: open, closed, or all \(defaults to open\) | #### Output @@ -97,6 +99,40 @@ List cards from a Trello board or list | ↳ `dueComplete` | boolean | Whether the due date is complete | | `count` | number | Number of cards returned | +### `trello_search` + +Search Trello cards and boards by keyword + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | Search text, supports Trello search operators \(e.g. board:, list:, due:\) | +| `idBoards` | array | No | Restrict the search to these board IDs | +| `modelTypes` | string | No | Comma-separated result types to search: cards, boards, or all \(default all\) | +| `cardsLimit` | number | No | Maximum number of cards to return \(1-1000, default 10\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `cards` | array | Cards matching the search query | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| `boards` | array | Boards matching the search query | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `desc` | string | Board description | +| ↳ `url` | string | Full board URL | +| ↳ `closed` | boolean | Whether the board is archived | +| ↳ `idOrganization` | string | Workspace/organization ID that owns the board | +| `count` | number | Total number of cards and boards returned | + ### `trello_create_card` Create a new card in a Trello list @@ -112,6 +148,7 @@ Create a new card in a Trello list | `due` | string | No | Due date \(ISO 8601 format\) | | `dueComplete` | boolean | No | Whether the due date should be marked complete | | `labelIds` | array | No | Label IDs to attach to the card | +| `memberIds` | array | No | Member IDs to assign to the card | #### Output @@ -169,6 +206,22 @@ Update an existing card on Trello | ↳ `due` | string | Card due date in ISO 8601 format | | ↳ `dueComplete` | boolean | Whether the due date is complete | +### `trello_delete_card` + +Permanently delete a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to permanently delete \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the card was deleted | + ### `trello_get_actions` Get activity/actions from a board or card @@ -182,6 +235,8 @@ Get activity/actions from a board or card | `filter` | string | No | Filter actions by type \(e.g., "commentCard,updateCard,createCard" or "all"\) | | `limit` | number | No | Maximum number of board actions to return | | `page` | number | No | Page number for action results | +| `since` | string | No | Only return actions after this date \(ISO 8601 timestamp\) or action ID, for paging through long histories | +| `before` | string | No | Only return actions before this date \(ISO 8601 timestamp\) or action ID, for paging through long histories | #### Output @@ -321,6 +376,31 @@ Create a new list on a Trello board | ↳ `pos` | number | List position on the board | | ↳ `idBoard` | string | Board ID containing the list | +### `trello_update_list` + +Rename, move, archive, or reopen a Trello list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | Trello list ID \(24-character hex string\) | +| `name` | string | No | New name of the list | +| `closed` | boolean | No | Archive the list \(true\) or reopen it \(false\) | +| `idBoard` | string | No | Board ID to move the list to \(24-character hex string\) | +| `pos` | string | No | New position of the list \(top, bottom, or positive float\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `list` | json | Updated list \(id, name, closed, pos, idBoard\) | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | +| ↳ `closed` | boolean | Whether the list is archived | +| ↳ `pos` | number | List position on the board | +| ↳ `idBoard` | string | Board ID containing the list | + ### `trello_get_card` Retrieve a single Trello card by ID @@ -374,6 +454,54 @@ Add a checklist to a Trello card | ↳ `idBoard` | string | Board ID containing the checklist | | ↳ `pos` | number | Checklist position on the card | +### `trello_add_checklist_item` + +Add an item to a Trello checklist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | Trello checklist ID to add the item to \(24-character hex string\) | +| `name` | string | Yes | Name of the checklist item | +| `pos` | string | No | Position of the item \(top, bottom, or positive float\) | +| `checked` | boolean | No | Whether the item should start checked off | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | Created checklist item \(id, name, state, pos, idChecklist\) | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | Item state \(complete or incomplete\) | +| ↳ `pos` | number | Item position on the checklist | +| ↳ `idChecklist` | string | Checklist ID containing the item | + +### `trello_update_checklist_item` + +Check off, uncheck, or rename a Trello checklist item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID that owns the checklist item \(24-character hex string\) | +| `checkItemId` | string | Yes | Checklist item ID to update \(24-character hex string\) | +| `state` | string | No | Set the item state to complete or incomplete | +| `name` | string | No | New name for the checklist item | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | Updated checklist item \(id, name, state, pos, idChecklist\) | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | Item state \(complete or incomplete\) | +| ↳ `pos` | number | Item position on the checklist | +| ↳ `idChecklist` | string | Checklist ID containing the item | + ### `trello_add_label` Attach an existing label to a Trello card @@ -391,6 +519,23 @@ Attach an existing label to a Trello card | --------- | ---- | ----------- | | `labelIds` | array | Label IDs now applied to the card | +### `trello_remove_label` + +Detach a label from a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to detach the label from \(24-character hex string\) | +| `labelId` | string | Yes | ID of the label to detach \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the label was removed from the card | + ### `trello_add_member` Assign a member to a Trello card @@ -408,4 +553,41 @@ Assign a member to a Trello card | --------- | ---- | ----------- | | `memberIds` | array | Member IDs now assigned to the card | +### `trello_remove_member` + +Unassign a member from a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to unassign the member from \(24-character hex string\) | +| `memberId` | string | Yes | ID of the member to unassign \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the member was removed from the card | + +### `trello_list_members` + +List members of a Trello board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Members on the selected board | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | +| `count` | number | Number of members returned | + diff --git a/apps/docs/content/docs/en/integrations/vercel.mdx b/apps/docs/content/docs/en/integrations/vercel.mdx index 361ee626a5c..66b26629ca0 100644 --- a/apps/docs/content/docs/en/integrations/vercel.mdx +++ b/apps/docs/content/docs/en/integrations/vercel.mdx @@ -49,6 +49,7 @@ List deployments for a Vercel project or team | `until` | number | No | Get deployments created before this JavaScript timestamp | | `limit` | number | No | Maximum number of deployments to return per request | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -87,6 +88,7 @@ Get details of a specific Vercel deployment | `deploymentId` | string | Yes | The unique deployment identifier or hostname | | `withGitRepoInfo` | string | No | Whether to add in gitRepo information \(true/false\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -145,6 +147,7 @@ Create a new deployment or redeploy an existing one | `gitSource` | string | No | JSON string defining the Git Repository source to deploy \(e.g. \{"type":"github","repo":"owner/repo","ref":"main"\}\) | | `forceNew` | string | No | Forces a new deployment even if there is a previous similar deployment \(0 or 1\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -174,6 +177,7 @@ Cancel a running Vercel deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | The deployment ID to cancel | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -198,6 +202,7 @@ Delete a Vercel deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | The deployment ID or URL to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -222,6 +227,7 @@ Get build and runtime events for a Vercel deployment | `since` | number | No | Timestamp to start pulling build logs from | | `until` | number | No | Timestamp to stop pulling build logs at | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -250,6 +256,7 @@ List files in a Vercel deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | The deployment ID to list files for | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -279,6 +286,7 @@ Promote a deployment by pointing the production deployment to the given deployme | `projectId` | string | Yes | Project ID or name | | `deploymentId` | string | Yes | The ID of the deployment to promote to production | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -297,7 +305,9 @@ List all projects in a Vercel team or account | `apiKey` | string | Yes | Vercel Access Token | | `search` | string | No | Search projects by name | | `limit` | number | No | Maximum number of projects to return | +| `from` | string | No | Continuation token for pagination, taken from the previous response's pagination.next value. Query only projects updated after this timestamp or continuation token. | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -307,10 +317,13 @@ List all projects in a Vercel team or account | ↳ `id` | string | Project ID | | ↳ `name` | string | Project name | | ↳ `framework` | string | Framework | +| ↳ `rootDirectory` | string | Root directory of the project | +| ↳ `nodeVersion` | string | Node.js version | | ↳ `createdAt` | number | Creation timestamp | | ↳ `updatedAt` | number | Last updated timestamp | | `count` | number | Number of projects returned | | `hasMore` | boolean | Whether more projects are available | +| `nextFrom` | string | Continuation token to pass as `from` to fetch the next page | ### `vercel_get_project` @@ -323,6 +336,7 @@ Get details of a specific Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -331,6 +345,8 @@ Get details of a specific Vercel project | `id` | string | Project ID | | `name` | string | Project name | | `framework` | string | Project framework | +| `rootDirectory` | string | Root directory of the project | +| `nodeVersion` | string | Node.js version | | `createdAt` | number | Creation timestamp | | `updatedAt` | number | Last updated timestamp | | `link` | object | Git repository connection | @@ -353,7 +369,11 @@ Create a new Vercel project | `buildCommand` | string | No | Custom build command | | `outputDirectory` | string | No | Custom output directory | | `installCommand` | string | No | Custom install command | +| `rootDirectory` | string | No | Subdirectory of the repository the project lives in \(for monorepos\) | +| `nodeVersion` | string | No | Node.js version to use \(e.g. 22.x, 20.x, 18.x\) | +| `devCommand` | string | No | Custom dev server command | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -380,7 +400,11 @@ Update an existing Vercel project | `buildCommand` | string | No | Custom build command | | `outputDirectory` | string | No | Custom output directory | | `installCommand` | string | No | Custom install command | +| `rootDirectory` | string | No | Subdirectory of the repository the project lives in \(for monorepos\) | +| `nodeVersion` | string | No | Node.js version to use \(e.g. 22.x, 20.x, 18.x\) | +| `devCommand` | string | No | Custom dev server command | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -402,6 +426,7 @@ Delete a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -420,6 +445,7 @@ Pause a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -440,6 +466,7 @@ Unpause a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -460,6 +487,7 @@ List all domains for a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | | `limit` | number | No | Maximum number of domains to return | #### Output @@ -499,6 +527,7 @@ Add a domain to a Vercel project | `redirectStatusCode` | number | No | HTTP status code for redirect \(301, 302, 307, 308\) | | `gitBranch` | string | No | Git branch to link the domain to | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -531,6 +560,7 @@ Remove a domain from a Vercel project | `projectId` | string | Yes | Project ID or name | | `domain` | string | Yes | Domain name to remove | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -553,6 +583,7 @@ Update a project domain's configuration on Vercel | `redirectStatusCode` | number | No | HTTP status code for redirect \(301, 302, 307, 308\) | | `gitBranch` | string | No | Git branch to link the domain to | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -585,6 +616,7 @@ Verify a Vercel project domain by checking its verification challenge | `projectId` | string | Yes | Project ID or name | | `domain` | string | Yes | Domain name to verify | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -610,7 +642,10 @@ Retrieve environment variables for a Vercel project | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | +| `decrypt` | boolean | No | If true, decrypted variable values are returned instead of ciphertext | +| `gitBranch` | string | No | Filter results to the environment variables for this git branch \(must have target=preview\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -645,6 +680,7 @@ Create an environment variable for a Vercel project | `gitBranch` | string | No | Git branch to associate with the variable \(requires target to include preview\) | | `comment` | string | No | Comment to add context to the variable \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -678,6 +714,7 @@ Update an environment variable for a Vercel project | `gitBranch` | string | No | Git branch to associate with the variable \(requires target to include preview\) | | `comment` | string | No | Comment to add context to the variable \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -705,6 +742,7 @@ Delete an environment variable from a Vercel project | `projectId` | string | Yes | Project ID or name | | `envId` | string | Yes | Environment variable ID to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -723,6 +761,7 @@ List all domains in a Vercel account or team | `apiKey` | string | Yes | Vercel Access Token | | `limit` | number | No | Maximum number of domains to return \(default 20\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -744,6 +783,10 @@ List all domains in a Vercel account or team | ↳ `id` | string | Creator ID | | ↳ `username` | string | Creator username | | ↳ `email` | string | Creator email | +| ↳ `customNameservers` | array | Custom nameservers | +| ↳ `userId` | string | Owner user ID | +| ↳ `teamId` | string | Owner team ID | +| ↳ `transferStartedAt` | number | Transfer start timestamp | | `count` | number | Number of domains returned | | `hasMore` | boolean | Whether more domains are available | @@ -758,6 +801,7 @@ Get information about a specific domain in a Vercel account | `apiKey` | string | Yes | Vercel Access Token | | `domain` | string | Yes | The domain name to retrieve | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -794,6 +838,7 @@ Add a new domain to a Vercel account or team | `apiKey` | string | Yes | Vercel Access Token | | `name` | string | Yes | The domain name to add | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -827,6 +872,7 @@ Delete a domain from a Vercel account or team | `apiKey` | string | Yes | Vercel Access Token | | `domain` | string | Yes | The domain name to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -846,6 +892,7 @@ Get the configuration for a domain in a Vercel account | `apiKey` | string | Yes | Vercel Access Token | | `domain` | string | Yes | The domain name to get configuration for | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -873,6 +920,7 @@ List all DNS records for a domain in a Vercel account | `domain` | string | Yes | The domain name to list records for | | `limit` | number | No | Maximum number of records to return | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -906,10 +954,19 @@ Create a DNS record for a domain in a Vercel account | `domain` | string | Yes | The domain name to create the record for | | `recordName` | string | Yes | The subdomain or record name | | `recordType` | string | Yes | DNS record type \(A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, TXT, NS\) | -| `value` | string | Yes | The value of the DNS record | +| `value` | string | No | The value of the DNS record \(not used for SRV/HTTPS records\) | | `ttl` | number | No | Time to live in seconds | | `mxPriority` | number | No | Priority for MX records | +| `srvTarget` | string | No | Target hostname for SRV records \(required when recordType is SRV\) | +| `srvWeight` | number | No | Weight for SRV records \(required when recordType is SRV\) | +| `srvPort` | number | No | Port for SRV records \(required when recordType is SRV\) | +| `srvPriority` | number | No | Priority for SRV records \(required when recordType is SRV\) | +| `httpsTarget` | string | No | Target hostname for HTTPS records \(required when recordType is HTTPS\) | +| `httpsPriority` | number | No | Priority for HTTPS records \(required when recordType is HTTPS\) | +| `httpsParams` | string | No | Optional service parameters for HTTPS records \(e.g. "alpn=h2,h3"\) | +| `comment` | string | No | A comment to add context on what this DNS record is for \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -933,8 +990,16 @@ Update an existing DNS record for a domain in a Vercel account | `type` | string | No | DNS record type \(A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, TXT, NS\) | | `ttl` | number | No | Time to live in seconds \(60 to 2147483647\) | | `mxPriority` | number | No | Priority for MX records | +| `srvTarget` | string | No | Target hostname for SRV records \(required together when updating SRV data\) | +| `srvWeight` | number | No | Weight for SRV records \(required together when updating SRV data\) | +| `srvPort` | number | No | Port for SRV records \(required together when updating SRV data\) | +| `srvPriority` | number | No | Priority for SRV records \(required together when updating SRV data\) | +| `httpsTarget` | string | No | Target hostname for HTTPS records \(required together when updating HTTPS data\) | +| `httpsPriority` | number | No | Priority for HTTPS records \(required together when updating HTTPS data\) | +| `httpsParams` | string | No | Optional service parameters for HTTPS records \(e.g. "alpn=h2,h3"\) | | `comment` | string | No | A comment to add context on what this DNS record is for \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -963,6 +1028,7 @@ Delete a DNS record for a domain in a Vercel account | `domain` | string | Yes | The domain name the record belongs to | | `recordId` | string | Yes | The ID of the DNS record to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -983,6 +1049,7 @@ List aliases for a Vercel project or team | `domain` | string | No | Filter aliases by domain | | `limit` | number | No | Maximum number of aliases to return | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1014,6 +1081,7 @@ Get details about a specific alias by ID or hostname | `apiKey` | string | Yes | Vercel Access Token | | `aliasId` | string | Yes | Alias ID or hostname to look up | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1042,7 +1110,9 @@ Assign an alias (domain/subdomain) to a deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | Deployment ID to assign the alias to | | `alias` | string | Yes | The domain or subdomain to assign as an alias | +| `redirect` | string | No | Hostname to 307-redirect the alias to instead of serving the deployment directly | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1064,6 +1134,7 @@ Delete an alias by its ID | `apiKey` | string | Yes | Vercel Access Token | | `aliasId` | string | Yes | Alias ID to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1322,6 +1393,7 @@ Create a new deployment check | `externalId` | string | No | External identifier for the check | | `rerequestable` | boolean | No | Whether the check can be rerequested | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1356,6 +1428,7 @@ Get details of a specific deployment check | `deploymentId` | string | Yes | Deployment ID the check belongs to | | `checkId` | string | Yes | Check ID to retrieve | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1389,6 +1462,7 @@ List all checks for a deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | Deployment ID to list checks for | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1432,6 +1506,7 @@ Update an existing deployment check | `path` | string | No | Page path being checked | | `output` | string | No | JSON string with check output metrics | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1466,6 +1541,8 @@ Rerequest a deployment check | `deploymentId` | string | Yes | Deployment ID the check belongs to | | `checkId` | string | Yes | Check ID to rerequest | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | +| `autoUpdate` | boolean | No | Whether to mark the check as running immediately on rerequest | #### Output diff --git a/apps/docs/content/docs/en/integrations/wordpress.mdx b/apps/docs/content/docs/en/integrations/wordpress.mdx index 32bdf1606ed..432851c9a02 100644 --- a/apps/docs/content/docs/en/integrations/wordpress.mdx +++ b/apps/docs/content/docs/en/integrations/wordpress.mdx @@ -29,7 +29,7 @@ In Sim, the WordPress integration enables your agents to automate content publis ## Usage Instructions -Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication. +Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth. @@ -507,7 +507,6 @@ Delete a media item from WordPress.com | --------- | ---- | -------- | ----------- | | `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | | `mediaId` | number | Yes | The ID of the media item to delete | -| `force` | boolean | No | Force delete \(media has no trash, so deletion is permanent\) | #### Output @@ -715,6 +714,86 @@ List categories from WordPress.com | `total` | number | Total number of categories | | `totalPages` | number | Total number of result pages | +### `wordpress_get_category` + +Get a single category from WordPress.com by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `categoryId` | number | Yes | The ID of the category to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `category` | object | The retrieved category | +| ↳ `id` | number | Category ID | +| ↳ `count` | number | Number of posts in this category | +| ↳ `description` | string | Category description | +| ↳ `link` | string | Category archive URL | +| ↳ `name` | string | Category name | +| ↳ `slug` | string | Category slug | +| ↳ `taxonomy` | string | Taxonomy name | +| ↳ `parent` | number | Parent category ID | + +### `wordpress_update_category` + +Update an existing category in WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `categoryId` | number | Yes | The ID of the category to update | +| `name` | string | No | Category name | +| `description` | string | No | Category description | +| `parent` | number | No | Parent category ID for hierarchical categories | +| `slug` | string | No | URL slug for the category | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `category` | object | The updated category | +| ↳ `id` | number | Category ID | +| ↳ `count` | number | Number of posts in this category | +| ↳ `description` | string | Category description | +| ↳ `link` | string | Category archive URL | +| ↳ `name` | string | Category name | +| ↳ `slug` | string | Category slug | +| ↳ `taxonomy` | string | Taxonomy name | +| ↳ `parent` | number | Parent category ID | + +### `wordpress_delete_category` + +Delete a category from WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `categoryId` | number | Yes | The ID of the category to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the category was deleted | +| `category` | object | The deleted category | +| ↳ `id` | number | Category ID | +| ↳ `count` | number | Number of posts in this category | +| ↳ `description` | string | Category description | +| ↳ `link` | string | Category archive URL | +| ↳ `name` | string | Category name | +| ↳ `slug` | string | Category slug | +| ↳ `taxonomy` | string | Taxonomy name | +| ↳ `parent` | number | Parent category ID | + ### `wordpress_create_tag` Create a new tag in WordPress.com @@ -770,6 +849,82 @@ List tags from WordPress.com | `total` | number | Total number of tags | | `totalPages` | number | Total number of result pages | +### `wordpress_get_tag` + +Get a single tag from WordPress.com by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `tagId` | number | Yes | The ID of the tag to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | object | The retrieved tag | +| ↳ `id` | number | Tag ID | +| ↳ `count` | number | Number of posts with this tag | +| ↳ `description` | string | Tag description | +| ↳ `link` | string | Tag archive URL | +| ↳ `name` | string | Tag name | +| ↳ `slug` | string | Tag slug | +| ↳ `taxonomy` | string | Taxonomy name | + +### `wordpress_update_tag` + +Update an existing tag in WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `tagId` | number | Yes | The ID of the tag to update | +| `name` | string | No | Tag name | +| `description` | string | No | Tag description | +| `slug` | string | No | URL slug for the tag | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | object | The updated tag | +| ↳ `id` | number | Tag ID | +| ↳ `count` | number | Number of posts with this tag | +| ↳ `description` | string | Tag description | +| ↳ `link` | string | Tag archive URL | +| ↳ `name` | string | Tag name | +| ↳ `slug` | string | Tag slug | +| ↳ `taxonomy` | string | Taxonomy name | + +### `wordpress_delete_tag` + +Delete a tag from WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `tagId` | number | Yes | The ID of the tag to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the tag was deleted | +| `tag` | object | The deleted tag | +| ↳ `id` | number | Tag ID | +| ↳ `count` | number | Number of posts with this tag | +| ↳ `description` | string | Tag description | +| ↳ `link` | string | Tag archive URL | +| ↳ `name` | string | Tag name | +| ↳ `slug` | string | Tag slug | +| ↳ `taxonomy` | string | Taxonomy name | + ### `wordpress_get_current_user` Get information about the currently authenticated WordPress.com user @@ -874,8 +1029,8 @@ Search across all content types in WordPress.com (posts, pages, media) | `query` | string | Yes | Search query | | `perPage` | number | No | Number of results per request \(default: 10, max: 100\) | | `page` | number | No | Page number for pagination | -| `type` | string | No | Filter by content type: post, page, attachment | -| `subtype` | string | No | Filter by post type slug \(e.g., post, page\) | +| `type` | string | No | Filter by search index type: post, term, or post-format | +| `subtype` | string | No | Filter by subtype within the selected type \(e.g., post or page when type is post\) | #### Output @@ -885,8 +1040,8 @@ Search across all content types in WordPress.com (posts, pages, media) | ↳ `id` | number | Content ID | | ↳ `title` | string | Content title | | ↳ `url` | string | Content URL | -| ↳ `type` | string | Content type \(post, page, attachment\) | -| ↳ `subtype` | string | Post type slug | +| ↳ `type` | string | Content type \(post, term, or post-format\) | +| ↳ `subtype` | string | Subtype within the content type \(e.g., post, page\) | | `total` | number | Total number of results | | `totalPages` | number | Total number of result pages | diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx index 29ea677ea1b..6b21046388d 100644 --- a/apps/sim/app/(auth)/components/support-footer.tsx +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -4,7 +4,16 @@ import { cn } from '@sim/emcn' import { useBrandConfig } from '@/ee/whitelabeling' export interface SupportFooterProps { - position?: 'fixed' | 'absolute' + /** + * `fixed`/`absolute` pin the footer over the page (short, centered forms + * only — content must never render underneath it). `static` renders it in + * normal document flow after the content, which is required for pages with + * unbounded content height (e.g. the resume gate's HITL form): an + * absolutely-positioned footer with no reserved space is not pushed down by + * flow content, so it silently overlaps and eats clicks on whatever content + * ends up in its footprint. + */ + position?: 'fixed' | 'absolute' | 'static' } export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { @@ -13,7 +22,8 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { return (
diff --git a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx index 7f41bd212d7..c3ea3a614c3 100644 --- a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx +++ b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx @@ -18,5 +18,5 @@ interface InterfacesShellProps { } export function InterfacesShell({ children }: InterfacesShellProps) { - return }>{children} + return }>{children} } diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index f148c358fd5..e6bf318c428 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -103,28 +103,51 @@ function getBlockNameFromSnapshot( } } +const DISPLAY_VALUE_PREVIEW_MAX_CHARS = 5000 + +function truncateForPreview(text: string): { text: string; truncated: boolean } { + if (text.length <= DISPLAY_VALUE_PREVIEW_MAX_CHARS) return { text, truncated: false } + return { text: text.slice(0, DISPLAY_VALUE_PREVIEW_MAX_CHARS), truncated: true } +} + function renderStructuredValuePreview(value: unknown) { - if (value === null || value === undefined) { + if (value === null || value === undefined || value === '') { return } if (typeof value === 'object') { + const prettyPrinted = JSON.stringify(value, null, 2) + const { text, truncated } = truncateForPreview(prettyPrinted) return (
+ {truncated && ( +

+ Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '} + {prettyPrinted.length.toLocaleString()} characters shown). +

+ )}
) } - const stringValue = String(value) + const { text: stringValue, truncated } = truncateForPreview(String(value)) return ( -
- {stringValue} +
+
+ {truncated ? `${stringValue}…` : stringValue} +
+ {truncated && ( +

+ Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '} + {String(value).length.toLocaleString()} characters shown). +

+ )}
) } @@ -516,50 +539,60 @@ export default function ResumeExecutionPage({ const handleResume = useCallback( async () => { - if (!selectedContextId || !selectedDetail) return + if (!selectedContextId || !selectedDetail) { + setError('No pause point is selected. Refresh and try again.') + return + } setLoadingAction(true) setError(null) setMessage(null) let resumePayload: any - if (isHumanMode && hasInputFormat) { - const errors: Record = {} - const submission: Record = {} - for (const field of inputFormatFields) { - const rawValue = formValues[field.name] ?? '' - const hasValue = - field.type === 'boolean' - ? rawValue === 'true' || rawValue === 'false' - : rawValue.trim().length > 0 && rawValue !== '__unset__' - if (!hasValue || rawValue === '__unset__') { - if (field.required) errors[field.name] = 'This field is required.' - continue - } - const { value, error: parseError } = parseFormValue(field, rawValue) - if (parseError) { - errors[field.name] = parseError - continue + try { + if (isHumanMode && hasInputFormat) { + const errors: Record = {} + const submission: Record = {} + for (const field of inputFormatFields) { + const rawValue = formValues[field.name] ?? '' + const hasValue = + field.type === 'boolean' + ? rawValue === 'true' || rawValue === 'false' + : rawValue.trim().length > 0 && rawValue !== '__unset__' + if (!hasValue || rawValue === '__unset__') { + if (field.required) errors[field.name] = 'This field is required.' + continue + } + const { value, error: parseError } = parseFormValue(field, rawValue) + if (parseError) { + errors[field.name] = parseError + continue + } + if (value !== undefined) submission[field.name] = value } - if (value !== undefined) submission[field.name] = value - } - if (Object.keys(errors).length > 0) { - setFormErrors(errors) - setLoadingAction(false) - return - } - setFormErrors({}) - resumePayload = { submission } - } else { - let parsedInput: any - if (resumeInput && resumeInput.trim().length > 0) { - try { - parsedInput = JSON.parse(resumeInput) - } catch { - setError('Resume input must be valid JSON.') + if (Object.keys(errors).length > 0) { + setFormErrors(errors) + setError('Fix the highlighted fields before resuming.') setLoadingAction(false) return } + setFormErrors({}) + resumePayload = { submission } + } else { + let parsedInput: any + if (resumeInput && resumeInput.trim().length > 0) { + try { + parsedInput = JSON.parse(resumeInput) + } catch { + setError('Resume input must be valid JSON.') + setLoadingAction(false) + return + } + } + resumePayload = parsedInput } - resumePayload = parsedInput + } catch (err: any) { + setError(err?.message || 'Failed to prepare resume payload.') + setLoadingAction(false) + return } try { const { ok, payload } = await resumeMutation.mutateAsync({ diff --git a/apps/sim/app/(landing)/careers/careers.tsx b/apps/sim/app/(landing)/careers/careers.tsx index d1c53b5f4e6..b8c8de250d3 100644 --- a/apps/sim/app/(landing)/careers/careers.tsx +++ b/apps/sim/app/(landing)/careers/careers.tsx @@ -9,7 +9,6 @@ import { JobGroups, } from '@/app/(landing)/careers/components/job-board' import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params' -import { TrustedBy } from '@/app/(landing)/components/trusted-by' interface CareersProps { searchParams: Promise @@ -87,8 +86,6 @@ export default async function Careers({ searchParams }: CareersProps) { > - - ) diff --git a/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx index 5f0be48b263..8962a59843d 100644 --- a/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx +++ b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx @@ -83,8 +83,8 @@ function JobRow({ posting }: JobRowProps) { target='_blank' rel='noopener noreferrer' className={cn( - 'group flex items-center justify-between gap-6 border-[var(--border)] border-t py-5', - 'transition-colors hover:bg-[var(--surface-hover)]' + '-mx-3 group flex items-center justify-between gap-6 rounded-lg border-[var(--border)]', + 'border-t px-3 py-5 transition-colors hover:bg-[var(--surface-hover)]' )} >
diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx index 8a18a8094c9..28f8d516ee5 100644 --- a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx +++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx @@ -12,6 +12,7 @@ import { } from '@/lib/api/contracts/contact' import { flattenFieldErrors } from '@/lib/api/contracts/primitives' import { getEnv } from '@/lib/core/config/env' +import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent } from '@/lib/posthog/client' import { useSubmitContact } from '@/hooks/queries/contact' @@ -175,6 +176,13 @@ export function ContactForm() { ) } + const canSubmit = + quickValidateEmail(form.email.trim()).isValid && + form.name.trim().length > 0 && + form.topic.length > 0 && + form.subject.trim().length > 0 && + form.message.trim().length > 0 + const isBusy = contactMutation.isPending || isSubmitting const submitError = contactMutation.isError @@ -336,7 +344,7 @@ export function ContactForm() { variant='primary' flush fullWidth - disabled={isBusy} + disabled={isBusy || !canSubmit} className='mt-1 justify-center [&>span]:flex-none' > {isBusy ? 'Sending…' : 'Send message'} diff --git a/apps/sim/app/api/tools/onepassword/get-item-file/route.ts b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts new file mode 100644 index 00000000000..65efec88e06 --- /dev/null +++ b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { onePasswordGetItemFileContract } from '@/lib/api/contracts/tools/onepassword' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + connectRequest, + createOnePasswordClient, + findItemFileAttributes, + resolveCredentials, +} from '../utils' + +const logger = createLogger('OnePasswordGetItemFileAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized 1Password get-item-file attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest( + onePasswordGetItemFileContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + const creds = resolveCredentials(params) + + logger.info( + `[${requestId}] Downloading file ${params.fileId} from item ${params.itemId} (${creds.mode} mode)` + ) + + if (creds.mode === 'service_account') { + const client = await createOnePasswordClient(creds.serviceAccountToken!) + const item = await client.items.get(params.vaultId, params.itemId) + const attr = findItemFileAttributes(item, params.fileId) + if (!attr) { + return NextResponse.json({ error: 'File not found on item' }, { status: 404 }) + } + + const content = await client.items.files.read(params.vaultId, params.itemId, attr) + return NextResponse.json({ + file: { + name: attr.name, + mimeType: 'application/octet-stream', + data: Buffer.from(content).toString('base64'), + size: attr.size, + }, + }) + } + + const metaResponse = await connectRequest({ + serverUrl: creds.serverUrl!, + apiKey: creds.apiKey!, + path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}`, + method: 'GET', + }) + if (!metaResponse.ok) { + const metaData = await metaResponse.json().catch(() => ({})) + return NextResponse.json( + { error: metaData.message || 'Failed to get file metadata' }, + { status: metaResponse.status } + ) + } + const meta = await metaResponse.json() + + const contentResponse = await connectRequest({ + serverUrl: creds.serverUrl!, + apiKey: creds.apiKey!, + path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}/content`, + method: 'GET', + }) + if (!contentResponse.ok) { + const errorData = await contentResponse.json().catch(() => ({})) + return NextResponse.json( + { error: errorData.message || 'Failed to download file content' }, + { status: contentResponse.status } + ) + } + + const buffer = Buffer.from(await contentResponse.arrayBuffer()) + return NextResponse.json({ + file: { + name: meta.name ?? 'attachment', + mimeType: contentResponse.headers.get('content-type') || 'application/octet-stream', + data: buffer.toString('base64'), + size: meta.size ?? buffer.length, + }, + }) + } catch (error) { + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Get item file failed:`, error) + return NextResponse.json({ error: `Failed to get item file: ${message}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/onepassword/list-items/route.ts b/apps/sim/app/api/tools/onepassword/list-items/route.ts index daeb42e807d..395d0955ced 100644 --- a/apps/sim/app/api/tools/onepassword/list-items/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-items/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, + matchesFilter, normalizeSdkItemOverview, resolveCredentials, } from '../utils' @@ -45,11 +46,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const normalized = items.map(normalizeSdkItemOverview) if (params.filter) { - const filterLower = params.filter.toLowerCase() - const filtered = normalized.filter( - (item) => - item.title?.toLowerCase().includes(filterLower) || - item.id?.toLowerCase().includes(filterLower) + const filter = params.filter + const filtered = normalized.filter((item) => + matchesFilter(item.title ?? '', item.id ?? '', filter) ) return NextResponse.json(filtered) } diff --git a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts index fa4011daa70..3638db1c2d7 100644 --- a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, + matchesFilter, normalizeSdkVault, resolveCredentials, } from '../utils' @@ -45,11 +46,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const normalized = vaults.map(normalizeSdkVault) if (params.filter) { - const filterLower = params.filter.toLowerCase() - const filtered = normalized.filter( - (v) => - v.name?.toLowerCase().includes(filterLower) || v.id?.toLowerCase().includes(filterLower) - ) + const filter = params.filter + const filtered = normalized.filter((v) => matchesFilter(v.name ?? '', v.id ?? '', filter)) return NextResponse.json(filtered) } diff --git a/apps/sim/app/api/tools/onepassword/replace-item/route.ts b/apps/sim/app/api/tools/onepassword/replace-item/route.ts index 0f2ee44b76b..67d7a7b10f8 100644 --- a/apps/sim/app/api/tools/onepassword/replace-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/replace-item/route.ts @@ -1,4 +1,3 @@ -import type { Item } from '@1password/sdk' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -8,12 +7,11 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + connectItemToSdkItem, connectRequest, createOnePasswordClient, normalizeSdkItem, resolveCredentials, - toSdkCategory, - toSdkFieldType, } from '../utils' const logger = createLogger('OnePasswordReplaceItemAPI') @@ -49,40 +47,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const client = await createOnePasswordClient(creds.serviceAccountToken!) const existing = await client.items.get(params.vaultId, params.itemId) - - const sdkItem = { - ...existing, - id: params.itemId, - title: itemData.title || existing.title, - category: itemData.category ? toSdkCategory(itemData.category) : existing.category, - vaultId: params.vaultId, - fields: itemData.fields - ? (itemData.fields as Array>).map((f) => ({ - id: f.id || generateId().slice(0, 8), - title: f.label || f.title || '', - fieldType: toSdkFieldType(f.type || 'STRING'), - value: f.value || '', - sectionId: f.section?.id ?? f.sectionId, - })) - : existing.fields, - sections: itemData.sections - ? (itemData.sections as Array>).map((s) => ({ - id: s.id || '', - title: s.label || s.title || '', - })) - : existing.sections, - notes: itemData.notes ?? existing.notes, - tags: itemData.tags ?? existing.tags, - websites: - itemData.urls || itemData.websites - ? (itemData.urls ?? itemData.websites ?? []).map((u: Record) => ({ - url: u.href || u.url || '', - label: u.label || '', - autofillBehavior: 'AnywhereOnWebsite' as const, - })) - : existing.websites, - } as Item - + const sdkItem = connectItemToSdkItem(itemData, existing) const result = await client.items.put(sdkItem) return NextResponse.json(normalizeSdkItem(result)) } diff --git a/apps/sim/app/api/tools/onepassword/update-item/route.ts b/apps/sim/app/api/tools/onepassword/update-item/route.ts index f027e95c45d..70fa927b992 100644 --- a/apps/sim/app/api/tools/onepassword/update-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/update-item/route.ts @@ -7,6 +7,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + connectItemToSdkItem, connectRequest, createOnePasswordClient, normalizeSdkItem, @@ -45,13 +46,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (creds.mode === 'service_account') { const client = await createOnePasswordClient(creds.serviceAccountToken!) - const item = await client.items.get(params.vaultId, params.itemId) + const existing = await client.items.get(params.vaultId, params.itemId) + // Patch operations are documented and typed against the Connect-shaped + // vocabulary (label/type/section.id) that get_item/create_item/replace_item + // return — apply them to that normalized view, then convert back to the + // SDK's vocabulary (title/fieldType/sectionId) before writing. Patching the + // raw SDK item directly would silently no-op most field/category writes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const connectItem = normalizeSdkItem(existing) as Record for (const op of ops) { - applyPatch(item, op) + applyPatch(connectItem, op) } - const result = await client.items.put(item) + const sdkItem = connectItemToSdkItem(connectItem, existing) + const result = await client.items.put(sdkItem) return NextResponse.json(normalizeSdkItem(result)) } @@ -104,7 +113,7 @@ function applyPatch(item: Record, op: JsonPatchOperation) { for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i] if (Array.isArray(target)) { - target = target[Number(seg)] + target = arrayElementForSegment(target, seg) } else { target = target[seg] } @@ -117,15 +126,37 @@ function applyPatch(item: Record, op: JsonPatchOperation) { if (Array.isArray(target) && lastSeg === '-') { target.push(op.value) } else if (Array.isArray(target)) { - target[Number(lastSeg)] = op.value + const index = arrayIndexForSegment(target, lastSeg) + if (index !== -1) target[index] = op.value } else { target[lastSeg] = op.value } } else if (op.op === 'remove') { if (Array.isArray(target)) { - target.splice(Number(lastSeg), 1) + const index = arrayIndexForSegment(target, lastSeg) + if (index !== -1) target.splice(index, 1) } else { delete target[lastSeg] } } } + +/** + * Resolves an array element for a JSON Patch path segment. 1Password's PATCH API + * addresses items in the `fields`/`sections` arrays by their `id`, not by numeric + * array index (e.g. `/fields/{fieldId}/value`), so a numeric-looking segment is + * only treated as a literal index when no element's `id` matches it. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function arrayIndexForSegment(target: any[], segment: string): number { + const byId = target.findIndex((el) => el && typeof el === 'object' && el.id === segment) + if (byId !== -1) return byId + const index = Number(segment) + return Number.isInteger(index) && index >= 0 && index < target.length ? index : -1 +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function arrayElementForSegment(target: any[], segment: string): any { + const index = arrayIndexForSegment(target, segment) + return index === -1 ? undefined : target[index] +} diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index 87c5e090da0..b78cf0d511c 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -1,9 +1,11 @@ import dns from 'dns/promises' import type { + FileAttributes, Item, ItemCategory, ItemField, ItemFieldType, + ItemFile, ItemOverview, ItemSection, VaultOverview, @@ -11,6 +13,7 @@ import type { } from '@1password/sdk' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' import { @@ -102,10 +105,19 @@ interface NormalizedField { entropy: null } +/** Normalized attached-file metadata shape matching the Connect API response. */ +export interface NormalizedItemFile { + id: string + name: string + size: number + section: { id: string } | null +} + /** Normalized full item shape matching the Connect API response. */ export interface NormalizedItem extends NormalizedItemOverview { fields: NormalizedField[] sections: Array<{ id: string; label: string }> + files: NormalizedItemFile[] } /** @@ -323,9 +335,11 @@ export interface ConnectResponse { ok: boolean status: number statusText: string + headers: { get: (name: string) => string | null } // eslint-disable-next-line @typescript-eslint/no-explicit-any json: () => Promise text: () => Promise + arrayBuffer: () => Promise } /** Proxy a request to the 1Password Connect Server. */ @@ -431,6 +445,24 @@ export function normalizeSdkItem(item: Item): NormalizedItem { id: section.id, label: section.title, })), + files: [ + ...(item.files ?? []).map((file: ItemFile) => ({ + id: file.attributes.id, + name: file.attributes.name, + size: file.attributes.size, + section: file.sectionId ? { id: file.sectionId } : null, + })), + ...(item.document + ? [ + { + id: item.document.id, + name: item.document.name, + size: item.document.size, + section: null, + }, + ] + : []), + ], createdAt: item.createdAt instanceof Date ? item.createdAt.toISOString() : (item.createdAt ?? null), updatedAt: @@ -439,6 +471,98 @@ export function normalizeSdkItem(item: Item): NormalizedItem { } } +/** + * Find an attached file's SDK {@link FileAttributes} on an item by file ID. + * Checks both the `files` array and the single `document` attribute that + * Document-category items carry instead of a `files` entry. + */ +export function findItemFileAttributes(item: Item, fileId: string): FileAttributes | undefined { + if (item.document?.id === fileId) return item.document + return item.files?.find((file) => file.attributes.id === fileId)?.attributes +} + +/** + * Convert a Connect-shaped item (the vocabulary `normalizeSdkItem` produces and + * this integration's tools document — `label`/`type`/`section: {id}`) back into + * an SDK-compatible {@link Item} for `client.items.put()`. Falls back to `existing` + * for any array the caller didn't provide, so partial input (e.g. Replace Item's + * optional fields) is preserved. + * + * Service Account mode must always convert through this function before calling + * `put()` — never apply a Connect-shaped JSON Patch directly onto a raw SDK + * {@link Item}, since SDK field/category vocabulary differs from Connect's + * (`title` vs `label`, `fieldType` vs `type`, `sectionId` vs `section.id`, SDK + * category enum strings vs Connect's SCREAMING_SNAKE_CASE) and silently no-ops or + * corrupts the write otherwise. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function connectItemToSdkItem(connectItem: Record, existing: Item): Item { + const existingFieldsById = new Map((existing.fields ?? []).map((f) => [f.id, f])) + const existingSectionsById = new Map((existing.sections ?? []).map((s) => [s.id, s])) + + return { + ...existing, + id: existing.id, + vaultId: existing.vaultId, + title: connectItem.title || existing.title, + category: connectItem.category ? toSdkCategory(connectItem.category) : existing.category, + fields: Array.isArray(connectItem.fields) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectItem.fields.map((f: Record) => ({ + // Preserve any SDK-only metadata (e.g. password-generation `details`) + // on fields that already existed — only brand-new fields start bare. + ...(f.id ? existingFieldsById.get(f.id) : undefined), + id: f.id || generateId().slice(0, 8), + title: f.label || f.title || '', + fieldType: toSdkFieldType(f.type || 'STRING'), + value: f.value || '', + sectionId: f.section?.id ?? f.sectionId, + })) + : existing.fields, + sections: Array.isArray(connectItem.sections) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectItem.sections.map((s: Record) => ({ + ...(s.id ? existingSectionsById.get(s.id) : undefined), + id: s.id || '', + title: s.label || s.title || '', + })) + : existing.sections, + notes: connectItem.notes ?? existing.notes, + tags: connectItem.tags ?? existing.tags, + websites: Array.isArray(connectItem.urls ?? connectItem.websites) + ? (connectItem.urls ?? connectItem.websites).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (u: Record) => ({ + url: u.href || u.url || '', + label: u.label || '', + autofillBehavior: 'AnywhereOnWebsite' as const, + }) + ) + : existing.websites, + } as Item +} + +/** + * Best-effort SCIM `eq` filter matcher for Service Account mode, which has no + * server-side filtering (unlike Connect, whose `filter` query param is forwarded + * verbatim and evaluated by the Connect server). Recognizes `attribute eq "value"` + * (quotes optional) as an exact, case-insensitive match against the named attribute + * — `id` compares against the id, anything else (name/title/etc.) against the + * display value; anything that doesn't parse as `eq` falls back to a + * case-insensitive substring match against both so the field remains useful for + * free-text search. + */ +export function matchesFilter(value: string, id: string, filter: string): boolean { + const eqMatch = filter.match(/^\s*(\S+)\s+eq\s+"?([^"]*)"?\s*$/i) + if (eqMatch) { + const [, attribute, needle] = eqMatch + const target = attribute.toLowerCase() === 'id' ? id : value + return target.toLowerCase() === needle.toLowerCase() + } + const needle = filter.toLowerCase() + return value.toLowerCase().includes(needle) || id.toLowerCase().includes(needle) +} + /** Convert a Connect-style category string to the SDK category string. */ export function toSdkCategory(category: string): `${ItemCategory}` { return CONNECT_TO_SDK_CATEGORY[category] ?? 'Login' diff --git a/apps/sim/app/api/tools/sharepoint/download-file/route.ts b/apps/sim/app/api/tools/sharepoint/download-file/route.ts new file mode 100644 index 00000000000..1d9adcd5552 --- /dev/null +++ b/apps/sim/app/api/tools/sharepoint/download-file/route.ts @@ -0,0 +1,178 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { sharepointDownloadFileContract } from '@/lib/api/contracts/tools/microsoft' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +export const dynamic = 'force-dynamic' + +/** Microsoft Graph API error response structure */ +interface GraphApiError { + error?: { + code?: string + message?: string + } +} + +/** Microsoft Graph API drive item metadata response */ +interface DriveItemMetadata { + id?: string + name?: string + folder?: Record + file?: { + mimeType?: string + } +} + +const logger = createLogger('SharepointDownloadFileAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized SharePoint download attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + const parsed = await parseRequest(sharepointDownloadFileContract, request, {}) + if (!parsed.success) return parsed.response + const { accessToken, driveId, itemId, fileName } = parsed.data.body + const authHeader = `Bearer ${accessToken}` + + logger.info(`[${requestId}] Getting file metadata from SharePoint`, { driveId, itemId }) + + const metadataUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}` + const metadataUrlValidation = await validateUrlWithDNS(metadataUrl, 'metadataUrl') + if (!metadataUrlValidation.isValid) { + return NextResponse.json( + { success: false, error: metadataUrlValidation.error }, + { status: 400 } + ) + } + + const metadataResponse = await secureFetchWithPinnedIP( + metadataUrl, + metadataUrlValidation.resolvedIP!, + { + headers: { Authorization: authHeader }, + } + ) + + if (!metadataResponse.ok) { + const errorDetails = (await metadataResponse.json().catch(() => ({}))) as GraphApiError + logger.error(`[${requestId}] Failed to get file metadata`, { + status: metadataResponse.status, + error: errorDetails, + }) + return NextResponse.json( + { success: false, error: errorDetails.error?.message || 'Failed to get file metadata' }, + { status: 400 } + ) + } + + const metadata = (await metadataResponse.json()) as DriveItemMetadata + + if (metadata.folder && !metadata.file) { + logger.error(`[${requestId}] Attempted to download a folder`, { + itemId: metadata.id, + itemName: metadata.name, + }) + return NextResponse.json( + { + success: false, + error: `Cannot download folder "${metadata.name}". Please select a file instead.`, + }, + { status: 400 } + ) + } + + const mimeType = metadata.file?.mimeType || 'application/octet-stream' + + logger.info(`[${requestId}] Downloading file from SharePoint`, { driveId, itemId, mimeType }) + + const downloadUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/content` + const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl') + if (!downloadUrlValidation.isValid) { + return NextResponse.json( + { success: false, error: downloadUrlValidation.error }, + { status: 400 } + ) + } + + const downloadResponse = await secureFetchWithPinnedIP( + downloadUrl, + downloadUrlValidation.resolvedIP!, + { + headers: { Authorization: authHeader }, + // The content endpoint 302s to a preauthenticated URL on a different origin that needs no auth. + stripAuthOnRedirect: true, + maxResponseBytes: MAX_FILE_SIZE, + } + ) + + if (!downloadResponse.ok) { + const downloadError = (await downloadResponse.json().catch(() => ({}))) as GraphApiError + logger.error(`[${requestId}] Failed to download file`, { + status: downloadResponse.status, + error: downloadError, + }) + return NextResponse.json( + { success: false, error: downloadError.error?.message || 'Failed to download file' }, + { status: 400 } + ) + } + + const arrayBuffer = await downloadResponse.arrayBuffer() + const fileBuffer = Buffer.from(arrayBuffer) + + const resolvedName = fileName || metadata.name || 'download' + + logger.info(`[${requestId}] File downloaded successfully`, { + driveId, + itemId, + name: resolvedName, + size: fileBuffer.length, + mimeType, + }) + + const base64Data = fileBuffer.toString('base64') + + return NextResponse.json({ + success: true, + output: { + file: { + name: resolvedName, + mimeType, + data: base64Data, + size: fileBuffer.length, + }, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error downloading SharePoint file:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Unknown error occurred'), + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 3c85a7bd7d1..55bb4eec935 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -135,8 +135,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .join('/') const uploadUrl = driveId - ? `https://graph.microsoft.com/v1.0/drives/${driveId}/root:${encodedPath}:/content` - : `https://graph.microsoft.com/v1.0/sites/${siteId}/drive/root:${encodedPath}:/content` + ? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:${encodedPath}:/content` + : `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drive/root:${encodedPath}:/content` logger.info(`[${requestId}] Uploading to: ${uploadUrl}`) diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index 3e2dcd4cdd6..7f02f7791ea 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -11,6 +11,7 @@ import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' +import { encodeStoragePath, encodeStorageSegment } from '@/tools/supabase/utils' export const dynamic = 'force-dynamic' @@ -185,7 +186,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: projectValidation.error }, { status: 400 }) } - const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}` + const encodedBucket = encodeStorageSegment(validatedData.bucket) + const encodedPath = encodeStoragePath(fullPath) + const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${encodedBucket}/${encodedPath}` const headers: Record = { apikey: validatedData.apiKey, @@ -248,7 +251,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { path: fullPath, }) - const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}` + const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${encodedBucket}/${encodedPath}` return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts new file mode 100644 index 00000000000..a8854c4afdf --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts @@ -0,0 +1,202 @@ +/** + * Tests for the workflow deployed-state API route. + * Covers internal-JWT authorization (acting user required + workspace read + * permission) and the unchanged session path. + * + * @vitest-environment node + */ + +import { + workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyInternalToken } = vi.hoisted(() => ({ + mockVerifyInternalToken: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ + verifyInternalToken: mockVerifyInternalToken, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) + +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) + +import { GET } from './route' + +const mockAuthorizeWorkflowByWorkspacePermission = + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission +const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState +const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions + +const DEPLOYED_STATE = { + blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, +} + +function createRequest(options?: { bearerToken?: string }) { + const headers: Record = {} + if (options?.bearerToken) { + headers.Authorization = `Bearer ${options.bearerToken}` + } + return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers }) +} + +const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) }) + +describe('GET /api/workflows/[id]/deployed', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyInternalToken.mockResolvedValue({ valid: false }) + mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE) + }) + + describe('internal JWT path', () => { + it('returns 200 when the token carries a user with read permission', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, + workspacePermission: 'read', + }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.deployedState).toEqual(DEPLOYED_STATE) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'workflow-123', + userId: 'user-123', + action: 'read', + }) + expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled() + }) + + it('returns 403 when the acting user lacks read permission', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Unauthorized: Access denied to read this workflow', + workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, + workspacePermission: null, + }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Unauthorized: Access denied to read this workflow') + expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() + }) + + it('returns 403 when the token carries no acting user (fail closed)', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Forbidden') + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Workflow not found') + expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() + }) + }) + + describe('session path', () => { + it('returns 200 when session permissions validate', async () => { + mockValidateWorkflowPermissions.mockResolvedValue({ + error: null, + session: { user: { id: 'user-123' } }, + workflow: { id: 'workflow-123' }, + }) + + const response = await GET(createRequest(), routeParams()) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.deployedState).toEqual(DEPLOYED_STATE) + expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith( + 'workflow-123', + expect.any(String), + 'read' + ) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('propagates validateWorkflowPermissions errors unchanged', async () => { + mockValidateWorkflowPermissions.mockResolvedValue({ + error: { message: 'Unauthorized', status: 401 }, + session: null, + workflow: null, + }) + + const response = await GET(createRequest(), routeParams()) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Unauthorized') + }) + + it('falls back to session validation when the bearer token is not a valid internal token', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: false }) + mockValidateWorkflowPermissions.mockResolvedValue({ + error: { message: 'Unauthorized', status: 401 }, + session: null, + workflow: null, + }) + + const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams()) + + expect(response.status).toBe(401) + expect(mockValidateWorkflowPermissions).toHaveBeenCalled() + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + }) + + it('returns null deployedState when loading the snapshot fails', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, + workspacePermission: 'admin', + }) + mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment')) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.deployedState).toBeNull() + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index d68f2b6d6ed..60e8feaf7ec 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { NextRequest, NextResponse } from 'next/server' import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' @@ -19,6 +20,18 @@ function addNoCacheHeaders(response: NextResponse): NextResponse { return response } +/** + * GET /api/workflows/[id]/deployed + * Returns the active deployed state snapshot for a workflow. + * + * Internal (server-to-server) calls must carry the acting user in the internal + * JWT payload (`generateInternalToken(userId)` — the executor's + * `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that + * user with the same workspace-read semantics as the sibling + * `/api/workflows/[id]` route. Internal calls without a user id are rejected + * (fail closed). Session calls are authorized via + * `validateWorkflowPermissions` as before. + */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -29,14 +42,39 @@ export const GET = withRouteHandler( try { const authHeader = request.headers.get('authorization') let isInternalCall = false + let internalCallUserId: string | undefined if (authHeader?.startsWith('Bearer ')) { const token = authHeader.split(' ')[1] const verification = await verifyInternalToken(token) isInternalCall = verification.valid + internalCallUserId = verification.userId } - if (!isInternalCall) { + if (isInternalCall) { + if (!internalCallUserId) { + logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`) + return addNoCacheHeaders(createErrorResponse('Forbidden', 403)) + } + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: id, + userId: internalCallUserId, + action: 'read', + }) + if (!authorization.workflow) { + logger.warn(`[${requestId}] Workflow ${id} not found for internal call`) + return addNoCacheHeaders(createErrorResponse('Workflow not found', 404)) + } + if (!authorization.allowed) { + logger.warn( + `[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}` + ) + return addNoCacheHeaders( + createErrorResponse(authorization.message || 'Access denied', authorization.status) + ) + } + } else { const { error } = await validateWorkflowPermissions(id, requestId, 'read') if (error) { const response = createErrorResponse(error.message, error.status) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 246a62ab694..4cd544c27bb 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -527,6 +527,7 @@ async function handleExecutePost( startBlockId, stopAfterBlockId, runFromBlock: rawRunFromBlock, + parentWorkspaceId, } = validation.data const triggerBlockId = parsedTriggerBlockId ?? startBlockId @@ -642,6 +643,7 @@ async function handleExecutePost( stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth + parentWorkspaceId: _parentWorkspaceId, ...rest } = body return Object.keys(rest).length > 0 ? rest : validatedInput @@ -729,6 +731,25 @@ async function handleExecutePost( ) } + /** + * Workflow-in-workflow invocations (e.g. the agent `workflow_executor` + * tool) declare the parent execution's workspace. Reject execution when + * the target workflow lives in a different workspace so a stale or + * foreign workflow id cannot silently execute with the parent's context. + * The error intentionally omits the target's workspace id. + */ + if (parentWorkspaceId && workflowAuthorization.workflow?.workspaceId !== parentWorkspaceId) { + reqLogger.warn('Blocked cross-workspace child workflow execution', { + parentWorkspaceId, + }) + return NextResponse.json( + { + error: `Child workflow ${workflowId} belongs to a different workspace and cannot be executed`, + }, + { status: 403 } + ) + } + if (req.signal.aborted) { return clientCancelledResponse() } diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index e717958e081..7f2d2a4eb9a 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -19,7 +19,10 @@ import { loadForkDependentValues, } from '@/lib/workspaces/fork/mapping/dependent-value-store' import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources' -import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs' +import { + annotateForkClearedRefSourceLiveness, + collectForkClearedRefCandidates, +} from '@/lib/workspaces/fork/promote/cleared-refs' import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan' import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references' @@ -127,15 +130,21 @@ export const GET = withRouteHandler( sourceLabels.set(`${kind}:${candidate.id}`, candidate.label) } const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name])) - const clearedRefs = collectForkClearedRefCandidates({ - items: plan.items, - sourceStates, - resolver: plan.resolver, - workflowIdMap: plan.workflowIdMap, - resolveBlockId, - sourceLabels, - sourceWorkflowNames, - }) + // Annotate each reference-cause entry's source liveness so the client can phrase the blocker + // reason (a deleted source can't be copied - it must be mapped to a live target resource). + const clearedRefs = await annotateForkClearedRefSourceLiveness( + db, + auth.sourceWorkspaceId, + collectForkClearedRefCandidates({ + items: plan.items, + sourceStates, + resolver: plan.resolver, + workflowIdMap: plan.workflowIdMap, + resolveBlockId, + sourceLabels, + sourceWorkflowNames, + }) + ) const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({ kind: reference.kind, diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts index 6b6228620eb..7cfadf34bed 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts @@ -48,6 +48,7 @@ export const POST = withRouteHandler( redeployed: result.redeployed, deployFailed: result.deployFailed, unmappedRequired: result.unmappedRequired, + blockers: result.blockers, needsConfiguration: result.needsConfiguration, clearedOptional: result.clearedOptional, } diff --git a/apps/sim/app/f/[token]/public-file-auth-shell.tsx b/apps/sim/app/f/[token]/public-file-auth-shell.tsx index bc4e18c3f9d..5adb3ae8fd1 100644 --- a/apps/sim/app/f/[token]/public-file-auth-shell.tsx +++ b/apps/sim/app/f/[token]/public-file-auth-shell.tsx @@ -15,7 +15,7 @@ interface PublicFileAuthShellProps { */ export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) { return ( - }> + }>

diff --git a/apps/sim/app/invite/components/layout.tsx b/apps/sim/app/invite/components/layout.tsx index 8f7c0bb2fa0..614e7c69356 100644 --- a/apps/sim/app/invite/components/layout.tsx +++ b/apps/sim/app/invite/components/layout.tsx @@ -10,5 +10,5 @@ interface InviteLayoutProps { * so the invite-to-workspace flow is visually aligned with the rest of auth. */ export default function InviteLayout({ children }: InviteLayoutProps) { - return }>{children} + return }>{children} } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx index 964aadd907e..82ffbf88cae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx @@ -1,8 +1,7 @@ 'use client' import { useId, useMemo, useState } from 'react' -import { Checkbox, ChevronDown, ChipInput, cn } from '@sim/emcn' -import { Search } from 'lucide-react' +import { Checkbox, ChevronDown, cn } from '@sim/emcn' import { ForkFileTree, type ForkFlatFile, @@ -15,14 +14,11 @@ export interface ForkResourcePickerItem { label: string } -/** Show the inline search once a kind has more entries than fit comfortably. */ -const SEARCH_THRESHOLD = 8 - interface ResourceKindRowProps { label: string items: ForkResourcePickerItem[] selected: Set - /** Toggle the given ids on/off. Used for select-all over the currently-VISIBLE (filtered) subset. */ + /** Toggle the given ids on/off. Used by the select-all header checkbox. */ onToggleMany: (ids: string[], checked: boolean) => void onToggleItem: (id: string, checked: boolean) => void disabled?: boolean @@ -30,10 +26,10 @@ interface ResourceKindRowProps { /** * One expandable resource kind in the fork / sync copy picker: a tri-state "select all" header - * (count of selected / total) plus, when expanded, a searchable scrollable list of individual - * resources so the user can copy a specific subset. Shared by the fork modal's "Copy resources" - * and the sync modal's "Copy resources" so the two surfaces stay identical. Files nest in a - * folder tree instead - use {@link FileKindRow}. + * (count of selected / total) plus, when expanded, a scrollable list of individual resources so + * the user can copy a specific subset. Shared by the fork modal's "Copy resources" and the sync + * modal's "Copy resources" so the two surfaces stay identical. Files nest in a folder tree + * instead - use {@link FileKindRow}. */ export function ResourceKindRow({ label, @@ -44,19 +40,10 @@ export function ResourceKindRow({ disabled = false, }: ResourceKindRowProps) { const [expanded, setExpanded] = useState(false) - const [query, setQuery] = useState('') const fieldId = useId() - const filtered = useMemo(() => { - const trimmed = query.trim().toLowerCase() - if (!trimmed) return items - return items.filter((item) => item.label.toLowerCase().includes(trimmed)) - }, [items, query]) - - // Count + header state + select-all are scoped to the VISIBLE (filtered) items so a search never - // selects or counts hidden ones. With no filter, `filtered === items`, so behavior is unchanged. - const total = filtered.length - const selectedCount = filtered.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0) + const total = items.length + const selectedCount = items.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0) const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' return ( @@ -68,7 +55,7 @@ export function ResourceKindRow({ checked={headerState} onCheckedChange={() => onToggleMany( - filtered.map((item) => item.id), + items.map((item) => item.id), headerState !== true ) } @@ -92,46 +79,32 @@ export function ResourceKindRow({

{expanded ? ( -
- {total > SEARCH_THRESHOLD ? ( - setQuery(event.target.value)} - placeholder={`Search ${label.toLowerCase()}`} - disabled={disabled} - /> - ) : null} -
- {filtered.map((item) => { - const isChecked = selected.has(item.id) - const itemId = `${fieldId}-${item.id}` - return ( - - ) - })} - {filtered.length === 0 ? ( -

No matches

- ) : null} -
+
+ {items.map((item) => { + const isChecked = selected.has(item.id) + const itemId = `${fieldId}-${item.id}` + return ( + + ) + })}
) : null}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts index 677c6c8f4fc..c8311b2bd1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts @@ -3,7 +3,11 @@ */ import { describe, expect, it } from 'vitest' import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' -import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' +import { + forkBlockerResolution, + selectVisibleClearedRefs, + splitForkClearedRefs, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' type ReferenceRef = Extract type WorkflowRef = Extract @@ -20,8 +24,9 @@ const base = { const referenceRef = ( kind: ReferenceRef['kind'], sourceId: string, - fieldLabel = 'Field' -): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId }) + fieldLabel = 'Field', + sourceDeleted = false +): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId, sourceDeleted }) const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({ ...base, @@ -118,3 +123,47 @@ describe('selectVisibleClearedRefs', () => { ).toEqual([workflowReference]) }) }) + +describe('splitForkClearedRefs', () => { + it('splits reference/workflow causes into blockers and dependents into informational', () => { + const tableReference = referenceRef('table', 'tbl-1') + const workflowReference = workflowRef('wf-other') + const labelDependent = dependentRef('credential', 'cred-1', 'Label') + const { blockers, informational } = splitForkClearedRefs([ + tableReference, + workflowReference, + labelDependent, + ]) + expect(blockers).toEqual([tableReference, workflowReference]) + expect(informational).toEqual([labelDependent]) + }) + + it('treats an unmapped MCP server and a source-deleted reference as blockers', () => { + const mcpReference = referenceRef('mcp-server', 'srv-1') + const deletedReference = referenceRef('skill', 'sk-gone', 'Skill', true) + const { blockers, informational } = splitForkClearedRefs([mcpReference, deletedReference]) + expect(blockers).toEqual([mcpReference, deletedReference]) + expect(informational).toEqual([]) + }) +}) + +describe('forkBlockerResolution', () => { + it('phrases each blocker reason with its actionable resolution', () => { + expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe( + 'map it to a target or select it for copy' + ) + expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe( + 'map it to an MCP server in the target workspace' + ) + expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe( + 'deleted in the source — map it to an existing knowledge base in the target' + ) + expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe( + 'deploy "Source" in the source or remove the reference' + ) + }) + + it('returns null for non-blocking dependent entries', () => { + expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts index 3f0bba304fe..d55a460fa07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts @@ -1,4 +1,5 @@ import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' +import { forkSyncBlockerReasonFor } from '@/lib/workspaces/fork/promote/sync-blockers' /** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */ export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean @@ -37,3 +38,51 @@ export function selectVisibleClearedRefs( return true }) } + +/** + * Split the visible would-clear entries into sync BLOCKERS (cause `reference`/`workflow` - the + * sync is disabled while any remain) and the informational remainder (`dependent` entries, owned + * by the reconfigure flow - they clear but never block). Pure, so the modal's gate and the two + * sections stay one testable rule. + */ +export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): { + blockers: ForkClearedRef[] + informational: ForkClearedRef[] +} { + const blockers: ForkClearedRef[] = [] + const informational: ForkClearedRef[] = [] + for (const ref of visibleRefs) { + if (forkSyncBlockerReasonFor(ref)) blockers.push(ref) + else informational.push(ref) + } + return { blockers, informational } +} + +/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */ +const BLOCKER_KIND_LABEL: Record = { + table: 'table', + 'knowledge-base': 'knowledge base', + file: 'file', + 'custom-tool': 'custom tool', + skill: 'skill', + 'mcp-server': 'MCP server', +} + +/** + * The actionable resolution line for a blocking entry, phrased for "{block} would lose {field} + * in {workflow} - {resolution}". Null for non-blocking (dependent) entries. + */ +export function forkBlockerResolution(ref: ForkClearedRef): string | null { + const reason = forkSyncBlockerReasonFor(ref) + if (!reason) return null + switch (reason) { + case 'unmapped-copyable': + return 'map it to a target or select it for copy' + case 'unmapped-mcp-server': + return 'map it to an MCP server in the target workspace' + case 'source-deleted': + return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target` + case 'workflow-missing': + return `deploy "${ref.sourceLabel}" in the source or remove the reference` + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx index 5c7c6eedba5..075d46dc7e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx @@ -3,7 +3,6 @@ import { type Dispatch, type SetStateAction, useMemo, useState } from 'react' import { ChevronDown, cn } from '@sim/emcn' import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector' import { dependentKey, @@ -57,8 +56,8 @@ interface ResourceReconfigureProps { * Always-on per-resource reconfigure listing: every workflow the resource is used in, each a * chevron row that expands to its blocks + dependent selectors so the user can (re)configure * them at any time - not only right after a target swap. A workflow with nothing configurable - * (a secret/file, or a credential with no dependent selector here) renders greyed and - * non-expandable with a tooltip, so the usage is still visible. + * (a secret/file, or a credential with no dependent selector here) renders as a plain + * non-interactive row without a chevron, with a tooltip, so the usage is still visible. */ export function ResourceReconfigure({ workflows, @@ -88,24 +87,26 @@ export function ResourceReconfigure({ }, [workflows, dependents]) if (workflows.length === 0) return null + // Muted caption label over the list (no divider) so this reads as subordinate to the + // resource-name section header above, mirroring the "Recent runs" listing in + // mothership-view's resource-content. return ( -
- -
- {workflowBlocks.map((workflow) => ( - - ))} -
-
+
+ Workflows +
+ {workflowBlocks.map((workflow) => ( + + ))} +
) } @@ -120,7 +121,7 @@ interface ReconfigWorkflowRowProps { setReconfig: Dispatch>> } -/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */ +/** One workflow row: a chevron header (a plain non-interactive row when nothing to configure). */ function ReconfigWorkflowRow({ workflowName, blocks, @@ -141,28 +142,31 @@ function ReconfigWorkflowRow({ return (
- {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed, - non-expandable row uses a native title tooltip to explain why. */} - + {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A row with + nothing to configure renders as muted plain text (no chevron, not a button) with a + native title tooltip explaining why it isn't expandable. */} + {configurable ? ( + + ) : ( +
+ {workflowName} +
+ )} {configurable && open ? blocks.map((block) => ( ): ForkCopyableUnmappe label: 'KB', parentId: null, parentLabel: null, + referenced: true, ...overrides, }) @@ -82,6 +85,23 @@ describe('copy-vs-map reconciliation', () => { }) }) +describe('forkDefaultCopySelection', () => { + it('seeds every referenced candidate and leaves unreferenced ones unselected', () => { + const selection = forkDefaultCopySelection([ + copyable({ kind: 'knowledge-base', sourceId: 'kb-1', referenced: true }), + copyable({ kind: 'table', sourceId: 'tbl-new', referenced: false }), + copyable({ kind: 'file', sourceId: 'workspace/SRC/new.png', referenced: false }), + ]) + expect(selection).toEqual(new Set(['knowledge-base:kb-1'])) + }) + + it('seeds nothing when every candidate is unreferenced', () => { + expect( + forkDefaultCopySelection([copyable({ kind: 'skill', sourceId: 'sk-new', referenced: false })]) + ).toEqual(new Set()) + }) +}) + describe('isForkRequiredComplete', () => { it('a required ref is satisfied by a mapping target', () => { const entries = [ @@ -100,12 +120,47 @@ describe('isForkRequiredComplete', () => { expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false) }) + it('a referenced MCP server (map-only, required) blocks until mapped - copy cannot satisfy it', () => { + const entries = [ + entry({ kind: 'mcp-server', resourceType: 'mcp_server', sourceId: 'srv-1', required: true }), + ] + // MCP servers are never copy candidates, so the copy set can't contain them; only a + // mapping target resolves the entry. + expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false) + expect(isForkRequiredComplete(entries, { 'mcp-server:srv-1': 'srv-tgt' }, new Set())).toBe(true) + }) + + it('a source-deleted referenced resource (required, no copy candidate) blocks until mapped', () => { + // A deleted source is dropped from the copy candidates (its label lookup fails), so the + // only resolution is mapping the dead id to a live target resource. + const entries = [ + entry({ kind: 'table', resourceType: 'table', sourceId: 'tbl-gone', required: true }), + ] + expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false) + expect(isForkRequiredComplete(entries, { 'table:tbl-gone': 'tbl-live' }, new Set())).toBe(true) + }) + it('optional refs never block', () => { const entries = [entry({ kind: 'table', sourceId: 't1', required: false })] expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true) }) }) +describe('forkRequiredKindsLabel', () => { + it('names credentials and secrets by kind, together or alone', () => { + expect(forkRequiredKindsLabel(new Set(['credential', 'env-var']))).toBe( + 'credentials and secrets' + ) + expect(forkRequiredKindsLabel(new Set(['credential']))).toBe('credentials') + expect(forkRequiredKindsLabel(new Set(['env-var']))).toBe('secrets') + }) + + it('falls back to "references" for any other (or empty) kind set', () => { + expect(forkRequiredKindsLabel(new Set(['table']))).toBe('references') + expect(forkRequiredKindsLabel(new Set())).toBe('references') + }) +}) + describe('forkRequiredPending', () => { it('is true when a required ref is neither mapped nor selected for copy', () => { const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts index f1707ad1a3b..afea9cd5010 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts @@ -35,6 +35,20 @@ export function forkVisibleCopyables( return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate))) } +/** + * The copy selection seeded once the diff settles: every REFERENCED candidate (deselecting one + * clears its references, so the common case needs no clicks). Unreferenced candidates - used by + * no synced workflow - start unselected: copying them is opt-in, so scratch data created in the + * source is never pushed by surprise. + */ +export function forkDefaultCopySelection(copyableUnmapped: ForkCopyableUnmapped[]): Set { + const keys = new Set() + for (const candidate of copyableUnmapped) { + if (candidate.referenced) keys.add(forkRefKey(candidate)) + } + return keys +} + /** Keys of the visible copy candidates actually selected for copy. */ export function forkCopyingKeys( visibleCopyables: ForkCopyableUnmapped[], @@ -83,3 +97,20 @@ export function forkRequiredPending( !copyingKeys.has(forkRefKey(entry)) ) } + +/** + * Human label for the kinds still failing the required gate, for "Map all required {label} first" + * messaging - shared by the Sync button's disabled tooltip (client gate) and the server gate's + * failure toast so both name the obstacle identically. Credentials and secrets are named + * explicitly: they are the map-only kinds that fail the required gate WITHOUT also appearing in + * the cleared-ref blockers (the collector excludes them), so they need their own wording. Any + * other kind falls back to "references". + */ +export function forkRequiredKindsLabel(kinds: ReadonlySet): string { + const credentials = kinds.has('credential') + const secrets = kinds.has('env-var') + if (credentials && secrets) return 'credentials and secrets' + if (credentials) return 'credentials' + if (secrets) return 'secrets' + return 'references' +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx index c2186faff72..9e5129e6dc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx @@ -11,6 +11,7 @@ import { ChipModalFooter, type ChipModalFooterSlotAction, ChipModalHeader, + cn, toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' @@ -28,13 +29,19 @@ import { FileKindRow, ResourceKindRow, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker' -import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' +import { + forkBlockerResolution, + selectVisibleClearedRefs, + splitForkClearedRefs, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure' import { effectiveForkTarget, forkCopyingKeys, + forkDefaultCopySelection, forkMappedCopyableKeys, forkRefKey, + forkRequiredKindsLabel, forkRequiredPending, forkVisibleCopyables, isForkRequiredComplete, @@ -127,7 +134,7 @@ const MAPPING_SECTION: Record forkRefKey(candidate) /** Sentinel option value for the editor's "Copy instead" entry - handled via onSelect, never sent. */ const COPY_INSTEAD_VALUE = '__copy_instead__' +/** Archived-workflow names shown in the sync confirm before truncating to "and X more". */ +const ARCHIVED_PREVIEW_LIMIT = 5 + interface EdgeOption { value: string label: string @@ -278,27 +288,33 @@ export function PromoteWorkspaceModal({ // Group the visible copy candidates by kind so each renders as its own expandable section // (chevron + tri-state select-all + count), matching the fork picker. Files nest in a folder ▸ - // file tree inside their section; every other kind is a flat searchable list. - const copyablesByKind = useMemo(() => { - const groups = new Map() + // file tree inside their section; every other kind is a flat list. Referenced and unreferenced + // candidates group separately: unreferenced ones (used by no synced workflow) render under a + // muted "Not used by any workflow" grouping and default to unselected. + const { referencedByKind, unreferencedByKind } = useMemo(() => { + const referenced = new Map() + const unreferenced = new Map() for (const candidate of visibleCopyables) { + const groups = candidate.referenced ? referenced : unreferenced const list = groups.get(candidate.kind) if (list) list.push(candidate) else groups.set(candidate.kind, [candidate]) } - return groups + return { referencedByKind: referenced, unreferencedByKind: unreferenced } }, [visibleCopyables]) - // Default every copyable referenced resource to "copy" once the diff loads, so the common case + // Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case // (bring the referenced resources along) needs no clicks; the user can deselect to clear instead. - // Seed ONLY from a settled diff for the current direction: on a direction switch the reset clears - // `copyDefaulted`, but `useForkDiff` keeps the previous direction's payload (placeholderData) until - // the new fetch resolves - seeding from it would latch against stale keys and leave the real - // copyables unchecked, clearing their references on Sync. + // Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying them is + // opt-in since nothing references them. Seed ONLY from a settled diff for the current direction: + // on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps the previous + // direction's payload (placeholderData) until the new fetch resolves - seeding from it would + // latch against stale keys and leave the real copyables unchecked, clearing their references + // on Sync. useEffect(() => { if (!open || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return setCopyDefaulted(true) - setCopySelected(new Set(copyableUnmapped.map(copyableKey))) + setCopySelected(forkDefaultCopySelection(copyableUnmapped)) }, [open, diff.isPlaceholderData, copyableUnmapped, copyDefaulted]) // Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a @@ -422,18 +438,20 @@ export function PromoteWorkspaceModal({ } } - // The references this sync will blank, reactively narrowed to the current selection. A resource + // The references this sync would blank, reactively narrowed to the current selection. A resource // is "resolved" once it has a mapping target OR is selected for copy - the same predicate drives // a `reference` (its own resource) and a `dependent` (its PARENT resource), so mapping or copying - // a parent KB makes its child document drop off. `workflow` refs always clear (not resolvable here). - const clearedRefsToShow = useMemo(() => { + // a parent KB makes its child document drop off. Then split: `reference`/`workflow` entries are + // BLOCKERS (Sync stays disabled while any remain - mirroring the server's zero-cleared-refs + // gate); `dependent` entries stay informational (the reconfigure flow owns them). + const { blockers: blockingRefs, informational: dependentClears } = useMemo(() => { const isResolved = (kind: string, sourceId: string) => { const key = `${kind}:${sourceId}` const entry = entriesByParent.get(key) const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false return mapped || copyingKeys.has(key) } - return selectVisibleClearedRefs(clearedRefs, isResolved) + return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved)) }, [clearedRefs, entriesByParent, targets, copyingKeys]) // Per-kind status for the overview listing: "Fully mapped" or "n/total mapped", @@ -461,6 +479,14 @@ export function PromoteWorkspaceModal({ } }) + // Kinds whose required gate is still failing, so the Sync tooltip can name the actual + // obstacle. An unmapped credential/secret is NEVER a cleared-ref blocker (the collector + // excludes required kinds), so the required gate must not borrow the blocker message - + // it would point at a "Blocking sync" section that isn't rendered. + const pendingRequiredKinds = new Set( + kindSummaries.filter((summary) => summary.requiredPending).map((summary) => summary.kind) + ) + // Step 0 is the overview; each subsequent step edits one resource kind, entered via // "Edit mappings". Reconfigure cards render inline under the changed mapping (not as // their own steps) so the credential/KB context stays visible. `safeStep` guards @@ -479,8 +505,18 @@ export function PromoteWorkspaceModal({ mapping.isPlaceholderData || !diff.data || diff.isPlaceholderData + // Zero-blockers invariant (mirrors the server gate): Sync stays disabled while ANY reference + // would clear in a synced target workflow. `requiredComplete` covers the mapping entries + // (credentials/secrets and unresolved resource refs); `blockingRefs` additionally covers + // workflow-to-workflow references, which have no mapping entry to resolve. + const syncBlocked = blockingRefs.length > 0 const syncDisabled = - submitting || !otherWorkspaceId || !requiredComplete || !reconfigComplete || dataPending + submitting || + !otherWorkspaceId || + !requiredComplete || + !reconfigComplete || + syncBlocked || + dataPending const headsUp = (diff.data?.mcpReauthServerIds.length ?? 0) > 0 || (diff.data?.inlineSecretSources.length ?? 0) > 0 @@ -553,20 +589,22 @@ export function PromoteWorkspaceModal({ }) if (!result.promoteRunId) { + if (result.blockers.length > 0) { + // The server's authoritative gate re-found would-clear references (something changed + // between the preview and Sync). The mutation's settled invalidation refetches the + // diff, so the refreshed blocker list is already on its way in. + const count = result.blockers.length + toast.error( + `Sync blocked: ${count} reference${count === 1 ? '' : 's'} would break in the target. Review the updated list and try again.` + ) + return + } if (result.unmappedRequired.length > 0) { // Name the actual blocking kinds rather than always blaming credentials: the server // blocks on required REFERENCES (credentials and/or secrets); required dependents are // gated client-side before this runs (see the Sync button's disabled tooltip). const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind)) - const what = - kinds.has('credential') && kinds.has('env-var') - ? 'credentials and secrets' - : kinds.has('credential') - ? 'credentials' - : kinds.has('env-var') - ? 'secrets' - : 'references' - toast.error(`Map all required ${what} first`) + toast.error(`Map all required ${forkRequiredKindsLabel(kinds)} first`) return } toast.error('Sync did not complete') @@ -631,6 +669,83 @@ export function PromoteWorkspaceModal({ ) }, [diff.data?.workflows]) + // Target workflows this sync archives (their source was deleted), named in the confirm modal so + // the overwrite warning is concrete - the push-to-parent case is the high-stakes one, so the + // target workspace is named explicitly there. + const archivedWorkflowNames = useMemo( + () => + workflowChanges + .filter((change) => change.action === 'archive') + .map((change) => change.currentName), + [workflowChanges] + ) + const targetWorkspaceName = + direction === 'push' ? (parent?.name ?? 'the parent workspace') : 'this workspace' + + // One expandable row per copyable kind present in `byKind` - shared by the referenced group + // and the unreferenced "Not used by any workflow" group so both render exactly like the fork + // picker (files as a folder tree, every other kind flat). + const renderCopyKindSections = ( + byKind: ReadonlyMap + ) => + COPYABLE_KIND_SECTIONS.map((section) => { + const candidates = byKind.get(section.kind) + if (!candidates || candidates.length === 0) return null + // The picker rows track item ids; copy selection is keyed `${kind}:${id}` + // (matching `copyableKey`), so derive the per-kind selected-id subset and + // re-prefix on toggle. + const selectedIds = new Set( + candidates + .filter((candidate) => copySelected.has(copyableKey(candidate))) + .map((candidate) => candidate.sourceId) + ) + const toggleMany = (ids: string[], checked: boolean) => + setCopySelected((prev) => { + const next = new Set(prev) + for (const id of ids) { + const key = `${section.kind}:${id}` + if (checked) next.add(key) + else next.delete(key) + } + return next + }) + const toggleAll = (selectAll: boolean) => + toggleMany( + candidates.map((candidate) => candidate.sourceId), + selectAll + ) + return section.kind === 'file' ? ( + ({ + id: candidate.sourceId, + label: candidate.label, + folderId: candidate.parentId, + folderName: candidate.parentLabel, + }))} + selected={selectedIds} + onToggleAll={toggleAll} + onToggleItem={(id, checked) => toggleMany([id], checked)} + onToggleMany={toggleMany} + disabled={submitting} + /> + ) : ( + ({ + id: candidate.sourceId, + label: candidate.label, + }))} + selected={selectedIds} + onToggleMany={toggleMany} + onToggleItem={(id, checked) => toggleMany([id], checked)} + disabled={submitting} + /> + ) + }) + // Right-cluster action sitting immediately left of the primary. The overview pairs // "Edit mappings" with Sync (entering the step walk); every editing step pairs Back // with Next (or with Sync on the last step). Back out of step 1 lands on the @@ -782,10 +897,31 @@ export function PromoteWorkspaceModal({ ) : null} - {clearedRefsToShow.length > 0 ? ( + {syncBlocked ? ( + +
+ {blockingRefs.map((ref, index) => ( +
+ {ref.blockLabel} would lose{' '} + {ref.fieldLabel} in{' '} + {ref.workflowName} — {forkBlockerResolution(ref)} +
+ ))} +
+

+ Sync is blocked while any of these remain, so every synced workflow stays fully + operational in the target. +

+
+ ) : null} + + {dependentClears.length > 0 ? (
- {clearedRefsToShow.map((ref, index) => ( + {dependentClears.map((ref, index) => (

- Map or copy a reference to keep it. Fields that reference another workflow, or - that hang off a remapped credential or knowledge base, are cleared regardless. + Fields that hang off a remapped credential or knowledge base are cleared — + re-pick them in the target after the sync.

) : null} @@ -806,67 +942,33 @@ export function PromoteWorkspaceModal({ {visibleCopyables.length > 0 ? (
- {COPYABLE_KIND_SECTIONS.map((section) => { - const candidates = copyablesByKind.get(section.kind) - if (!candidates || candidates.length === 0) return null - // The picker rows track item ids; copy selection is keyed `${kind}:${id}` - // (matching `copyableKey`), so derive the per-kind selected-id subset and - // re-prefix on toggle. - const selectedIds = new Set( - candidates - .filter((candidate) => copySelected.has(copyableKey(candidate))) - .map((candidate) => candidate.sourceId) - ) - const toggleMany = (ids: string[], checked: boolean) => - setCopySelected((prev) => { - const next = new Set(prev) - for (const id of ids) { - const key = `${section.kind}:${id}` - if (checked) next.add(key) - else next.delete(key) - } - return next - }) - const toggleAll = (selectAll: boolean) => - toggleMany( - candidates.map((candidate) => candidate.sourceId), - selectAll - ) - return section.kind === 'file' ? ( - ({ - id: candidate.sourceId, - label: candidate.label, - folderId: candidate.parentId, - folderName: candidate.parentLabel, - }))} - selected={selectedIds} - onToggleAll={toggleAll} - onToggleItem={(id, checked) => toggleMany([id], checked)} - onToggleMany={toggleMany} - disabled={submitting} - /> - ) : ( - ({ - id: candidate.sourceId, - label: candidate.label, - }))} - selected={selectedIds} - onToggleMany={toggleMany} - onToggleItem={(id, checked) => toggleMany([id], checked)} - disabled={submitting} - /> - ) - })} -

- These referenced resources aren't in the target yet. Selected ones are copied - during the sync; deselected ones have their references cleared. -

+ {referencedByKind.size > 0 ? ( + <> + {renderCopyKindSections(referencedByKind)} +

+ These referenced resources aren't in the target yet. Selected ones are + copied during the sync; a deselected one blocks the sync until it's mapped + or selected again. +

+ + ) : null} + {unreferencedByKind.size > 0 ? ( + <> +
0 && 'mt-1' + )} + > + Not used by any workflow +
+ {renderCopyKindSections(unreferencedByKind)} +

+ These aren't referenced by any synced workflow. Selected ones are copied + during the sync; deselected ones are simply left out. +

+ + ) : null}
) : null} @@ -882,17 +984,7 @@ export function PromoteWorkspaceModal({ ? takenTargetOwners(currentGroup.items, targets, entry) : EMPTY_TARGET_OWNERS return ( - - * - - ) : undefined - } - > + ) : null} {/* Always-on: every workflow this resource is used in, each expandable to - its blocks + dependent selectors (greyed when nothing to configure). */} + its blocks + dependent selectors (a plain row when nothing to configure). */} setConfirmSyncOpen(true), disabled: syncDisabled, - disabledTooltip: !requiredComplete - ? 'Map all required secrets first' - : !reconfigComplete - ? 'Reconfigure all required fields first' - : dataPending - ? 'Loading sync details…' - : undefined, + // Priority mirrors the resolution flow: clear the blockers, map the required + // resources, reconfigure their dependents - each failing gate names ITS + // obstacle (an unmapped credential/secret is a required-mapping failure, not + // a cleared-ref blocker; see `pendingRequiredKinds`). + disabledTooltip: syncBlocked + ? 'Resolve every blocking reference first — map it, copy it, or fix it in the source' + : !requiredComplete + ? `Map all required ${forkRequiredKindsLabel(pendingRequiredKinds)} first` + : !reconfigComplete + ? 'Reconfigure all required fields first' + : dataPending + ? 'Loading sync details…' + : undefined, } } /> @@ -993,7 +1091,29 @@ export function PromoteWorkspaceModal({ pending: submitting, pendingLabel: 'Syncing...', }} - /> + > + {archivedWorkflowNames.length > 0 ? ( +
+

+ Will be archived in {targetWorkspaceName}{' '} + (deleted in the source): +

+ {archivedWorkflowNames.slice(0, ARCHIVED_PREVIEW_LIMIT).map((name, index) => ( +
+ {name} +
+ ))} + {archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} + ) } diff --git a/apps/sim/blocks/blocks/ahrefs.ts b/apps/sim/blocks/blocks/ahrefs.ts index 80e56aa7d6b..cd5b5e22347 100644 --- a/apps/sim/blocks/blocks/ahrefs.ts +++ b/apps/sim/blocks/blocks/ahrefs.ts @@ -3,6 +3,45 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { AhrefsResponse } from '@/tools/ahrefs/types' +const COUNTRY_OPTIONS = [ + { label: 'United States', id: 'us' }, + { label: 'United Kingdom', id: 'gb' }, + { label: 'Germany', id: 'de' }, + { label: 'France', id: 'fr' }, + { label: 'Spain', id: 'es' }, + { label: 'Italy', id: 'it' }, + { label: 'Canada', id: 'ca' }, + { label: 'Australia', id: 'au' }, + { label: 'Japan', id: 'jp' }, + { label: 'Brazil', id: 'br' }, + { label: 'India', id: 'in' }, + { label: 'Netherlands', id: 'nl' }, + { label: 'Poland', id: 'pl' }, + { label: 'Russia', id: 'ru' }, + { label: 'Mexico', id: 'mx' }, +] + +const MODE_OPTIONS = [ + { label: 'Domain (entire domain)', id: 'domain' }, + { label: 'Prefix (URL prefix)', id: 'prefix' }, + { label: 'Subdomains (include all)', id: 'subdomains' }, + { label: 'Exact (exact URL)', id: 'exact' }, +] + +const DATE_WAND_CONFIG = { + enabled: true, + prompt: `Generate a date in YYYY-MM-DD format based on the user's description. +Examples: +- "today" -> Current date in YYYY-MM-DD format +- "yesterday" -> Yesterday's date in YYYY-MM-DD format +- "last week" -> Date 7 days ago in YYYY-MM-DD format +- "beginning of this month" -> First day of current month in YYYY-MM-DD format + +Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', + generationType: 'timestamp' as const, +} + export const AhrefsBlock: BlockConfig = { type: 'ahrefs', name: 'Ahrefs', @@ -10,7 +49,7 @@ export const AhrefsBlock: BlockConfig = { authMode: AuthMode.ApiKey, longDescription: 'Integrate Ahrefs SEO tools into your workflow. Analyze domain ratings, backlinks, organic keywords, top pages, and more. Requires an Ahrefs Enterprise plan with API access.', - docsLink: 'https://docs.ahrefs.com/docs/api/reference/introduction', + docsLink: 'https://docs.sim.ai/integrations/ahrefs', category: 'tools', integrationType: IntegrationType.Analytics, bgColor: '#FFFFFF', @@ -22,13 +61,15 @@ export const AhrefsBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'Domain Rating', id: 'ahrefs_domain_rating' }, + { label: 'Metrics Overview', id: 'ahrefs_metrics' }, { label: 'Backlinks', id: 'ahrefs_backlinks' }, { label: 'Backlinks Stats', id: 'ahrefs_backlinks_stats' }, { label: 'Referring Domains', id: 'ahrefs_referring_domains' }, + { label: 'Broken Backlinks', id: 'ahrefs_broken_backlinks' }, { label: 'Organic Keywords', id: 'ahrefs_organic_keywords' }, + { label: 'Organic Competitors', id: 'ahrefs_organic_competitors' }, { label: 'Top Pages', id: 'ahrefs_top_pages' }, { label: 'Keyword Overview', id: 'ahrefs_keyword_overview' }, - { label: 'Broken Backlinks', id: 'ahrefs_broken_backlinks' }, ], value: () => 'ahrefs_domain_rating', }, @@ -48,79 +89,81 @@ export const AhrefsBlock: BlockConfig = { placeholder: 'YYYY-MM-DD (defaults to today)', condition: { field: 'operation', value: 'ahrefs_domain_rating' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, - // Backlinks operation inputs + // Metrics operation inputs { id: 'target', title: 'Target Domain/URL', type: 'short-input', - placeholder: 'example.com or https://example.com/page', - condition: { field: 'operation', value: 'ahrefs_backlinks' }, + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_metrics' }, required: true, }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_metrics' }, + mode: 'advanced', + }, { id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, - ], + options: MODE_OPTIONS, value: () => 'domain', - condition: { field: 'operation', value: 'ahrefs_backlinks' }, + condition: { field: 'operation', value: 'ahrefs_metrics' }, mode: 'advanced', }, { - id: 'limit', - title: 'Limit', + id: 'date', + title: 'Date', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_backlinks' }, + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_metrics' }, mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, }, + // Backlinks operation inputs { - id: 'offset', - title: 'Offset', + id: 'target', + title: 'Target Domain/URL', type: 'short-input', - placeholder: '0', + placeholder: 'example.com or https://example.com/page', + condition: { field: 'operation', value: 'ahrefs_backlinks' }, + required: true, + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', condition: { field: 'operation', value: 'ahrefs_backlinks' }, mode: 'advanced', }, { - id: 'date', - title: 'Date', + id: 'history', + title: 'History', + type: 'dropdown', + options: [ + { label: 'All time (includes lost backlinks)', id: 'all_time' }, + { label: 'Live only', id: 'live' }, + ], + value: () => 'all_time', + condition: { field: 'operation', value: 'ahrefs_backlinks' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', type: 'short-input', - placeholder: 'YYYY-MM-DD (defaults to today)', + placeholder: '1000', condition: { field: 'operation', value: 'ahrefs_backlinks' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, }, // Backlinks Stats operation inputs { @@ -135,12 +178,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, - ], + options: MODE_OPTIONS, value: () => 'domain', condition: { field: 'operation', value: 'ahrefs_backlinks_stats' }, mode: 'advanced', @@ -152,19 +190,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'YYYY-MM-DD (defaults to today)', condition: { field: 'operation', value: 'ahrefs_backlinks_stats' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, // Referring Domains operation inputs { @@ -179,13 +205,20 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'mode', title: 'Analysis Mode', type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_referring_domains' }, + mode: 'advanced', + }, + { + id: 'history', + title: 'History', + type: 'dropdown', options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, + { label: 'All time (includes lost domains)', id: 'all_time' }, + { label: 'Live only', id: 'live' }, ], - value: () => 'domain', + value: () => 'all_time', condition: { field: 'operation', value: 'ahrefs_referring_domains' }, mode: 'advanced', }, @@ -193,38 +226,35 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', + placeholder: '1000', condition: { field: 'operation', value: 'ahrefs_referring_domains' }, mode: 'advanced', }, + // Broken Backlinks operation inputs { - id: 'offset', - title: 'Offset', + id: 'target', + title: 'Target Domain/URL', type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'ahrefs_referring_domains' }, + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + required: true, + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, mode: 'advanced', }, { - id: 'date', - title: 'Date', + id: 'limit', + title: 'Limit', type: 'short-input', - placeholder: 'YYYY-MM-DD (defaults to today)', - condition: { field: 'operation', value: 'ahrefs_referring_domains' }, + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, }, // Organic Keywords operation inputs { @@ -239,23 +269,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'country', title: 'Country', type: 'dropdown', - options: [ - { label: 'United States', id: 'us' }, - { label: 'United Kingdom', id: 'gb' }, - { label: 'Germany', id: 'de' }, - { label: 'France', id: 'fr' }, - { label: 'Spain', id: 'es' }, - { label: 'Italy', id: 'it' }, - { label: 'Canada', id: 'ca' }, - { label: 'Australia', id: 'au' }, - { label: 'Japan', id: 'jp' }, - { label: 'Brazil', id: 'br' }, - { label: 'India', id: 'in' }, - { label: 'Netherlands', id: 'nl' }, - { label: 'Poland', id: 'pl' }, - { label: 'Russia', id: 'ru' }, - { label: 'Mexico', id: 'mx' }, - ], + options: COUNTRY_OPTIONS, value: () => 'us', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', @@ -264,12 +278,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, - ], + options: MODE_OPTIONS, value: () => 'domain', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', @@ -278,15 +287,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, - mode: 'advanced', - }, - { - id: 'offset', - title: 'Offset', - type: 'short-input', - placeholder: '0', + placeholder: '1000', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', }, @@ -297,81 +298,41 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'YYYY-MM-DD (defaults to today)', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, - // Top Pages operation inputs + // Organic Competitors operation inputs { id: 'target', - title: 'Target Domain', + title: 'Target Domain/URL', type: 'short-input', placeholder: 'example.com', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, required: true, }, { id: 'country', title: 'Country', type: 'dropdown', - options: [ - { label: 'United States', id: 'us' }, - { label: 'United Kingdom', id: 'gb' }, - { label: 'Germany', id: 'de' }, - { label: 'France', id: 'fr' }, - { label: 'Spain', id: 'es' }, - { label: 'Italy', id: 'it' }, - { label: 'Canada', id: 'ca' }, - { label: 'Australia', id: 'au' }, - { label: 'Japan', id: 'jp' }, - { label: 'Brazil', id: 'br' }, - { label: 'India', id: 'in' }, - { label: 'Netherlands', id: 'nl' }, - { label: 'Poland', id: 'pl' }, - { label: 'Russia', id: 'ru' }, - { label: 'Mexico', id: 'mx' }, - ], + options: COUNTRY_OPTIONS, value: () => 'us', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', }, { id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - ], + options: MODE_OPTIONS, value: () => 'domain', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', }, { id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, - mode: 'advanced', - }, - { - id: 'offset', - title: 'Offset', - type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', }, { @@ -379,65 +340,28 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n title: 'Date', type: 'short-input', placeholder: 'YYYY-MM-DD (defaults to today)', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, - // Keyword Overview operation inputs + // Top Pages operation inputs { - id: 'keyword', - title: 'Keyword', + id: 'target', + title: 'Target Domain', type: 'short-input', - placeholder: 'Enter keyword to analyze', - condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_top_pages' }, required: true, }, { id: 'country', title: 'Country', type: 'dropdown', - options: [ - { label: 'United States', id: 'us' }, - { label: 'United Kingdom', id: 'gb' }, - { label: 'Germany', id: 'de' }, - { label: 'France', id: 'fr' }, - { label: 'Spain', id: 'es' }, - { label: 'Italy', id: 'it' }, - { label: 'Canada', id: 'ca' }, - { label: 'Australia', id: 'au' }, - { label: 'Japan', id: 'jp' }, - { label: 'Brazil', id: 'br' }, - { label: 'India', id: 'in' }, - { label: 'Netherlands', id: 'nl' }, - { label: 'Poland', id: 'pl' }, - { label: 'Russia', id: 'ru' }, - { label: 'Mexico', id: 'mx' }, - ], + options: COUNTRY_OPTIONS, value: () => 'us', - condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', }, - // Broken Backlinks operation inputs - { - id: 'target', - title: 'Target Domain/URL', - type: 'short-input', - placeholder: 'example.com', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, - required: true, - }, { id: 'mode', title: 'Analysis Mode', @@ -446,48 +370,45 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n { label: 'Domain (entire domain)', id: 'domain' }, { label: 'Prefix (URL prefix)', id: 'prefix' }, { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, ], value: () => 'domain', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', }, { id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', }, { - id: 'offset', - title: 'Offset', + id: 'date', + title: 'Date', type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, }, + // Keyword Overview operation inputs { - id: 'date', - title: 'Date', + id: 'keyword', + title: 'Keyword', type: 'short-input', - placeholder: 'YYYY-MM-DD (defaults to today)', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + placeholder: 'Enter keyword to analyze', + condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, + required: true, + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, }, // API Key (common to all operations) { @@ -502,33 +423,39 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n tools: { access: [ 'ahrefs_domain_rating', + 'ahrefs_metrics', 'ahrefs_backlinks', 'ahrefs_backlinks_stats', 'ahrefs_referring_domains', + 'ahrefs_broken_backlinks', 'ahrefs_organic_keywords', + 'ahrefs_organic_competitors', 'ahrefs_top_pages', 'ahrefs_keyword_overview', - 'ahrefs_broken_backlinks', ], config: { tool: (params) => { switch (params.operation) { case 'ahrefs_domain_rating': return 'ahrefs_domain_rating' + case 'ahrefs_metrics': + return 'ahrefs_metrics' case 'ahrefs_backlinks': return 'ahrefs_backlinks' case 'ahrefs_backlinks_stats': return 'ahrefs_backlinks_stats' case 'ahrefs_referring_domains': return 'ahrefs_referring_domains' + case 'ahrefs_broken_backlinks': + return 'ahrefs_broken_backlinks' case 'ahrefs_organic_keywords': return 'ahrefs_organic_keywords' + case 'ahrefs_organic_competitors': + return 'ahrefs_organic_competitors' case 'ahrefs_top_pages': return 'ahrefs_top_pages' case 'ahrefs_keyword_overview': return 'ahrefs_keyword_overview' - case 'ahrefs_broken_backlinks': - return 'ahrefs_broken_backlinks' default: return 'ahrefs_domain_rating' } @@ -536,7 +463,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n params: (params) => { const result: Record = {} if (params.limit) result.limit = Number(params.limit) - if (params.offset) result.offset = Number(params.offset) return result }, }, @@ -549,27 +475,46 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n mode: { type: 'string', description: 'Analysis mode (domain, prefix, subdomains, exact)' }, country: { type: 'string', description: 'Country code for geo-specific data' }, date: { type: 'string', description: 'Date for historical data in YYYY-MM-DD format' }, + history: { + type: 'string', + description: 'Historical scope for backlink-profile endpoints (all_time, live)', + }, limit: { type: 'number', description: 'Maximum number of results to return' }, - offset: { type: 'number', description: 'Number of results to skip for pagination' }, }, outputs: { // Domain Rating output domainRating: { type: 'number', description: 'Domain Rating score (0-100)' }, ahrefsRank: { type: 'number', description: 'Ahrefs Rank (global ranking)' }, + // Metrics output + metrics: { + type: 'json', + description: + 'Organic and paid search overview (organicTraffic, organicKeywords, organicKeywordsTop3, organicCost, paidTraffic, paidKeywords, paidPages, paidCost)', + }, // Backlinks output backlinks: { type: 'json', description: 'List of backlinks' }, // Backlinks Stats output - stats: { type: 'json', description: 'Backlink statistics' }, + stats: { + type: 'json', + description: + 'Backlink and referring domain totals (liveBacklinks, liveReferringDomains, allTimeBacklinks, allTimeReferringDomains)', + }, // Referring Domains output referringDomains: { type: 'json', description: 'List of referring domains' }, + // Broken Backlinks output + brokenBacklinks: { type: 'json', description: 'List of broken backlinks' }, // Organic Keywords output keywords: { type: 'json', description: 'List of organic keywords' }, + // Organic Competitors output + competitors: { type: 'json', description: 'List of organic search competitors' }, // Top Pages output pages: { type: 'json', description: 'List of top pages' }, // Keyword Overview output - overview: { type: 'json', description: 'Keyword metrics overview' }, - // Broken Backlinks output - brokenBacklinks: { type: 'json', description: 'List of broken backlinks' }, + overview: { + type: 'json', + description: + 'Keyword metrics overview, including search intent flags (informational, navigational, commercial, transactional, branded, local)', + }, }, } @@ -615,6 +560,16 @@ export const AhrefsBlockMeta = { category: 'marketing', tags: ['marketing', 'automation'], }, + { + icon: AhrefsIcon, + title: 'Ahrefs organic competitor finder', + prompt: + 'Build a monthly workflow that pulls Ahrefs organic competitors for my domain, cross-references them against my tracked competitor list, and posts newly surfaced competitors to Slack for review.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'research'], + alsoIntegrations: ['slack'], + }, { icon: AhrefsIcon, title: 'Ahrefs + Similarweb growth scoreboard', @@ -668,5 +623,12 @@ export const AhrefsBlockMeta = { content: '# Track Organic Rankings\n\nReport how a domain is ranking in organic search using Ahrefs.\n\n## Steps\n1. Pull the organic keywords report for the target domain.\n2. Identify the top-ranking keywords and their positions.\n3. Compare against a prior snapshot if available to find gains and losses.\n\n## Output\nA summary of top organic keywords, notable position gains and drops, and pages that may need attention.', }, + { + name: 'find-organic-competitors', + description: + 'Use Ahrefs organic competitors data to identify sites competing for the same search traffic.', + content: + '# Find Organic Competitors\n\nSurface who actually competes with a domain in organic search using Ahrefs.\n\n## Steps\n1. Run an organic competitors report for the target domain.\n2. Rank results by common keyword overlap and competitor traffic.\n3. Cross-reference against the known competitor list to flag new entrants.\n\n## Output\nA ranked list of organic competitors with keyword overlap and traffic, highlighting any that are not yet being tracked.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/algolia.ts b/apps/sim/blocks/blocks/algolia.ts index be4edae15f3..7b62fa540b5 100644 --- a/apps/sim/blocks/blocks/algolia.ts +++ b/apps/sim/blocks/blocks/algolia.ts @@ -37,6 +37,7 @@ export const AlgoliaBlock: BlockConfig = { { label: 'Copy/Move Index', id: 'copy_move_index' }, { label: 'Clear Records', id: 'clear_records' }, { label: 'Delete By Filter', id: 'delete_by_filter' }, + { label: 'Get Task Status', id: 'get_task_status' }, ], value: () => 'search', }, @@ -63,7 +64,7 @@ export const AlgoliaBlock: BlockConfig = { title: 'Hits Per Page', type: 'short-input', placeholder: '20', - condition: { field: 'operation', value: ['search', 'browse_records'] }, + condition: { field: 'operation', value: ['search', 'browse_records', 'list_indices'] }, mode: 'advanced', }, { @@ -71,7 +72,7 @@ export const AlgoliaBlock: BlockConfig = { title: 'Page', type: 'short-input', placeholder: '0', - condition: { field: 'operation', value: 'search' }, + condition: { field: 'operation', value: ['search', 'list_indices'] }, mode: 'advanced', }, { @@ -108,6 +109,26 @@ Return ONLY the filter string, no quotes or explanation.`, condition: { field: 'operation', value: ['search', 'get_record', 'browse_records'] }, mode: 'advanced', }, + { + id: 'facets', + title: 'Facets', + type: 'short-input', + placeholder: 'category,brand (or * for all)', + condition: { field: 'operation', value: 'search' }, + mode: 'advanced', + }, + { + id: 'getRankingInfo', + title: 'Include Ranking Info', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'search' }, + mode: 'advanced', + }, // Browse cursor { id: 'cursor', @@ -389,7 +410,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Around Lat/Lng', type: 'short-input', placeholder: '40.71,-74.01', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, { @@ -397,7 +418,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Around Radius (m)', type: 'short-input', placeholder: '1000 or "all"', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, { @@ -405,7 +426,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Inside Bounding Box', type: 'short-input', placeholder: '[[47.3165,0.757,47.3424,0.8012]]', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, { @@ -413,7 +434,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Inside Polygon', type: 'short-input', placeholder: '[[47.3165,0.757,47.3424,0.8012,47.33,0.78]]', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, // Get records (batch) field @@ -447,22 +468,14 @@ Return ONLY the JSON array.`, generationType: 'json-object', }, }, - // List indices pagination + // Get task status field { - id: 'listPage', - title: 'Page', + id: 'taskID', + title: 'Task ID', type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'list_indices' }, - mode: 'advanced', - }, - { - id: 'listHitsPerPage', - title: 'Indices Per Page', - type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'list_indices' }, - mode: 'advanced', + placeholder: '12345', + condition: { field: 'operation', value: 'get_task_status' }, + required: { field: 'operation', value: 'get_task_status' }, }, // Object ID - for add (optional), get, partial update, delete { @@ -515,32 +528,48 @@ Return ONLY the JSON array.`, 'algolia_copy_move_index', 'algolia_clear_records', 'algolia_delete_by_filter', + 'algolia_get_task_status', ], config: { - tool: (params: Record) => { - const op = params.operation as string - if (op === 'partial_update_record') { - params.createIfNotExists = params.createIfNotExists !== 'false' + tool: (params: Record) => `algolia_${params.operation}`, + params: (params: Record) => { + const { operation, ...rest } = params + const result: Record = {} + + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + result[key] = value } - if (op === 'update_settings' && params.forwardToReplicas === 'true') { - params.forwardToReplicas = true - } else if (op === 'update_settings') { - params.forwardToReplicas = false + + const toBool = (value: unknown, defaultValue: boolean) => { + if (typeof value === 'boolean') return value + if (typeof value === 'string') return value === 'true' + return defaultValue + } + + if (operation === 'partial_update_record') { + result.createIfNotExists = toBool(result.createIfNotExists, true) } - if (op === 'copy_move_index') { - params.operation = params.copyMoveOperation + if (operation === 'update_settings') { + result.forwardToReplicas = toBool(result.forwardToReplicas, false) } - if (op === 'delete_by_filter') { - params.filters = params.deleteFilters + if (operation === 'search' && result.getRankingInfo !== undefined) { + result.getRankingInfo = toBool(result.getRankingInfo, false) } - if (op === 'get_records') { - params.requests = params.getRecordsRequests + if (operation === 'copy_move_index') { + result.operation = result.copyMoveOperation + result.copyMoveOperation = undefined } - if (op === 'list_indices') { - if (params.listPage !== undefined) params.page = params.listPage - if (params.listHitsPerPage !== undefined) params.hitsPerPage = params.listHitsPerPage + if (operation === 'delete_by_filter') { + result.filters = result.deleteFilters + result.deleteFilters = undefined } - return `algolia_${op}` + if (operation === 'get_records') { + result.requests = result.getRecordsRequests + result.getRecordsRequests = undefined + } + + return result }, }, }, @@ -553,6 +582,8 @@ Return ONLY the JSON array.`, page: { type: 'string', description: 'Page number' }, filters: { type: 'string', description: 'Algolia filter string' }, attributesToRetrieve: { type: 'string', description: 'Attributes to retrieve' }, + facets: { type: 'string', description: 'Comma-separated facet attribute names to count' }, + getRankingInfo: { type: 'string', description: 'Include detailed ranking info in each hit' }, cursor: { type: 'string', description: 'Browse cursor for pagination' }, record: { type: 'json', description: 'Record data to add' }, attributes: { type: 'json', description: 'Attributes to partially update' }, @@ -579,8 +610,7 @@ Return ONLY the JSON array.`, type: 'json', description: 'Array of objects with objectID to retrieve multiple records', }, - listPage: { type: 'string', description: 'Page number for list indices pagination' }, - listHitsPerPage: { type: 'string', description: 'Indices per page for list indices' }, + taskID: { type: 'string', description: 'Task ID returned by a previous write operation' }, applicationId: { type: 'string', description: 'Algolia Application ID' }, apiKey: { type: 'string', description: 'Algolia API Key' }, }, @@ -644,6 +674,10 @@ Return ONLY the JSON array.`, type: 'number', description: 'Maximum number of hits accessible via pagination (default 1000)', }, + status: { + type: 'string', + description: 'Task status: "published" once applied, "notPublished" while still pending', + }, }, } @@ -739,5 +773,19 @@ export const AlgoliaBlockMeta = { content: '# Audit Search Relevance\n\nCheck that important queries return good results from an Algolia index.\n\n## Steps\n1. Run each query in the provided test set against the index.\n2. Record the top results, total hit count, and whether the expected record appears.\n3. Flag queries that return zero hits, too many hits, or miss the expected record.\n\n## Output\nA table of queries with result counts and pass/fail, plus suggestions for synonyms or ranking tweaks where relevance is weak.', }, + { + name: 'tune-index-ranking', + description: + 'Read an Algolia index configuration, propose ranking and searchable-attribute changes, and apply the update.', + content: + '# Tune Index Ranking\n\nAdjust how an Algolia index ranks results without touching the underlying data.\n\n## Steps\n1. Fetch the current index settings (searchable attributes, custom ranking, ranking criteria).\n2. Compare them against the desired outcome (e.g., surface newer or more popular items first).\n3. Propose specific changes to customRanking, searchableAttributes order, or attributesForFaceting.\n4. Apply the approved settings update to the index.\n\n## Output\nA before/after summary of the settings changed and why, plus confirmation the update succeeded.', + }, + { + name: 'snapshot-index-before-change', + description: + 'Copy an Algolia index to a timestamped backup before applying a risky settings or data change.', + content: + '# Snapshot Index Before Change\n\nProtect against a bad settings or batch update by copying the index first.\n\n## Steps\n1. Copy the source index to a new destination index named with a date or version suffix.\n2. Confirm the copy completed by checking the resulting task status.\n3. Apply the intended change (settings update, batch operation, or delete-by-filter) to the original index.\n4. If the change causes problems, the snapshot index can be copied back or used for comparison.\n\n## Output\nThe name of the backup index created and confirmation the source change was applied afterward.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/amplitude.ts b/apps/sim/blocks/blocks/amplitude.ts index a05f9ed5a0c..71ac94a19b1 100644 --- a/apps/sim/blocks/blocks/amplitude.ts +++ b/apps/sim/blocks/blocks/amplitude.ts @@ -6,12 +6,12 @@ export const AmplitudeBlock: BlockConfig = { name: 'Amplitude', description: 'Track events and query analytics from Amplitude', longDescription: - 'Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data.', + 'Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data.', docsLink: 'https://docs.sim.ai/integrations/amplitude', category: 'tools', integrationType: IntegrationType.Analytics, - bgColor: '#1B1F3B', - iconColor: '#1F77E0', + bgColor: '#13294B', + iconColor: '#1E61F0', icon: AmplitudeIcon, authMode: AuthMode.ApiKey, @@ -32,6 +32,8 @@ export const AmplitudeBlock: BlockConfig = { { label: 'Real-time Active Users', id: 'realtime_active_users' }, { label: 'List Events', id: 'list_events' }, { label: 'Get Revenue', id: 'get_revenue' }, + { label: 'Funnels', id: 'funnels' }, + { label: 'Retention', id: 'retention' }, ], value: () => 'send_event', }, @@ -70,6 +72,8 @@ export const AmplitudeBlock: BlockConfig = { 'realtime_active_users', 'list_events', 'get_revenue', + 'funnels', + 'retention', ], }, placeholder: 'Enter your Amplitude Secret Key', @@ -85,10 +89,26 @@ export const AmplitudeBlock: BlockConfig = { 'realtime_active_users', 'list_events', 'get_revenue', + 'funnels', + 'retention', ], }, }, + // Data Residency (all operations except User Profile, which is US-only) + { + id: 'dataResidency', + title: 'Data Residency', + type: 'dropdown', + options: [ + { label: 'US (default)', id: 'us' }, + { label: 'EU', id: 'eu' }, + ], + value: () => 'us', + condition: { field: 'operation', value: 'user_profile', not: true }, + mode: 'advanced', + }, + // --- Send Event fields --- { id: 'eventType', @@ -460,6 +480,8 @@ export const AmplitudeBlock: BlockConfig = { title: 'Interval', type: 'dropdown', options: [ + { label: 'Real-time', id: '-300000' }, + { label: 'Hourly', id: '-3600000' }, { label: 'Daily', id: '1' }, { label: 'Weekly', id: '7' }, { label: 'Monthly', id: '30' }, @@ -476,6 +498,14 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'event_segmentation' }, mode: 'advanced', }, + { + id: 'segmentationGroupBy2', + title: 'Group By (2nd Property)', + type: 'short-input', + placeholder: 'Second property name (prefix custom with "gp:")', + condition: { field: 'operation', value: 'event_segmentation' }, + mode: 'advanced', + }, { id: 'segmentationLimit', title: 'Limit', @@ -484,6 +514,46 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'event_segmentation' }, mode: 'advanced', }, + { + id: 'segmentationFilters', + title: 'Filters', + type: 'long-input', + placeholder: + '[{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":["San Francisco"]}]', + condition: { field: 'operation', value: 'event_segmentation' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of Amplitude event segmentation filter objects, each with subprop_type ("event" or "user"), subprop_key, subprop_op (e.g. "is", "is not", "contains"), and subprop_value (array of strings). Return ONLY the JSON array - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'segmentationFormula', + title: 'Formula', + type: 'short-input', + placeholder: 'e.g., UNIQUES(A)/UNIQUES(B) — required when Metric is Formula', + condition: { + field: 'operation', + value: 'event_segmentation', + and: { field: 'segmentationMetric', value: 'formula' }, + }, + required: { + field: 'operation', + value: 'event_segmentation', + and: { field: 'segmentationMetric', value: 'formula' }, + }, + mode: 'advanced', + }, + { + id: 'segmentationSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'event_segmentation' }, + mode: 'advanced', + }, // --- Get Active Users fields --- { @@ -539,6 +609,22 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'get_active_users' }, mode: 'advanced', }, + { + id: 'activeUsersGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name', + condition: { field: 'operation', value: 'get_active_users' }, + mode: 'advanced', + }, + { + id: 'activeUsersSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'get_active_users' }, + mode: 'advanced', + }, // --- Get Revenue fields --- { @@ -596,6 +682,243 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'get_revenue' }, mode: 'advanced', }, + { + id: 'revenueGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name (limit: one)', + condition: { field: 'operation', value: 'get_revenue' }, + mode: 'advanced', + }, + { + id: 'revenueSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'get_revenue' }, + mode: 'advanced', + }, + + // --- Funnels fields --- + { + id: 'funnelEvents', + title: 'Funnel Steps', + type: 'long-input', + required: { field: 'operation', value: 'funnels' }, + placeholder: '[{"event_type":"signup"},{"event_type":"purchase"}]', + condition: { field: 'operation', value: 'funnels' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of Amplitude event objects, one per funnel step in order, each with an "event_type" key. Return ONLY the JSON array - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'funnelStart', + title: 'Start Date', + type: 'short-input', + required: { field: 'operation', value: 'funnels' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'funnels' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'funnelEnd', + title: 'End Date', + type: 'short-input', + required: { field: 'operation', value: 'funnels' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'funnels' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'funnelMode', + title: 'Funnel Mode', + type: 'dropdown', + options: [ + { label: 'Ordered', id: 'ordered' }, + { label: 'Unordered', id: 'unordered' }, + { label: 'Sequential', id: 'sequential' }, + ], + value: () => 'ordered', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelUserType', + title: 'User Type', + type: 'dropdown', + options: [ + { label: 'Active', id: 'active' }, + { label: 'New', id: 'new' }, + ], + value: () => 'active', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelInterval', + title: 'Interval', + type: 'dropdown', + options: [ + { label: 'Real-time', id: '-300000' }, + { label: 'Hourly', id: '-3600000' }, + { label: 'Daily', id: '1' }, + { label: 'Weekly', id: '7' }, + { label: 'Monthly', id: '30' }, + ], + value: () => '1', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelConversionWindowSeconds', + title: 'Conversion Window (seconds)', + type: 'short-input', + placeholder: '2592000 (30 days)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name (limit: one)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Max group-by values (max 1000)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + + // --- Retention fields --- + { + id: 'retentionStartEvent', + title: 'Starting Event', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: '{"event_type":"_new"}', + condition: { field: 'operation', value: 'retention' }, + }, + { + id: 'retentionReturnEvent', + title: 'Returning Event', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: '{"event_type":"_all"}', + condition: { field: 'operation', value: 'retention' }, + }, + { + id: 'retentionStart', + title: 'Start Date', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'retention' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'retentionEnd', + title: 'End Date', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'retention' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'retentionMode', + title: 'Retention Mode', + type: 'dropdown', + options: [ + { label: 'N-Day', id: 'n-day' }, + { label: 'Rolling', id: 'rolling' }, + { label: 'Bracket', id: 'bracket' }, + ], + value: () => 'n-day', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, + { + id: 'retentionBrackets', + title: 'Retention Brackets', + type: 'short-input', + placeholder: '[[0,4]] — required when Retention Mode is Bracket', + condition: { + field: 'operation', + value: 'retention', + and: { field: 'retentionMode', value: 'bracket' }, + }, + required: { + field: 'operation', + value: 'retention', + and: { field: 'retentionMode', value: 'bracket' }, + }, + mode: 'advanced', + }, + { + id: 'retentionInterval', + title: 'Interval', + type: 'dropdown', + options: [ + { label: 'Daily', id: '1' }, + { label: 'Weekly', id: '7' }, + { label: 'Monthly', id: '30' }, + ], + value: () => '1', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, + { + id: 'retentionGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name (limit: one)', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, + { + id: 'retentionSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, ], tools: { @@ -611,6 +934,8 @@ export const AmplitudeBlock: BlockConfig = { 'amplitude_realtime_active_users', 'amplitude_list_events', 'amplitude_get_revenue', + 'amplitude_funnels', + 'amplitude_retention', ], config: { tool: (params) => `amplitude_${params.operation}`, @@ -649,7 +974,11 @@ export const AmplitudeBlock: BlockConfig = { if (params.segmentationMetric) result.metric = params.segmentationMetric if (params.segmentationInterval) result.interval = params.segmentationInterval if (params.segmentationGroupBy) result.groupBy = params.segmentationGroupBy + if (params.segmentationGroupBy2) result.groupBy2 = params.segmentationGroupBy2 if (params.segmentationLimit) result.limit = params.segmentationLimit + if (params.segmentationFilters) result.filters = params.segmentationFilters + if (params.segmentationFormula) result.formula = params.segmentationFormula + if (params.segmentationSegment) result.segment = params.segmentationSegment break case 'get_active_users': @@ -657,6 +986,8 @@ export const AmplitudeBlock: BlockConfig = { if (params.activeUsersEnd) result.end = params.activeUsersEnd if (params.activeUsersMetric) result.metric = params.activeUsersMetric if (params.activeUsersInterval) result.interval = params.activeUsersInterval + if (params.activeUsersGroupBy) result.groupBy = params.activeUsersGroupBy + if (params.activeUsersSegment) result.segment = params.activeUsersSegment break case 'get_revenue': @@ -664,6 +995,34 @@ export const AmplitudeBlock: BlockConfig = { if (params.revenueEnd) result.end = params.revenueEnd if (params.revenueMetric) result.metric = params.revenueMetric if (params.revenueInterval) result.interval = params.revenueInterval + if (params.revenueGroupBy) result.groupBy = params.revenueGroupBy + if (params.revenueSegment) result.segment = params.revenueSegment + break + + case 'funnels': + if (params.funnelEvents) result.events = params.funnelEvents + if (params.funnelStart) result.start = params.funnelStart + if (params.funnelEnd) result.end = params.funnelEnd + if (params.funnelMode) result.mode = params.funnelMode + if (params.funnelUserType) result.userType = params.funnelUserType + if (params.funnelInterval) result.interval = params.funnelInterval + if (params.funnelConversionWindowSeconds) + result.conversionWindowSeconds = params.funnelConversionWindowSeconds + if (params.funnelGroupBy) result.groupBy = params.funnelGroupBy + if (params.funnelLimit) result.limit = params.funnelLimit + if (params.funnelSegment) result.segment = params.funnelSegment + break + + case 'retention': + if (params.retentionStartEvent) result.startEvent = params.retentionStartEvent + if (params.retentionReturnEvent) result.returnEvent = params.retentionReturnEvent + if (params.retentionStart) result.start = params.retentionStart + if (params.retentionEnd) result.end = params.retentionEnd + if (params.retentionMode) result.retentionMode = params.retentionMode + if (params.retentionBrackets) result.retentionBrackets = params.retentionBrackets + if (params.retentionInterval) result.interval = params.retentionInterval + if (params.retentionGroupBy) result.groupBy = params.retentionGroupBy + if (params.retentionSegment) result.segment = params.retentionSegment break } @@ -696,6 +1055,32 @@ export const AmplitudeBlock: BlockConfig = { activeUsersEnd: { type: 'string', description: 'Active users end date' }, revenueStart: { type: 'string', description: 'Revenue start date' }, revenueEnd: { type: 'string', description: 'Revenue end date' }, + dataResidency: { type: 'string', description: 'Data residency region: "us" or "eu"' }, + segmentationFilters: { type: 'string', description: 'Event segmentation filters JSON' }, + segmentationFormula: { type: 'string', description: 'Event segmentation formula expression' }, + segmentationGroupBy2: { + type: 'string', + description: 'Event segmentation second group-by property', + }, + segmentationSegment: { + type: 'string', + description: 'Event segmentation segment definition JSON', + }, + activeUsersGroupBy: { type: 'string', description: 'Active users group-by property' }, + activeUsersSegment: { type: 'string', description: 'Active users segment definition JSON' }, + revenueGroupBy: { type: 'string', description: 'Revenue group-by property' }, + revenueSegment: { type: 'string', description: 'Revenue segment definition JSON' }, + funnelEvents: { type: 'string', description: 'Funnel step event objects JSON array' }, + funnelStart: { type: 'string', description: 'Funnel analysis start date' }, + funnelEnd: { type: 'string', description: 'Funnel analysis end date' }, + funnelGroupBy: { type: 'string', description: 'Funnel group-by property' }, + funnelSegment: { type: 'string', description: 'Funnel segment definition JSON' }, + retentionStartEvent: { type: 'string', description: 'Retention starting event JSON object' }, + retentionReturnEvent: { type: 'string', description: 'Retention returning event JSON object' }, + retentionStart: { type: 'string', description: 'Retention analysis start date' }, + retentionEnd: { type: 'string', description: 'Retention analysis end date' }, + retentionGroupBy: { type: 'string', description: 'Retention group-by property' }, + retentionSegment: { type: 'string', description: 'Retention segment definition JSON' }, }, outputs: { @@ -711,10 +1096,43 @@ export const AmplitudeBlock: BlockConfig = { type: 'number', description: 'Number of events ingested (send_event)', }, + payloadSizeBytes: { + type: 'number', + description: 'Size of the ingested payload in bytes (send_event)', + }, + serverUploadTime: { + type: 'number', + description: 'Server-side upload timestamp (send_event)', + }, matches: { type: 'json', description: 'User search matches (amplitudeId, userId)', }, + type: { + type: 'string', + description: 'Match type, e.g. match_user_or_device_id (user_search)', + }, + userId: { + type: 'string', + description: 'External user ID (user_profile)', + }, + deviceId: { + type: 'string', + description: 'Device ID (user_profile)', + }, + ampProps: { + type: 'json', + description: + 'Amplitude user properties (library, first_used, last_used, custom) (user_profile)', + }, + cohortIds: { + type: 'json', + description: 'Cohort IDs the user belongs to (user_profile)', + }, + computations: { + type: 'json', + description: 'Computed user properties (user_profile)', + }, events: { type: 'json', description: 'Event list (list_events, user_activity)', @@ -725,7 +1143,8 @@ export const AmplitudeBlock: BlockConfig = { }, series: { type: 'json', - description: 'Time-series data (segmentation, active_users, revenue, realtime)', + description: + 'Time-series data (segmentation, active_users, realtime: number[][]; revenue: [{dates, values}]; retention: [{dates, values, combined}])', }, seriesLabels: { type: 'json', @@ -733,7 +1152,7 @@ export const AmplitudeBlock: BlockConfig = { }, seriesMeta: { type: 'json', - description: 'Metadata labels for data series (active_users)', + description: 'Metadata labels for data series (active_users, retention)', }, seriesCollapsed: { type: 'json', @@ -741,7 +1160,12 @@ export const AmplitudeBlock: BlockConfig = { }, xValues: { type: 'json', - description: 'X-axis date/time values for chart data', + description: 'X-axis date/time values for chart data (segmentation, active_users, realtime)', + }, + funnels: { + type: 'json', + description: + 'Funnel results per segment (stepByStep, cumulative, medianTransTimes, dayFunnels, etc.) (funnels)', }, }, } @@ -849,5 +1273,12 @@ export const AmplitudeBlockMeta = { content: '# Lookup User Activity\n\nInvestigate a single user in Amplitude for support or debugging.\n\n## Steps\n1. Search for the user by user ID, device ID, or Amplitude ID to resolve their Amplitude ID.\n2. Pull the user activity stream for that Amplitude ID, ordered latest first.\n3. Optionally fetch the user profile to see their current properties.\n\n## Output\nA timeline of the user recent events plus key profile properties. Note the time range covered.', }, + { + name: 'analyze-conversion-funnel', + description: + 'Run an Amplitude funnel across a sequence of events to find conversion rates and drop-off points.', + content: + '# Analyze Conversion Funnel\n\nMeasure how users progress through a multi-step flow in Amplitude.\n\n## Steps\n1. Define the ordered sequence of events that make up the funnel (e.g., signup, activation, purchase).\n2. Pick the date range and, if useful, a property to group by.\n3. Run the funnel query and read the step-by-step and cumulative conversion numbers.\n\n## Output\nConversion counts and rates at each step, the biggest drop-off point, and any group-by breakdown.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index b8efbc74d70..6a5feefbb83 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -215,6 +215,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'Comma-separated user IDs to filter by', mode: 'advanced', condition: { field: 'operation', value: ['list_expenses', 'list_card_transactions'] }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex user IDs to filter by based on the description.\n\nReturn ONLY the comma-separated user IDs - no explanations, no extra text.', + placeholder: 'Describe which users to include...', + }, }, { id: 'statuses', @@ -223,6 +229,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'e.g., APPROVED, SETTLED (comma-separated)', mode: 'advanced', condition: { field: 'operation', value: 'list_expenses' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex expense statuses to filter by.\n\nValid statuses: DRAFT, SUBMITTED, APPROVED, OUT_OF_POLICY, VOID, CANCELED, SPLIT, SETTLED\n\nExamples:\n- "only settled expenses" -> SETTLED\n- "approved or settled" -> APPROVED,SETTLED\n- "expenses awaiting review" -> DRAFT,SUBMITTED\n\nReturn ONLY the comma-separated status values - no explanations, no extra text.', + placeholder: 'Describe which expense statuses to include...', + }, }, { id: 'paymentStatuses', @@ -231,6 +243,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'e.g., CLEARED, REFUNDED (comma-separated)', mode: 'advanced', condition: { field: 'operation', value: 'list_expenses' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex expense payment statuses to filter by.\n\nValid statuses: NOT_STARTED, PROCESSING, CANCELED, DECLINED, CLEARED, REFUNDING, REFUNDED, CASH_ADVANCE, CREDITED, AWAITING_PAYMENT, SCHEDULED\n\nExamples:\n- "only cleared payments" -> CLEARED\n- "refunded or refunding" -> REFUNDED,REFUNDING\n\nReturn ONLY the comma-separated status values - no explanations, no extra text.', + placeholder: 'Describe which payment statuses to include...', + }, }, { id: 'purchasedAtStart', @@ -284,6 +302,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'Comma-separated user IDs to filter spend limits by member', mode: 'advanced', condition: { field: 'operation', value: 'list_spend_limits' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex user IDs to filter spend limits by member based on the description.\n\nReturn ONLY the comma-separated user IDs - no explanations, no extra text.', + placeholder: 'Describe which spend limit members to include...', + }, }, { id: 'cursor', @@ -567,6 +591,10 @@ export const BrexBlock: BlockConfig = { createdAt: { type: 'string', description: 'Creation timestamp of the transfer' }, displayName: { type: 'string', description: 'Display name of the transfer' }, externalMemo: { type: 'string', description: 'External memo of the transfer' }, + isPproEnabled: { + type: 'boolean', + description: 'Whether Principal Protection (PPRO) is enabled for the transfer', + }, }, } diff --git a/apps/sim/blocks/blocks/clerk.ts b/apps/sim/blocks/blocks/clerk.ts index ebdb7309157..b383283d005 100644 --- a/apps/sim/blocks/blocks/clerk.ts +++ b/apps/sim/blocks/blocks/clerk.ts @@ -9,7 +9,7 @@ export const ClerkBlock: BlockConfig = { name: 'Clerk', description: 'Manage users, organizations, and sessions in Clerk', longDescription: - 'Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions.', + 'Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens.', docsLink: 'https://docs.sim.ai/integrations/clerk', category: 'tools', integrationType: IntegrationType.Security, @@ -27,12 +27,35 @@ export const ClerkBlock: BlockConfig = { { label: 'Create User', id: 'clerk_create_user' }, { label: 'Update User', id: 'clerk_update_user' }, { label: 'Delete User', id: 'clerk_delete_user' }, + { label: 'Ban User', id: 'clerk_ban_user' }, + { label: 'Unban User', id: 'clerk_unban_user' }, + { label: 'Lock User', id: 'clerk_lock_user' }, + { label: 'Unlock User', id: 'clerk_unlock_user' }, + { label: 'Get User OAuth Token', id: 'clerk_get_user_oauth_token' }, { label: 'List Organizations', id: 'clerk_list_organizations' }, { label: 'Get Organization', id: 'clerk_get_organization' }, { label: 'Create Organization', id: 'clerk_create_organization' }, + { label: 'Update Organization', id: 'clerk_update_organization' }, + { label: 'Delete Organization', id: 'clerk_delete_organization' }, + { label: 'List Organization Memberships', id: 'clerk_list_organization_memberships' }, + { label: 'Add Organization Member', id: 'clerk_add_organization_member' }, + { label: 'Update Organization Membership', id: 'clerk_update_organization_membership' }, + { label: 'Remove Organization Member', id: 'clerk_remove_organization_member' }, + { label: 'Create Organization Invitation', id: 'clerk_create_organization_invitation' }, + { label: 'List Organization Invitations', id: 'clerk_list_organization_invitations' }, { label: 'List Sessions', id: 'clerk_list_sessions' }, { label: 'Get Session', id: 'clerk_get_session' }, { label: 'Revoke Session', id: 'clerk_revoke_session' }, + { label: 'List Allowlist Identifiers', id: 'clerk_list_allowlist_identifiers' }, + { label: 'Create Allowlist Identifier', id: 'clerk_create_allowlist_identifier' }, + { label: 'Delete Allowlist Identifier', id: 'clerk_delete_allowlist_identifier' }, + { label: 'List Blocklist Identifiers', id: 'clerk_list_blocklist_identifiers' }, + { label: 'Create Blocklist Identifier', id: 'clerk_create_blocklist_identifier' }, + { label: 'Delete Blocklist Identifier', id: 'clerk_delete_blocklist_identifier' }, + { label: 'List JWT Templates', id: 'clerk_list_jwt_templates' }, + { label: 'Get JWT Template', id: 'clerk_get_jwt_template' }, + { label: 'Create Actor Token', id: 'clerk_create_actor_token' }, + { label: 'Revoke Actor Token', id: 'clerk_revoke_actor_token' }, ], value: () => 'clerk_list_users', }, @@ -68,7 +91,47 @@ export const ClerkBlock: BlockConfig = { condition: { field: 'operation', value: 'clerk_list_users' }, mode: 'advanced', }, - // Get User params + { + id: 'phoneNumberFilter', + title: 'Phone Filter', + type: 'short-input', + placeholder: 'Filter by phone number (comma-separated)', + condition: { field: 'operation', value: 'clerk_list_users' }, + mode: 'advanced', + }, + { + id: 'externalIdFilter', + title: 'External ID Filter', + type: 'short-input', + placeholder: 'Filter by external ID (comma-separated)', + condition: { field: 'operation', value: 'clerk_list_users' }, + mode: 'advanced', + }, + { + id: 'userIdFilter', + title: 'User ID Filter', + type: 'short-input', + placeholder: 'Filter by user ID (comma-separated)', + condition: { field: 'operation', value: 'clerk_list_users' }, + mode: 'advanced', + }, + { + id: 'orderBy', + title: 'Sort By', + type: 'short-input', + placeholder: 'e.g. -created_at', + condition: { + field: 'operation', + value: [ + 'clerk_list_users', + 'clerk_list_organizations', + 'clerk_list_organization_memberships', + 'clerk_list_organization_invitations', + ], + }, + mode: 'advanced', + }, + // Get/Update/Delete/Ban/Unban/Lock/Unlock User, OAuth token, and Actor Token params { id: 'userId', title: 'User ID', @@ -76,20 +139,58 @@ export const ClerkBlock: BlockConfig = { placeholder: 'user_...', condition: { field: 'operation', - value: ['clerk_get_user', 'clerk_update_user', 'clerk_delete_user'], + value: [ + 'clerk_get_user', + 'clerk_update_user', + 'clerk_delete_user', + 'clerk_ban_user', + 'clerk_unban_user', + 'clerk_lock_user', + 'clerk_unlock_user', + 'clerk_get_user_oauth_token', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_actor_token', + ], }, required: { field: 'operation', - value: ['clerk_get_user', 'clerk_update_user', 'clerk_delete_user'], + value: [ + 'clerk_get_user', + 'clerk_update_user', + 'clerk_delete_user', + 'clerk_ban_user', + 'clerk_unban_user', + 'clerk_lock_user', + 'clerk_unlock_user', + 'clerk_get_user_oauth_token', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_actor_token', + ], }, }, + { + id: 'provider', + title: 'OAuth Provider', + type: 'short-input', + placeholder: 'google, github, microsoft...', + condition: { field: 'operation', value: 'clerk_get_user_oauth_token' }, + required: { field: 'operation', value: 'clerk_get_user_oauth_token' }, + }, // Create/Update User params { id: 'emailAddress', title: 'Email Address', type: 'short-input', placeholder: 'user@example.com (comma-separated for multiple)', - condition: { field: 'operation', value: 'clerk_create_user' }, + condition: { + field: 'operation', + value: ['clerk_create_user', 'clerk_create_organization_invitation'], + }, + required: { field: 'operation', value: 'clerk_create_organization_invitation' }, }, { id: 'phoneNumber', @@ -143,7 +244,15 @@ export const ClerkBlock: BlockConfig = { type: 'code', language: 'json', placeholder: '{"role": "admin"}', - condition: { field: 'operation', value: ['clerk_create_user', 'clerk_update_user'] }, + condition: { + field: 'operation', + value: [ + 'clerk_create_user', + 'clerk_update_user', + 'clerk_create_organization', + 'clerk_create_organization_invitation', + ], + }, mode: 'advanced', }, { @@ -152,7 +261,15 @@ export const ClerkBlock: BlockConfig = { type: 'code', language: 'json', placeholder: '{"internalId": "123"}', - condition: { field: 'operation', value: ['clerk_create_user', 'clerk_update_user'] }, + condition: { + field: 'operation', + value: [ + 'clerk_create_user', + 'clerk_update_user', + 'clerk_create_organization', + 'clerk_create_organization_invitation', + ], + }, mode: 'advanced', }, // Organization params @@ -176,15 +293,44 @@ export const ClerkBlock: BlockConfig = { title: 'Organization ID', type: 'short-input', placeholder: 'org_... or slug', - condition: { field: 'operation', value: 'clerk_get_organization' }, - required: { field: 'operation', value: 'clerk_get_organization' }, + condition: { + field: 'operation', + value: [ + 'clerk_get_organization', + 'clerk_update_organization', + 'clerk_delete_organization', + 'clerk_list_organization_memberships', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_organization_invitation', + 'clerk_list_organization_invitations', + ], + }, + required: { + field: 'operation', + value: [ + 'clerk_get_organization', + 'clerk_update_organization', + 'clerk_delete_organization', + 'clerk_list_organization_memberships', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_organization_invitation', + 'clerk_list_organization_invitations', + ], + }, }, { id: 'orgName', title: 'Organization Name', type: 'short-input', placeholder: 'Acme Corp', - condition: { field: 'operation', value: 'clerk_create_organization' }, + condition: { + field: 'operation', + value: ['clerk_create_organization', 'clerk_update_organization'], + }, required: { field: 'operation', value: 'clerk_create_organization' }, }, { @@ -200,7 +346,10 @@ export const ClerkBlock: BlockConfig = { title: 'Slug', type: 'short-input', placeholder: 'acme-corp', - condition: { field: 'operation', value: 'clerk_create_organization' }, + condition: { + field: 'operation', + value: ['clerk_create_organization', 'clerk_update_organization'], + }, mode: 'advanced', }, { @@ -208,7 +357,95 @@ export const ClerkBlock: BlockConfig = { title: 'Max Members', type: 'short-input', placeholder: '0 for unlimited', - condition: { field: 'operation', value: 'clerk_create_organization' }, + condition: { + field: 'operation', + value: ['clerk_create_organization', 'clerk_update_organization'], + }, + mode: 'advanced', + }, + { + id: 'adminDeleteEnabled', + title: 'Admin Delete Enabled', + type: 'switch', + condition: { field: 'operation', value: 'clerk_update_organization' }, + mode: 'advanced', + }, + // Organization Membership / Invitation params + { + id: 'role', + title: 'Role', + type: 'short-input', + placeholder: 'org:admin or org:member', + condition: { + field: 'operation', + value: [ + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_create_organization_invitation', + 'clerk_list_organization_memberships', + ], + }, + required: { + field: 'operation', + value: [ + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_create_organization_invitation', + ], + }, + }, + { + id: 'inviterUserId', + title: 'Inviter User ID', + type: 'short-input', + placeholder: 'user_... (who sent the invite)', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'redirectUrl', + title: 'Redirect URL', + type: 'short-input', + placeholder: 'https://yourapp.com/accept-invite', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'expiresInDays', + title: 'Expires In (Days)', + type: 'short-input', + placeholder: '1-365, default: 30', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'notifyInvitation', + title: 'Send Invitation Email', + type: 'switch', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'invitationEmailFilter', + title: 'Email Filter', + type: 'short-input', + placeholder: 'Filter by invited email', + condition: { field: 'operation', value: 'clerk_list_organization_invitations' }, + mode: 'advanced', + }, + { + id: 'invitationStatus', + title: 'Status', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Pending', id: 'pending' }, + { label: 'Accepted', id: 'accepted' }, + { label: 'Revoked', id: 'revoked' }, + { label: 'Expired', id: 'expired' }, + ], + value: () => '', + condition: { field: 'operation', value: 'clerk_list_organization_invitations' }, mode: 'advanced', }, // Session params @@ -238,6 +475,8 @@ export const ClerkBlock: BlockConfig = { { label: 'Ended', id: 'ended' }, { label: 'Expired', id: 'expired' }, { label: 'Revoked', id: 'revoked' }, + { label: 'Removed', id: 'removed' }, + { label: 'Replaced', id: 'replaced' }, { label: 'Abandoned', id: 'abandoned' }, { label: 'Pending', id: 'pending' }, ], @@ -253,6 +492,85 @@ export const ClerkBlock: BlockConfig = { condition: { field: 'operation', value: ['clerk_get_session', 'clerk_revoke_session'] }, required: { field: 'operation', value: ['clerk_get_session', 'clerk_revoke_session'] }, }, + // Allowlist / Blocklist params + { + id: 'identifier', + title: 'Identifier', + type: 'short-input', + placeholder: 'user@example.com, +1234567890, or a web3 wallet', + condition: { + field: 'operation', + value: ['clerk_create_allowlist_identifier', 'clerk_create_blocklist_identifier'], + }, + required: { + field: 'operation', + value: ['clerk_create_allowlist_identifier', 'clerk_create_blocklist_identifier'], + }, + }, + { + id: 'allowlistNotify', + title: 'Notify Identifier', + type: 'switch', + condition: { field: 'operation', value: 'clerk_create_allowlist_identifier' }, + mode: 'advanced', + }, + { + id: 'identifierId', + title: 'Identifier ID', + type: 'short-input', + placeholder: 'The ID of the allowlist/blocklist identifier', + condition: { + field: 'operation', + value: ['clerk_delete_allowlist_identifier', 'clerk_delete_blocklist_identifier'], + }, + required: { + field: 'operation', + value: ['clerk_delete_allowlist_identifier', 'clerk_delete_blocklist_identifier'], + }, + }, + // JWT Template params + { + id: 'templateId', + title: 'Template ID', + type: 'short-input', + placeholder: 'The ID of the JWT template', + condition: { field: 'operation', value: 'clerk_get_jwt_template' }, + required: { field: 'operation', value: 'clerk_get_jwt_template' }, + }, + // Actor Token params + { + id: 'actor', + title: 'Actor', + type: 'code', + language: 'json', + placeholder: '{"sub": "user_support_agent_id"}', + condition: { field: 'operation', value: 'clerk_create_actor_token' }, + required: { field: 'operation', value: 'clerk_create_actor_token' }, + }, + { + id: 'expiresInSeconds', + title: 'Expires In (Seconds)', + type: 'short-input', + placeholder: 'Default: 3600', + condition: { field: 'operation', value: 'clerk_create_actor_token' }, + mode: 'advanced', + }, + { + id: 'sessionMaxDurationInSeconds', + title: 'Session Max Duration (Seconds)', + type: 'short-input', + placeholder: 'Default: 1800', + condition: { field: 'operation', value: 'clerk_create_actor_token' }, + mode: 'advanced', + }, + { + id: 'actorTokenId', + title: 'Actor Token ID', + type: 'short-input', + placeholder: 'The ID of the actor token to revoke', + condition: { field: 'operation', value: 'clerk_revoke_actor_token' }, + required: { field: 'operation', value: 'clerk_revoke_actor_token' }, + }, // Pagination params (common) { id: 'limit', @@ -261,7 +579,14 @@ export const ClerkBlock: BlockConfig = { placeholder: 'Results per page (1-500, default: 10)', condition: { field: 'operation', - value: ['clerk_list_users', 'clerk_list_organizations', 'clerk_list_sessions'], + value: [ + 'clerk_list_users', + 'clerk_list_organizations', + 'clerk_list_sessions', + 'clerk_list_organization_memberships', + 'clerk_list_organization_invitations', + 'clerk_list_allowlist_identifiers', + ], }, mode: 'advanced', }, @@ -272,7 +597,14 @@ export const ClerkBlock: BlockConfig = { placeholder: 'Skip N results for pagination', condition: { field: 'operation', - value: ['clerk_list_users', 'clerk_list_organizations', 'clerk_list_sessions'], + value: [ + 'clerk_list_users', + 'clerk_list_organizations', + 'clerk_list_sessions', + 'clerk_list_organization_memberships', + 'clerk_list_organization_invitations', + 'clerk_list_allowlist_identifiers', + ], }, mode: 'advanced', }, @@ -280,8 +612,15 @@ export const ClerkBlock: BlockConfig = { ...getTrigger('clerk_user_updated').subBlocks, ...getTrigger('clerk_user_deleted').subBlocks, ...getTrigger('clerk_session_created').subBlocks, + ...getTrigger('clerk_session_ended').subBlocks, + ...getTrigger('clerk_session_removed').subBlocks, + ...getTrigger('clerk_session_revoked').subBlocks, ...getTrigger('clerk_organization_created').subBlocks, + ...getTrigger('clerk_organization_updated').subBlocks, + ...getTrigger('clerk_organization_deleted').subBlocks, ...getTrigger('clerk_organization_membership_created').subBlocks, + ...getTrigger('clerk_organization_membership_updated').subBlocks, + ...getTrigger('clerk_organization_membership_deleted').subBlocks, ...getTrigger('clerk_webhook').subBlocks, ], @@ -292,8 +631,15 @@ export const ClerkBlock: BlockConfig = { 'clerk_user_updated', 'clerk_user_deleted', 'clerk_session_created', + 'clerk_session_ended', + 'clerk_session_removed', + 'clerk_session_revoked', 'clerk_organization_created', + 'clerk_organization_updated', + 'clerk_organization_deleted', 'clerk_organization_membership_created', + 'clerk_organization_membership_updated', + 'clerk_organization_membership_deleted', 'clerk_webhook', ], }, @@ -305,12 +651,35 @@ export const ClerkBlock: BlockConfig = { 'clerk_create_user', 'clerk_update_user', 'clerk_delete_user', + 'clerk_ban_user', + 'clerk_unban_user', + 'clerk_lock_user', + 'clerk_unlock_user', + 'clerk_get_user_oauth_token', 'clerk_list_organizations', 'clerk_get_organization', 'clerk_create_organization', + 'clerk_update_organization', + 'clerk_delete_organization', + 'clerk_list_organization_memberships', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_organization_invitation', + 'clerk_list_organization_invitations', 'clerk_list_sessions', 'clerk_get_session', 'clerk_revoke_session', + 'clerk_list_allowlist_identifiers', + 'clerk_create_allowlist_identifier', + 'clerk_delete_allowlist_identifier', + 'clerk_list_blocklist_identifiers', + 'clerk_create_blocklist_identifier', + 'clerk_delete_blocklist_identifier', + 'clerk_list_jwt_templates', + 'clerk_get_jwt_template', + 'clerk_create_actor_token', + 'clerk_revoke_actor_token', ], config: { tool: (params) => params.operation as string, @@ -320,13 +689,20 @@ export const ClerkBlock: BlockConfig = { secretKey, emailAddressFilter, usernameFilter, + phoneNumberFilter, + externalIdFilter, + userIdFilter, orgQuery, orgName, sessionUserId, sessionStatus, + invitationEmailFilter, + invitationStatus, + notifyInvitation, + allowlistNotify, publicMetadata, privateMetadata, - maxAllowedMemberships, + actor, ...rest } = params @@ -339,9 +715,14 @@ export const ClerkBlock: BlockConfig = { case 'clerk_list_users': if (emailAddressFilter) cleanParams.emailAddress = emailAddressFilter if (usernameFilter) cleanParams.username = usernameFilter + if (phoneNumberFilter) cleanParams.phoneNumber = phoneNumberFilter + if (externalIdFilter) cleanParams.externalId = externalIdFilter + if (userIdFilter) cleanParams.userId = userIdFilter break case 'clerk_create_user': case 'clerk_update_user': + case 'clerk_create_organization_invitation': + case 'clerk_create_organization': if (publicMetadata) { cleanParams.publicMetadata = typeof publicMetadata === 'string' ? JSON.parse(publicMetadata) : publicMetadata @@ -350,25 +731,54 @@ export const ClerkBlock: BlockConfig = { cleanParams.privateMetadata = typeof privateMetadata === 'string' ? JSON.parse(privateMetadata) : privateMetadata } + if ( + operation === 'clerk_create_organization_invitation' && + notifyInvitation !== undefined + ) { + cleanParams.notify = notifyInvitation + } + if (operation === 'clerk_create_organization' && orgName) { + cleanParams.name = orgName + } break case 'clerk_list_organizations': if (orgQuery) cleanParams.query = orgQuery break - case 'clerk_create_organization': + case 'clerk_update_organization': if (orgName) cleanParams.name = orgName - if (maxAllowedMemberships) - cleanParams.maxAllowedMemberships = Number(maxAllowedMemberships) break case 'clerk_list_sessions': if (sessionUserId) cleanParams.userId = sessionUserId if (sessionStatus) cleanParams.status = sessionStatus break + case 'clerk_list_organization_invitations': + if (invitationEmailFilter) cleanParams.emailAddress = invitationEmailFilter + if (invitationStatus) cleanParams.status = invitationStatus + break + case 'clerk_create_allowlist_identifier': + if (allowlistNotify !== undefined) cleanParams.notify = allowlistNotify + break + case 'clerk_create_actor_token': + if (actor !== undefined) { + cleanParams.actor = typeof actor === 'string' ? JSON.parse(actor) : actor + } + break } + // Fields that arrive as strings from short-input UI but must be numbers at execution time + const numericFields = new Set([ + 'limit', + 'offset', + 'maxAllowedMemberships', + 'expiresInDays', + 'expiresInSeconds', + 'sessionMaxDurationInSeconds', + ]) + // Add remaining params that don't need mapping Object.entries(rest).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { - cleanParams[key] = value + cleanParams[key] = numericFields.has(key) ? Number(value) : value } }) @@ -383,6 +793,7 @@ export const ClerkBlock: BlockConfig = { userId: { type: 'string', description: 'User ID' }, organizationId: { type: 'string', description: 'Organization ID or slug' }, sessionId: { type: 'string', description: 'Session ID' }, + role: { type: 'string', description: 'Organization role, e.g. org:admin or org:member' }, query: { type: 'string', description: 'Search query' }, limit: { type: 'number', description: 'Results per page' }, offset: { type: 'number', description: 'Pagination offset' }, @@ -393,8 +804,13 @@ export const ClerkBlock: BlockConfig = { users: { type: 'json', description: 'Array of user objects' }, organizations: { type: 'json', description: 'Array of organization objects' }, sessions: { type: 'json', description: 'Array of session objects' }, + memberships: { type: 'json', description: 'Array of organization membership objects' }, + invitations: { type: 'json', description: 'Array of organization invitation objects' }, + identifiers: { type: 'json', description: 'Array of allowlist/blocklist identifier objects' }, + templates: { type: 'json', description: 'Array of JWT template objects' }, + accessTokens: { type: 'json', description: 'Array of OAuth access token objects' }, // Single entity fields (destructured from get/create/update operations) - id: { type: 'string', description: 'Resource ID (user, organization, or session)' }, + id: { type: 'string', description: 'Resource ID (user, organization, session, etc.)' }, name: { type: 'string', description: 'Organization name' }, slug: { type: 'string', description: 'Organization slug' }, username: { type: 'string', description: 'Username' }, @@ -404,23 +820,70 @@ export const ClerkBlock: BlockConfig = { hasImage: { type: 'boolean', description: 'Whether resource has an image' }, emailAddresses: { type: 'json', description: 'User email addresses' }, phoneNumbers: { type: 'json', description: 'User phone numbers' }, + emailAddress: { type: 'string', description: 'Email address (for invitations)' }, primaryEmailAddressId: { type: 'string', description: 'Primary email address ID' }, primaryPhoneNumberId: { type: 'string', description: 'Primary phone number ID' }, + primaryWeb3WalletId: { type: 'string', description: 'Primary Web3 wallet ID' }, externalId: { type: 'string', description: 'External system ID' }, passwordEnabled: { type: 'boolean', description: 'Whether password is enabled' }, twoFactorEnabled: { type: 'boolean', description: 'Whether 2FA is enabled' }, + totpEnabled: { type: 'boolean', description: 'Whether TOTP is enabled' }, + backupCodeEnabled: { type: 'boolean', description: 'Whether backup codes are enabled' }, + deleteSelfEnabled: { type: 'boolean', description: 'Whether user can delete themselves' }, + createOrganizationEnabled: { + type: 'boolean', + description: 'Whether user can create organizations', + }, banned: { type: 'boolean', description: 'Whether user is banned' }, locked: { type: 'boolean', description: 'Whether user is locked' }, - userId: { type: 'string', description: 'User ID (for sessions)' }, + lockoutExpiresInSeconds: { type: 'number', description: 'Seconds until lockout expires' }, + userId: { type: 'string', description: 'User ID (for sessions and memberships)' }, clientId: { type: 'string', description: 'Client ID (for sessions)' }, - status: { type: 'string', description: 'Session status' }, + status: { type: 'string', description: 'Session or invitation status' }, lastActiveAt: { type: 'number', description: 'Last activity timestamp' }, + lastActiveOrganizationId: { + type: 'string', + description: 'Last active organization ID (for sessions)', + }, lastSignInAt: { type: 'number', description: 'Last sign-in timestamp' }, membersCount: { type: 'number', description: 'Number of members' }, + pendingInvitationsCount: { type: 'number', description: 'Number of pending invitations' }, maxAllowedMemberships: { type: 'number', description: 'Max allowed memberships' }, adminDeleteEnabled: { type: 'boolean', description: 'Whether admin delete is enabled' }, createdBy: { type: 'string', description: 'Creator user ID' }, publicMetadata: { type: 'json', description: 'Public metadata' }, + privateMetadata: { type: 'json', description: 'Private metadata' }, + unsafeMetadata: { type: 'json', description: 'Unsafe metadata' }, + organizationId: { + type: 'string', + description: 'Organization ID (for memberships/invitations)', + }, + role: { type: 'string', description: 'Organization membership role' }, + roleName: { type: 'string', description: 'Human-readable role name' }, + permissions: { type: 'json', description: 'Permissions granted by the role' }, + identifier: { type: 'string', description: 'Allowlist/blocklist identifier value' }, + identifierType: { type: 'string', description: 'Identifier type (email, phone, web3 wallet)' }, + invitationId: { type: 'string', description: 'Allowlist invitation ID' }, + inviterId: { type: 'string', description: 'User ID of the invitation inviter' }, + inviterEmail: { type: 'string', description: "Inviter's email address" }, + inviterFirstName: { type: 'string', description: "Inviter's first name" }, + inviterLastName: { type: 'string', description: "Inviter's last name" }, + expiresAt: { type: 'number', description: 'Expiration timestamp (invitation, actor token)' }, + expireAt: { type: 'number', description: 'Expiration timestamp (session)' }, + abandonAt: { type: 'number', description: 'Session abandon timestamp' }, + url: { type: 'string', description: 'Invitation or actor token URL' }, + token: { type: 'string', description: 'OAuth access token or actor token' }, + provider: { type: 'string', description: 'OAuth provider' }, + scopes: { type: 'json', description: 'OAuth scopes granted to the token' }, + claims: { type: 'json', description: 'JWT template claims' }, + lifetime: { type: 'number', description: 'JWT template lifetime in seconds' }, + allowedClockSkew: { type: 'number', description: 'JWT template allowed clock skew in seconds' }, + customSigningKey: { + type: 'boolean', + description: 'Whether the JWT template uses a custom signing key', + }, + signingAlgorithm: { type: 'string', description: 'JWT template signing algorithm' }, + actor: { type: 'json', description: 'Actor object identifying who is impersonating' }, // Common outputs totalCount: { type: 'number', description: 'Total count for paginated results' }, deleted: { type: 'boolean', description: 'Whether the resource was deleted' }, @@ -469,7 +932,7 @@ export const ClerkBlockMeta = { icon: ClerkIcon, title: 'Clerk org-management automator', prompt: - 'Create a workflow that on a new enterprise plan via Stripe creates a Clerk organization, invites the admin, and writes the Clerk org ID back to the Stripe customer.', + 'Create a workflow that on a new enterprise plan via Stripe creates a Clerk organization, invites the admin by email, and writes the Clerk org ID back to the Stripe customer.', modules: ['agent', 'workflows'], category: 'operations', tags: ['enterprise', 'automation'], @@ -479,7 +942,7 @@ export const ClerkBlockMeta = { icon: ClerkIcon, title: 'Clerk inactive-user cleaner', prompt: - 'Build a scheduled workflow that finds Clerk users with no sign-ins in 180 days, sends a re-engagement email, and removes accounts after a grace period.', + 'Build a scheduled workflow that finds Clerk users with no sign-ins in 180 days, sends a re-engagement email, and bans accounts that stay inactive after a grace period.', modules: ['scheduled', 'agent', 'workflows'], category: 'operations', tags: ['automation', 'enterprise'], @@ -489,7 +952,7 @@ export const ClerkBlockMeta = { icon: ClerkIcon, title: 'Clerk access-review automator', prompt: - 'Create a scheduled quarterly workflow that lists Clerk organizations and their users, requires owner re-attestation, and writes the review trail to a compliance table.', + 'Create a scheduled quarterly workflow that lists Clerk organizations and their memberships, requires owner re-attestation, and writes the review trail to a compliance table.', modules: ['scheduled', 'tables', 'agent', 'workflows'], category: 'operations', tags: ['legal', 'enterprise'], @@ -511,7 +974,7 @@ export const ClerkBlockMeta = { description: 'Look up a Clerk user by email, username, or name and return their profile. Use to resolve a user before acting on their account or syncing them elsewhere.', content: - '# Find User\n\nLocate a Clerk user account.\n\n## Steps\n1. Use List Users with a Search Query (matches email, phone, username, or name), or the email/username filters for an exact match.\n2. If you already have the Clerk user id (user_...), use Get User instead for the full record.\n3. Review the returned profile: id, primary email, name, externalId, and flags like banned, locked, and twoFactorEnabled.\n\n## Output\nReturn the matched user id, primary email, name, and key status flags. If multiple users match, list the candidates with their emails so the right one can be confirmed; if none match, say so.', + '# Find User\n\nLocate a Clerk user account.\n\n## Steps\n1. Use List Users with a Search Query (matches email, phone, username, or name), or the email/username/phone/external ID filters for an exact match.\n2. If you already have the Clerk user id (user_...), use Get User instead for the full record.\n3. Review the returned profile: id, primary email, name, externalId, and flags like banned, locked, and twoFactorEnabled.\n\n## Output\nReturn the matched user id, primary email, name, and key status flags. If multiple users match, list the candidates with their emails so the right one can be confirmed; if none match, say so.', }, { name: 'provision-user', @@ -520,6 +983,13 @@ export const ClerkBlockMeta = { content: '# Provision User\n\nCreate or update a Clerk user.\n\n## Steps\n1. To create, use Create User with at least an email address (and optionally phone, username, password, first/last name).\n2. To set application roles or app data, pass Public Metadata (visible to the frontend) and Private Metadata (server-only) as JSON.\n3. Set External ID to link the Clerk user to your own system id.\n4. To modify an existing user, use Update User with the user id and only the fields that change.\n\n## Output\nReturn the user id, primary email, and the metadata that was set. Confirm whether the user was created or updated. If a required field is missing or the email already exists, report it clearly.', }, + { + name: 'moderate-user-access', + description: + 'Ban, unban, lock, or unlock a Clerk user to control their ability to sign in. Use for abuse response, suspicious-activity containment, or manual account recovery.', + content: + '# Moderate User Access\n\nControl whether a Clerk user can sign in.\n\n## Steps\n1. Resolve the target user id first (see find-user) if you only have an email or name.\n2. Use Ban User to immediately block all sign-in attempts (e.g. for confirmed abuse); use Unban User to lift it once resolved.\n3. Use Lock User for a temporary, reversible hold (e.g. suspicious login pattern under review); use Unlock User to restore access.\n4. For a full audit trail, use Audit User Sessions afterward to revoke any sessions that should not continue.\n\n## Output\nReturn the user id and the resulting banned/locked flags after the action. State clearly which control was applied and why, so the moderation trail is auditable.', + }, { name: 'audit-user-sessions', description: @@ -530,9 +1000,9 @@ export const ClerkBlockMeta = { { name: 'manage-organization', description: - 'Create a Clerk organization or look up its details and membership. Use when provisioning a new team or tenant in a multi-tenant app.', + 'Create or update a Clerk organization, manage its memberships, and invite new members. Use when provisioning a new team or tenant in a multi-tenant app, or when onboarding/offboarding members.', content: - '# Manage Organization\n\nCreate or inspect a Clerk organization.\n\n## Steps\n1. To create, use Create Organization with the organization name and the Creator User ID (that user becomes the admin); optionally set a slug and max members.\n2. To inspect, use Get Organization by org id or slug, or List Organizations with a search query and include members count.\n3. Read back the org id, slug, members count, and limits.\n\n## Output\nReturn the organization id, name, slug, and member count. When creating, confirm the admin user and echo the org id so it can be linked back to your billing or CRM record.', + "# Manage Organization\n\nCreate, inspect, and staff a Clerk organization.\n\n## Steps\n1. To create, use Create Organization with the organization name and the Creator User ID (that user becomes the admin); optionally set a slug and max members. Use Update Organization to rename, re-slug, or change membership limits later.\n2. To inspect, use Get Organization by org id or slug, or List Organizations with a search query and include members count.\n3. To staff the org, use Add Organization Member with an existing user id and role, or Create Organization Invitation to invite someone by email who does not have an account yet.\n4. Use List Organization Memberships to see current members and their roles, Update Organization Membership to change a member's role, and Remove Organization Member to offboard someone.\n5. Use List Organization Invitations to check on pending invites.\n\n## Output\nReturn the organization id, name, slug, and member count. When adding or inviting a member, confirm the user id or email and the assigned role. When creating, confirm the admin user and echo the org id so it can be linked back to your billing or CRM record.", }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/dropcontact.ts b/apps/sim/blocks/blocks/dropcontact.ts index be4ca04a2f2..d335da37266 100644 --- a/apps/sim/blocks/blocks/dropcontact.ts +++ b/apps/sim/blocks/blocks/dropcontact.ts @@ -10,7 +10,8 @@ export const DropcontactBlock: BlockConfig = { 'Use Dropcontact to verify and enrich B2B contacts. Submit a contact with their name, company, website, or LinkedIn URL and receive a verified professional email, phone number, company firmographics, and LinkedIn profile. Enrichment is async: Dropcontact processes the request, then Sim polls until the result is ready. Credits are only charged when a verified email is returned.', docsLink: 'https://docs.sim.ai/tools/dropcontact', category: 'tools', - bgColor: '#0066FF', + bgColor: '#0ABA9F', + iconColor: '#0ABA9F', icon: DropcontactIcon, authMode: AuthMode.ApiKey, integrationType: IntegrationType.Sales, diff --git a/apps/sim/blocks/blocks/fathom.ts b/apps/sim/blocks/blocks/fathom.ts index 2bd604f6a28..ed14925a544 100644 --- a/apps/sim/blocks/blocks/fathom.ts +++ b/apps/sim/blocks/blocks/fathom.ts @@ -24,6 +24,7 @@ export const FathomBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'List Meetings', id: 'fathom_list_meetings' }, + { label: 'List Meeting Types', id: 'fathom_list_meeting_types' }, { label: 'Get Summary', id: 'fathom_get_summary' }, { label: 'Get Transcript', id: 'fathom_get_transcript' }, { label: 'List Team Members', id: 'fathom_list_team_members' }, @@ -83,6 +84,17 @@ export const FathomBlock: BlockConfig = { value: () => 'false', condition: { field: 'operation', value: 'fathom_list_meetings' }, }, + { + id: 'includeHighlights', + title: 'Include Highlights', + 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', @@ -128,6 +140,34 @@ export const FathomBlock: BlockConfig = { }, mode: 'advanced', }, + { + id: 'meetingType', + title: 'Meeting Type', + type: 'short-input', + placeholder: 'Filter by meeting type name', + condition: { field: 'operation', value: 'fathom_list_meetings' }, + mode: 'advanced', + }, + { + id: 'calendarInviteesDomains', + title: 'Invitee Domain', + type: 'short-input', + placeholder: 'Filter by calendar invitee company domain', + condition: { field: 'operation', value: 'fathom_list_meetings' }, + mode: 'advanced', + }, + { + id: 'calendarInviteesDomainsType', + title: 'Invitee Domain Type', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Only Internal', id: 'only_internal' }, + { label: 'One or More External', id: 'one_or_more_external' }, + ], + condition: { field: 'operation', value: 'fathom_list_meetings' }, + mode: 'advanced', + }, { id: 'cursor', title: 'Pagination Cursor', @@ -135,7 +175,12 @@ export const FathomBlock: BlockConfig = { placeholder: 'Cursor from a previous response', condition: { field: 'operation', - value: ['fathom_list_meetings', 'fathom_list_team_members', 'fathom_list_teams'], + value: [ + 'fathom_list_meetings', + 'fathom_list_meeting_types', + 'fathom_list_team_members', + 'fathom_list_teams', + ], }, mode: 'advanced', }, @@ -162,6 +207,7 @@ export const FathomBlock: BlockConfig = { tools: { access: [ 'fathom_list_meetings', + 'fathom_list_meeting_types', 'fathom_get_summary', 'fathom_get_transcript', 'fathom_list_team_members', @@ -187,6 +233,10 @@ export const FathomBlock: BlockConfig = { type: 'string', description: 'Include linked CRM matches in meetings response', }, + includeHighlights: { + type: 'string', + description: 'Include highlights in meetings response', + }, createdAfter: { type: 'string', description: 'Filter meetings created after this timestamp' }, createdBefore: { type: 'string', @@ -194,10 +244,20 @@ export const FathomBlock: BlockConfig = { }, recordedBy: { type: 'string', description: 'Filter by recorder email' }, teams: { type: 'string', description: 'Filter by team name' }, + meetingType: { type: 'string', description: 'Filter by meeting type name' }, + calendarInviteesDomains: { + type: 'string', + description: 'Filter by calendar invitee company domain', + }, + calendarInviteesDomainsType: { + type: 'string', + description: 'Filter by invitee domain type', + }, cursor: { type: 'string', description: 'Pagination cursor for next page' }, }, outputs: { meetings: { type: 'json', description: 'List of meetings' }, + meetingTypes: { type: 'json', description: 'List of meeting types' }, template_name: { type: 'string', description: 'Summary template name' }, markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' }, transcript: { type: 'json', description: 'Meeting transcript entries' }, diff --git a/apps/sim/blocks/blocks/gong.ts b/apps/sim/blocks/blocks/gong.ts index 2a367701f40..7a28123fa3a 100644 --- a/apps/sim/blocks/blocks/gong.ts +++ b/apps/sim/blocks/blocks/gong.ts @@ -43,9 +43,13 @@ export const GongBlock: BlockConfig = { { label: 'List Trackers', id: 'list_trackers' }, { label: 'List Workspaces', id: 'list_workspaces' }, { label: 'List Flows', id: 'list_flows' }, + { label: 'Assign Flow Prospects', id: 'assign_flow_prospects' }, + { label: 'Get Prospect Flows', id: 'get_prospect_flows' }, { label: 'Get Coaching', id: 'get_coaching' }, { label: 'Lookup Email', id: 'lookup_email' }, { label: 'Lookup Phone', id: 'lookup_phone' }, + { label: 'Purge Email Address', id: 'purge_email_address' }, + { label: 'Purge Phone Number', id: 'purge_phone_number' }, ], value: () => 'list_calls', }, @@ -239,6 +243,12 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes type: 'short-input', placeholder: 'Comma-separated call IDs (optional)', condition: { field: 'operation', value: ['get_call_transcript', 'get_extensive_calls'] }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong call IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the call IDs (e.g., "calls 123456 and 789012")...', + }, }, { id: 'transcriptFromDateTime', @@ -289,6 +299,12 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes placeholder: 'Comma-separated user IDs (optional)', condition: { field: 'operation', value: 'get_extensive_calls' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong user IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the user IDs...', + }, }, // List Users inputs @@ -405,6 +421,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n ], }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong user IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the user IDs...', + }, }, // Aggregate by Period inputs @@ -499,6 +521,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'Comma-separated scorecard IDs (optional)', condition: { field: 'operation', value: 'answered_scorecards' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong scorecard IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the scorecard IDs...', + }, }, { id: 'reviewedUserIds', @@ -507,6 +535,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'Comma-separated user IDs (optional)', condition: { field: 'operation', value: 'answered_scorecards' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong user IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the reviewed user IDs...', + }, }, // Get Folder Content inputs @@ -550,6 +584,38 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n required: { field: 'operation', value: 'list_flows' }, }, + // Assign Flow Prospects / Get Prospect Flows inputs + { + id: 'flowId', + title: 'Flow ID', + type: 'short-input', + placeholder: 'Enter the Gong Engage flow ID', + condition: { field: 'operation', value: 'assign_flow_prospects' }, + required: { field: 'operation', value: 'assign_flow_prospects' }, + }, + { + id: 'crmProspectsIds', + title: 'CRM Prospect IDs', + type: 'short-input', + placeholder: 'Comma-separated CRM contact or lead IDs', + condition: { field: 'operation', value: ['assign_flow_prospects', 'get_prospect_flows'] }, + required: { field: 'operation', value: ['assign_flow_prospects', 'get_prospect_flows'] }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of CRM prospect IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the CRM prospect IDs...', + }, + }, + { + id: 'flowInstanceOwnerEmail', + title: 'Flow Instance Owner Email', + type: 'short-input', + placeholder: 'user@example.com', + condition: { field: 'operation', value: 'assign_flow_prospects' }, + required: { field: 'operation', value: 'assign_flow_prospects' }, + }, + // Get Coaching inputs { id: 'managerId', @@ -610,24 +676,24 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes }, }, - // Lookup Email inputs + // Lookup Email / Purge Email Address inputs { id: 'emailAddress', title: 'Email Address', type: 'short-input', placeholder: 'user@example.com', - condition: { field: 'operation', value: 'lookup_email' }, - required: { field: 'operation', value: 'lookup_email' }, + condition: { field: 'operation', value: ['lookup_email', 'purge_email_address'] }, + required: { field: 'operation', value: ['lookup_email', 'purge_email_address'] }, }, - // Lookup Phone inputs + // Lookup Phone / Purge Phone Number inputs { id: 'phoneNumber', title: 'Phone Number', type: 'short-input', placeholder: '+1234567890', - condition: { field: 'operation', value: 'lookup_phone' }, - required: { field: 'operation', value: 'lookup_phone' }, + condition: { field: 'operation', value: ['lookup_phone', 'purge_phone_number'] }, + required: { field: 'operation', value: ['lookup_phone', 'purge_phone_number'] }, }, // Pagination cursor (shared) @@ -692,9 +758,13 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes 'gong_list_trackers', 'gong_list_workspaces', 'gong_list_flows', + 'gong_assign_flow_prospects', + 'gong_get_prospect_flows', 'gong_get_coaching', 'gong_lookup_email', 'gong_lookup_phone', + 'gong_purge_email_address', + 'gong_purge_phone_number', ], config: { tool: (params) => `gong_${params.operation}`, @@ -763,8 +833,20 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes type: 'string', description: 'Email of a Gong user to retrieve personal and company flows', }, - emailAddress: { type: 'string', description: 'Email address to look up' }, - phoneNumber: { type: 'string', description: 'Phone number to look up' }, + flowId: { type: 'string', description: 'Gong Engage flow ID' }, + crmProspectsIds: { type: 'string', description: 'Comma-separated CRM prospect IDs' }, + flowInstanceOwnerEmail: { + type: 'string', + description: 'Email of the Gong user who owns the flow instance and its to-dos', + }, + emailAddress: { + type: 'string', + description: 'Email address to look up or purge', + }, + phoneNumber: { + type: 'string', + description: 'Phone number to look up or purge', + }, cursor: { type: 'string', description: 'Pagination cursor' }, }, outputs: { @@ -883,6 +965,18 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes 'Gong Engage flows: [{id, name, folderId, folderName, visibility, creationDate, exclusive}]', }, + // assign_flow_prospects / get_prospect_flows + prospectsAssigned: { + type: 'json', + description: + 'Prospects assigned to (or enrolled in) flows: [{flowId, flowName, crmProspectId, flowInstanceId, flowInstanceOwnerEmail, flowInstanceOwnerFullName, flowInstanceCreateDate, flowInstanceStatus, workspaceId, exclusive}]', + }, + prospectsNotAssigned: { + type: 'json', + description: + 'Prospects that failed to be assigned to a flow: [{flowId, crmProspectId, errorCode, errorMessage}]', + }, + // get_coaching coachingData: { type: 'json', diff --git a/apps/sim/blocks/blocks/google_appsheet.ts b/apps/sim/blocks/blocks/google_appsheet.ts new file mode 100644 index 00000000000..85a11e99236 --- /dev/null +++ b/apps/sim/blocks/blocks/google_appsheet.ts @@ -0,0 +1,277 @@ +import { GoogleAppsheetIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { GoogleAppsheetResponse } from '@/tools/google_appsheet/types' + +export const GoogleAppsheetBlock: BlockConfig = { + type: 'google_appsheet', + name: 'Google AppSheet', + description: 'Read, add, edit, and delete rows in a Google AppSheet table', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key.', + docsLink: 'https://docs.sim.ai/integrations/google_appsheet', + category: 'tools', + integrationType: IntegrationType.Databases, + bgColor: '#FFFFFF', + icon: GoogleAppsheetIcon, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Rows', id: 'google_appsheet_find_rows' }, + { label: 'Add Rows', id: 'google_appsheet_add_rows' }, + { label: 'Edit Rows', id: 'google_appsheet_edit_rows' }, + { label: 'Delete Rows', id: 'google_appsheet_delete_rows' }, + ], + value: () => 'google_appsheet_find_rows', + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'App > Settings > Integrations > IN', + required: true, + }, + { + id: 'tableName', + title: 'Table Name', + type: 'short-input', + placeholder: 'e.g. Orders', + required: true, + }, + // Find Rows operation inputs + { + id: 'selector', + title: 'Selector', + type: 'long-input', + placeholder: + 'Optional expression, e.g. Filter(Orders, [Status] = "Open") or Top(OrderBy(Filter(Orders, true), [Date], true), 10)', + condition: { field: 'operation', value: 'google_appsheet_find_rows' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an AppSheet Selector expression based on the user's description. The table name in the expression is a placeholder - use the literal word matching the table being queried. + +Format examples: +- Filter(TableName, [Status] = "Open") +- Filter(TableName, AND([Age] >= 21, [State] = "CA")) +- OrderBy(Filter(TableName, true), [LastName], true) +- Top(OrderBy(Filter(TableName, true), [Date], true), 10) + +Return ONLY the Selector expression - no explanations, no quotes around the entire expression.`, + placeholder: 'Describe the filter/sort criteria (e.g., "open orders sorted by date")...', + }, + }, + // Add/Edit/Delete Rows operation inputs (shared JSON array field) + { + id: 'rows', + title: 'Rows (JSON Array)', + type: 'code', + language: 'json', + placeholder: 'For Add: `[{ "FirstName": "Jan", "LastName": "Jones" }]`', + condition: { + field: 'operation', + value: [ + 'google_appsheet_add_rows', + 'google_appsheet_edit_rows', + 'google_appsheet_delete_rows', + ], + }, + required: { + field: 'operation', + value: [ + 'google_appsheet_add_rows', + 'google_appsheet_edit_rows', + 'google_appsheet_delete_rows', + ], + }, + wandConfig: { + enabled: true, + prompt: `Generate an AppSheet rows JSON array based on the user's description. +Each element is an object mapping column names to values. + +Current rows: {context} + +For Add, provide the columns for each new row: +[{ "FirstName": "Jan", "LastName": "Jones" }] + +For Edit, include the key column plus the columns to change: +[{ "RowID": "123", "Status": "Done" }] + +For Delete, include only the key column: +[{ "RowID": "123" }] + +Return ONLY the valid JSON array - no explanations, no markdown.`, + placeholder: 'Describe the rows to add, edit, or delete...', + }, + }, + { + id: 'region', + title: 'Region', + type: 'dropdown', + options: [ + { label: 'Global (www)', id: 'www' }, + { label: 'Europe (eu)', id: 'eu' }, + { label: 'Asia Pacific (asia-southeast)', id: 'asia-southeast' }, + ], + value: () => 'www', + mode: 'advanced', + }, + // API Key (common to all operations) + { + id: 'apiKey', + title: 'Application Access Key', + type: 'short-input', + placeholder: 'Enter your AppSheet Application Access Key', + password: true, + required: true, + }, + ], + + tools: { + access: [ + 'google_appsheet_find_rows', + 'google_appsheet_add_rows', + 'google_appsheet_edit_rows', + 'google_appsheet_delete_rows', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const { rows, ...rest } = params + const result: Record = { ...rest } + if (params.operation !== 'google_appsheet_find_rows' && rows) { + let parsedRows: unknown + try { + parsedRows = typeof rows === 'string' ? JSON.parse(rows) : rows + } catch (error: any) { + throw new Error(`Invalid JSON in Rows field: ${error.message}`) + } + if (!Array.isArray(parsedRows)) { + throw new Error('Rows must be a JSON array of row objects, e.g. [{ "RowID": "123" }]') + } + result.rows = parsedRows + } + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + appId: { type: 'string', description: 'AppSheet app ID' }, + tableName: { type: 'string', description: 'Name of the table to operate on' }, + region: { type: 'string', description: 'AppSheet region subdomain' }, + apiKey: { type: 'string', description: 'AppSheet Application Access Key' }, + selector: { type: 'string', description: 'Optional AppSheet Selector expression' }, + rows: { type: 'json', description: 'Array of row objects for the operation' }, + }, + + outputs: { + rows: { + type: 'json', + description: 'Rows returned by the AppSheet operation: [{ columnName: value, ... }]', + }, + metadata: { type: 'json', description: 'Operation metadata: { rowCount: number }' }, + }, +} + +export const GoogleAppsheetBlockMeta = { + tags: ['spreadsheet', 'automation', 'google-workspace'], + url: 'https://about.appsheet.com', + templates: [ + { + icon: GoogleAppsheetIcon, + title: 'AppSheet order intake', + prompt: + 'Build a workflow triggered by a form submission that adds a new row to an AppSheet Orders table, then posts a confirmation message to Slack with the order details.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['slack'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet daily status digest', + prompt: + 'Create a scheduled workflow that runs daily, finds all AppSheet rows where Status is "Open", summarizes them with an agent, and emails the summary to the operations team.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['automation', 'monitoring'], + alsoIntegrations: ['gmail'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet inventory sync', + prompt: + 'Build a workflow that reads updated rows from an AppSheet Inventory table, transforms the quantities with an agent, and writes the reconciled totals into a Google Sheet.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['automation', 'analysis'], + alsoIntegrations: ['google_sheets'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet ticket escalation', + prompt: + 'Build a workflow that finds AppSheet rows where Priority is "High" and Status is not "Resolved", and edits each row to add an Escalated flag, then creates a Linear issue for each one.', + modules: ['agent', 'workflows'], + category: 'support', + tags: ['automation', 'ticketing'], + alsoIntegrations: ['linear'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet cleanup job', + prompt: + 'Create a scheduled workflow that finds AppSheet rows older than 90 days with Status "Archived" and deletes them, then logs a summary of how many rows were removed to a table.', + modules: ['scheduled', 'tables', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet lead router', + prompt: + 'Build a workflow that finds new AppSheet rows in a Leads table, uses an agent to classify each lead by region, and edits the row to assign the correct sales rep.', + modules: ['agent', 'workflows'], + category: 'sales', + tags: ['automation', 'crm'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet field service report', + prompt: + 'Build a workflow that finds all AppSheet rows completed today in a Work Orders table, generates a summary report with an agent, and saves it as a file for the team.', + modules: ['agent', 'files', 'workflows'], + category: 'operations', + tags: ['automation', 'analysis'], + }, + ], + skills: [ + { + name: 'add-appsheet-row', + description: 'Add a new row to an AppSheet table in response to an external event.', + content: + '# Add AppSheet Row\n\nCreate a new row in an AppSheet table, e.g. when a form is submitted or an external event fires.\n\n## Steps\n1. Set App ID and Table Name for the target table.\n2. Provide the Rows JSON array with one object per new row, e.g. `[{ "FirstName": "Jan", "LastName": "Jones" }]`.\n3. Give the key column an explicit value, or omit it if its Initial value expression (e.g. UNIQUEID()) generates it automatically.\n4. Run the Add Rows operation and confirm the returned row includes the generated key.\n\n## Output\nThe newly created row(s), including any generated key values, plus a row count.', + }, + { + name: 'find-appsheet-rows', + description: + 'Query an AppSheet table with a Selector expression to filter, sort, or limit rows.', + content: + '# Find AppSheet Rows\n\nRead rows from an AppSheet table, optionally filtered and sorted with a Selector expression.\n\n## Steps\n1. Set App ID and Table Name for the target table.\n2. Leave Selector blank to return every row, or provide an expression such as `Filter(TableName, [Status] = "Open")`.\n3. Combine `OrderBy()` and `Top()` to sort and limit results, e.g. `Top(OrderBy(Filter(TableName, true), [Date], true), 10)`.\n4. Run the Find Rows operation.\n\n## Output\nThe matching rows and a row count, ready to feed into an agent or a downstream integration.', + }, + { + name: 'sync-appsheet-updates-to-sheet', + description: + 'Mirror updated AppSheet rows into a Google Sheet to maintain a real-time audit trail.', + content: + '# Sync AppSheet Updates to a Sheet\n\nKeep a Google Sheet in sync with changes to an AppSheet table, mirroring the pattern used in AppSheet-Zapier integrations for audit trails.\n\n## Steps\n1. Use Find Rows with a Selector expression that isolates recently changed rows (e.g. filtered by a LastModified column).\n2. For each row, append or update the corresponding row in a Google Sheet.\n3. Schedule the workflow to run on an interval so the sheet stays current.\n\n## Output\nA Google Sheet that reflects the latest AppSheet row data, useful as a shareable audit trail or reporting source.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/grafana.ts b/apps/sim/blocks/blocks/grafana.ts index 9cb12c810ce..d1c70517d66 100644 --- a/apps/sim/blocks/blocks/grafana.ts +++ b/apps/sim/blocks/blocks/grafana.ts @@ -13,7 +13,7 @@ export const GrafanaBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/integrations/grafana', category: 'tools', integrationType: IntegrationType.Observability, - bgColor: '#F46800', + bgColor: '#FFFFFF', icon: GrafanaIcon, subBlocks: [ { diff --git a/apps/sim/blocks/blocks/hex.ts b/apps/sim/blocks/blocks/hex.ts index 2bb36f75ef6..76cf338782c 100644 --- a/apps/sim/blocks/blocks/hex.ts +++ b/apps/sim/blocks/blocks/hex.ts @@ -8,7 +8,7 @@ export const HexBlock: BlockConfig = { name: 'Hex', description: 'Run and manage Hex projects', longDescription: - 'Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.', + 'Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token.', docsLink: 'https://docs.sim.ai/integrations/hex', category: 'tools', integrationType: IntegrationType.Analytics, @@ -36,8 +36,13 @@ export const HexBlock: BlockConfig = { { label: 'List Collections', id: 'list_collections' }, { label: 'Get Collection', id: 'get_collection' }, { label: 'Create Collection', id: 'create_collection' }, + { label: 'Update Collection', id: 'update_collection' }, { label: 'List Data Connections', id: 'list_data_connections' }, { label: 'Get Data Connection', id: 'get_data_connection' }, + { label: 'Create Group', id: 'create_group' }, + { label: 'Update Group', id: 'update_group' }, + { label: 'Delete Group', id: 'delete_group' }, + { label: 'Deactivate User', id: 'deactivate_user' }, ], value: () => 'run_project', }, @@ -107,6 +112,39 @@ Example: generationType: 'json-object', }, }, + { + id: 'viewId', + title: 'Saved View ID', + type: 'short-input', + placeholder: 'Enter a SavedView UUID (optional)', + condition: { field: 'operation', value: 'run_project' }, + mode: 'advanced', + }, + { + id: 'notifications', + title: 'Notifications', + type: 'code', + placeholder: '[{"type": "FAILURE", "slackChannelIds": ["C0123456789"]}]', + condition: { field: 'operation', value: 'run_project' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating Hex run notification configs. +Generate ONLY the raw JSON array based on the user's request. +The output MUST be a single, valid JSON array, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array. +Each item's "type" must be one of ALL, SUCCESS, or FAILURE. Optional fields: includeSuccessScreenshot (boolean), slackChannelIds, userIds, groupIds (arrays of strings). + +Example: +[{"type": "FAILURE", "slackChannelIds": ["C0123456789"], "includeSuccessScreenshot": false}]`, + placeholder: 'Describe who should be notified and when...', + generationType: 'json-object', + }, + }, { id: 'projectStatus', title: 'Status', @@ -130,28 +168,119 @@ Example: value: () => '', condition: { field: 'operation', value: 'get_project_runs' }, }, + { + id: 'runTriggerFilter', + title: 'Trigger Filter', + type: 'dropdown', + options: [ + { label: 'All', id: 'ALL' }, + { label: 'API', id: 'API' }, + { label: 'Scheduled', id: 'SCHEDULED' }, + { label: 'App Refresh', id: 'APP_REFRESH' }, + ], + value: () => 'ALL', + condition: { field: 'operation', value: 'get_project_runs' }, + mode: 'advanced', + }, { id: 'groupIdInput', title: 'Group ID', type: 'short-input', placeholder: 'Enter group UUID', - condition: { field: 'operation', value: 'get_group' }, - required: { field: 'operation', value: 'get_group' }, + condition: { field: 'operation', value: ['get_group', 'update_group', 'delete_group'] }, + required: { field: 'operation', value: ['get_group', 'update_group', 'delete_group'] }, + }, + { + id: 'groupName', + title: 'Group Name', + type: 'short-input', + placeholder: 'Enter group name', + condition: { field: 'operation', value: ['create_group', 'update_group'] }, + required: { field: 'operation', value: 'create_group' }, + }, + { + id: 'groupMemberUserIds', + title: 'Initial Member User IDs', + type: 'code', + placeholder: '["uuid1", "uuid2"]', + condition: { field: 'operation', value: 'create_group' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating JSON arrays of user UUIDs. +Generate ONLY the raw JSON array of user ID strings based on the user's request. +The output MUST be a single, valid JSON array of strings, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array. + +Example: +["a1b2c3d4-0000-0000-0000-000000000000", "e5f6a7b8-0000-0000-0000-000000000000"]`, + placeholder: 'Describe which users to add...', + generationType: 'json-object', + }, + }, + { + id: 'groupAddUserIds', + title: 'Add Member User IDs', + type: 'code', + placeholder: '["uuid1", "uuid2"]', + condition: { field: 'operation', value: 'update_group' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating JSON arrays of user UUIDs. +Generate ONLY the raw JSON array of user ID strings to add based on the user's request. +The output MUST be a single, valid JSON array of strings, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array.`, + placeholder: 'Describe which users to add...', + generationType: 'json-object', + }, + }, + { + id: 'groupRemoveUserIds', + title: 'Remove Member User IDs', + type: 'code', + placeholder: '["uuid1", "uuid2"]', + condition: { field: 'operation', value: 'update_group' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating JSON arrays of user UUIDs. +Generate ONLY the raw JSON array of user ID strings to remove based on the user's request. +The output MUST be a single, valid JSON array of strings, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array.`, + placeholder: 'Describe which users to remove...', + generationType: 'json-object', + }, }, { id: 'collectionId', title: 'Collection ID', type: 'short-input', placeholder: 'Enter collection UUID', - condition: { field: 'operation', value: 'get_collection' }, - required: { field: 'operation', value: 'get_collection' }, + condition: { + field: 'operation', + value: ['get_collection', 'update_collection', 'list_projects'], + }, + required: { field: 'operation', value: ['get_collection', 'update_collection'] }, }, { id: 'collectionName', title: 'Collection Name', type: 'short-input', placeholder: 'Enter collection name', - condition: { field: 'operation', value: 'create_collection' }, + condition: { field: 'operation', value: ['create_collection', 'update_collection'] }, required: { field: 'operation', value: 'create_collection' }, }, { @@ -159,7 +288,7 @@ Example: title: 'Description', type: 'long-input', placeholder: 'Optional description for the collection', - condition: { field: 'operation', value: 'create_collection' }, + condition: { field: 'operation', value: ['create_collection', 'update_collection'] }, }, { id: 'dataConnectionId', @@ -169,6 +298,14 @@ Example: condition: { field: 'operation', value: 'get_data_connection' }, required: { field: 'operation', value: 'get_data_connection' }, }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter user UUID', + condition: { field: 'operation', value: 'deactivate_user' }, + required: { field: 'operation', value: 'deactivate_user' }, + }, { id: 'apiKey', title: 'API Key', @@ -240,6 +377,20 @@ Example: condition: { field: 'operation', value: 'list_projects' }, mode: 'advanced', }, + { + id: 'includeComponents', + title: 'Include Components', + type: 'switch', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'includeTrashed', + title: 'Include Trashed', + type: 'switch', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, { id: 'statusFilter', title: 'Status Filter', @@ -253,6 +404,57 @@ Example: condition: { field: 'operation', value: 'list_projects' }, mode: 'advanced', }, + { + id: 'creatorEmail', + title: 'Creator Email', + type: 'short-input', + placeholder: 'Filter by creator email (optional)', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'ownerEmail', + title: 'Owner Email', + type: 'short-input', + placeholder: 'Filter by owner email (optional)', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'categories', + title: 'Categories', + type: 'code', + placeholder: '["Marketing", "Finance"]', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Created At', id: 'CREATED_AT' }, + { label: 'Last Edited At', id: 'LAST_EDITED_AT' }, + { label: 'Last Published At', id: 'LAST_PUBLISHED_AT' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'sortDirection', + title: 'Sort Direction', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Ascending', id: 'ASC' }, + { label: 'Descending', id: 'DESC' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, { id: 'groupId', title: 'Filter by Group', @@ -261,12 +463,57 @@ Example: condition: { field: 'operation', value: 'list_users' }, mode: 'advanced', }, + { + id: 'userIds', + title: 'Filter by User IDs', + type: 'short-input', + placeholder: 'Comma-separated user UUIDs (optional)', + condition: { field: 'operation', value: 'list_users' }, + mode: 'advanced', + }, + { + id: 'after', + title: 'After Cursor', + type: 'short-input', + placeholder: 'Cursor for the next page', + condition: { + field: 'operation', + value: [ + 'list_projects', + 'list_groups', + 'list_collections', + 'list_data_connections', + 'list_users', + ], + }, + mode: 'advanced', + }, + { + id: 'before', + title: 'Before Cursor', + type: 'short-input', + placeholder: 'Cursor for the previous page', + condition: { + field: 'operation', + value: [ + 'list_projects', + 'list_groups', + 'list_collections', + 'list_data_connections', + 'list_users', + ], + }, + mode: 'advanced', + }, ], tools: { access: [ 'hex_cancel_run', 'hex_create_collection', + 'hex_create_group', + 'hex_deactivate_user', + 'hex_delete_group', 'hex_get_collection', 'hex_get_data_connection', 'hex_get_group', @@ -280,6 +527,8 @@ Example: 'hex_list_projects', 'hex_list_users', 'hex_run_project', + 'hex_update_collection', + 'hex_update_group', 'hex_update_project', ], config: { @@ -313,10 +562,20 @@ Example: return 'hex_get_collection' case 'create_collection': return 'hex_create_collection' + case 'update_collection': + return 'hex_update_collection' case 'list_data_connections': return 'hex_list_data_connections' case 'get_data_connection': return 'hex_get_data_connection' + case 'create_group': + return 'hex_create_group' + case 'update_group': + return 'hex_update_group' + case 'delete_group': + return 'hex_delete_group' + case 'deactivate_user': + return 'hex_deactivate_user' default: return 'hex_run_project' } @@ -330,11 +589,26 @@ Example: if (op === 'update_project' && params.projectStatus) result.status = params.projectStatus if (op === 'get_project_runs' && params.runStatusFilter) result.statusFilter = params.runStatusFilter - if (op === 'get_group' && params.groupIdInput) result.groupId = params.groupIdInput + if ( + (op === 'get_group' || op === 'update_group' || op === 'delete_group') && + params.groupIdInput + ) + result.groupId = params.groupIdInput if (op === 'list_users' && params.groupId) result.groupId = params.groupId - if (op === 'create_collection' && params.collectionName) result.name = params.collectionName + if ((op === 'create_collection' || op === 'update_collection') && params.collectionName) + result.name = params.collectionName if (op === 'create_collection' && params.collectionDescription) result.description = params.collectionDescription + if (op === 'update_collection' && params.collectionDescription != null) + result.description = params.collectionDescription + if ((op === 'create_group' || op === 'update_group') && params.groupName) + result.name = params.groupName + if (op === 'create_group' && params.groupMemberUserIds) + result.memberUserIds = params.groupMemberUserIds + if (op === 'update_group' && params.groupAddUserIds) + result.addUserIds = params.groupAddUserIds + if (op === 'update_group' && params.groupRemoveUserIds) + result.removeUserIds = params.groupRemoveUserIds return result }, @@ -360,21 +634,42 @@ Example: type: 'boolean', description: 'Use cached SQL results instead of re-running queries', }, + viewId: { type: 'string', description: 'SavedView UUID to use for the project run' }, + notifications: { + type: 'json', + description: 'Notification details to deliver once the run completes', + }, projectStatus: { type: 'string', description: 'New project status name (custom workspace status label)', }, limit: { type: 'number', description: 'Max number of results to return' }, offset: { type: 'number', description: 'Offset for paginated results' }, + after: { type: 'string', description: 'Cursor to fetch results after' }, + before: { type: 'string', description: 'Cursor to fetch results before' }, includeArchived: { type: 'boolean', description: 'Include archived projects' }, + includeComponents: { type: 'boolean', description: 'Include components in results' }, + includeTrashed: { type: 'boolean', description: 'Include trashed projects in results' }, statusFilter: { type: 'string', description: 'Filter projects by status' }, + creatorEmail: { type: 'string', description: 'Filter projects by creator email' }, + ownerEmail: { type: 'string', description: 'Filter projects by owner email' }, + categories: { type: 'json', description: 'Filter projects by category names' }, + sortBy: { type: 'string', description: 'Sort field for list results' }, + sortDirection: { type: 'string', description: 'Sort direction for list results' }, runStatusFilter: { type: 'string', description: 'Filter runs by status' }, + runTriggerFilter: { type: 'string', description: 'Filter runs by trigger source' }, groupId: { type: 'string', description: 'Filter users by group UUID' }, - groupIdInput: { type: 'string', description: 'Group UUID for get group' }, + userIds: { type: 'string', description: 'Comma-separated user UUIDs to filter by' }, + groupIdInput: { type: 'string', description: 'Group UUID for get/update/delete group' }, + groupName: { type: 'string', description: 'Group name' }, + groupMemberUserIds: { type: 'json', description: 'Initial member user UUIDs for new group' }, + groupAddUserIds: { type: 'json', description: 'User UUIDs to add to the group' }, + groupRemoveUserIds: { type: 'json', description: 'User UUIDs to remove from the group' }, collectionId: { type: 'string', description: 'Collection UUID' }, collectionName: { type: 'string', description: 'Collection name' }, collectionDescription: { type: 'string', description: 'Collection description' }, dataConnectionId: { type: 'string', description: 'Data connection UUID' }, + userId: { type: 'string', description: 'User UUID' }, }, outputs: { @@ -415,7 +710,10 @@ Example: description: 'List of runs with runId, status, runUrl, startTime, endTime, elapsedTime, projectVersion', }, - users: { type: 'json', description: 'List of users with id, name, email, role' }, + users: { + type: 'json', + description: 'List of users with id, name, email, role, lastLoginDate', + }, groups: { type: 'json', description: 'List of groups with id, name, createdAt' }, collections: { type: 'json', @@ -437,8 +735,15 @@ Example: creator: { type: 'json', description: 'Creator details ({ email, id })' }, owner: { type: 'json', description: 'Owner details ({ email })' }, total: { type: 'number', description: 'Total results returned' }, - // Cancel output + // Cancel / delete / deactivate output success: { type: 'boolean', description: 'Whether the operation succeeded' }, + groupId: { type: 'string', description: 'Group UUID' }, + userId: { type: 'string', description: 'User UUID' }, + // Pagination + nextPage: { type: 'string', description: 'Cursor for the next page of runs' }, + previousPage: { type: 'string', description: 'Cursor for the previous page of runs' }, + after: { type: 'string', description: 'Cursor for the next page of results' }, + before: { type: 'string', description: 'Cursor for the previous page of results' }, // Data connection flags connectViaSsh: { type: 'boolean', description: 'SSH tunneling enabled' }, includeMagic: { type: 'boolean', description: 'Magic AI features enabled' }, @@ -546,5 +851,12 @@ export const HexBlockMeta = { content: '# Inventory Projects\n\nMap what projects and data sources exist in the workspace.\n\n## Steps\n1. List projects and capture IDs, names, and owners.\n2. List collections and get details to see how projects are grouped.\n3. List data connections to map which sources power the projects.\n4. Cross-reference projects to their collections and data connections.\n\n## Output\nReturn an inventory of projects grouped by collection, each annotated with its data connections. Useful for governance and cleanup.', }, + { + name: 'onboard-offboard-teammate', + description: + 'Add a new teammate to the right Hex groups on hire, or deactivate and remove them on departure.', + content: + '# Onboard/Offboard Teammate\n\nManage workspace access as people join or leave the team.\n\n## Steps\n1. List users to resolve the target user by name or email, and list groups to resolve the relevant group by name.\n2. For onboarding: add the user to the appropriate group(s) via group update.\n3. For offboarding: remove the user from their groups via group update, then deactivate the user account.\n4. Confirm the change by getting the group or listing users filtered by group.\n\n## Output\nReturn the user and group IDs affected and the action taken (added, removed, deactivated). Flag if the user or group could not be resolved.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/langsmith.ts b/apps/sim/blocks/blocks/langsmith.ts index cb8d20dff6b..028fe76c32c 100644 --- a/apps/sim/blocks/blocks/langsmith.ts +++ b/apps/sim/blocks/blocks/langsmith.ts @@ -23,6 +23,9 @@ export const LangsmithBlock: BlockConfig = { options: [ { label: 'Create Run', id: 'langsmith_create_run' }, { label: 'Create Runs Batch', id: 'langsmith_create_runs_batch' }, + { label: 'Update Run', id: 'langsmith_update_run' }, + { label: 'Get Run', id: 'langsmith_get_run' }, + { label: 'Create Feedback', id: 'langsmith_create_feedback' }, ], value: () => 'langsmith_create_run', }, @@ -41,13 +44,27 @@ export const LangsmithBlock: BlockConfig = { placeholder: 'Auto-generated if blank', condition: { field: 'operation', value: 'langsmith_create_run' }, }, + { + id: 'runId', + title: 'Run ID', + type: 'short-input', + placeholder: 'ID of the run to update, retrieve, or attach feedback to', + required: { + field: 'operation', + value: ['langsmith_update_run', 'langsmith_get_run', 'langsmith_create_feedback'], + }, + condition: { + field: 'operation', + value: ['langsmith_update_run', 'langsmith_get_run', 'langsmith_create_feedback'], + }, + }, { id: 'name', title: 'Name', type: 'short-input', placeholder: 'Run name', required: { field: 'operation', value: 'langsmith_create_run' }, - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, }, { id: 'run_type', @@ -78,7 +95,7 @@ export const LangsmithBlock: BlockConfig = { title: 'End Time', type: 'short-input', placeholder: '2025-01-01T12:00:30Z', - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, mode: 'advanced', }, { @@ -94,7 +111,7 @@ export const LangsmithBlock: BlockConfig = { title: 'Outputs', type: 'code', placeholder: '{"output":"value"}', - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, mode: 'advanced', }, { @@ -102,7 +119,7 @@ export const LangsmithBlock: BlockConfig = { title: 'Metadata', type: 'code', placeholder: '{"ls_model":"gpt-4"}', - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, mode: 'advanced', }, { @@ -110,7 +127,7 @@ export const LangsmithBlock: BlockConfig = { title: 'Tags', type: 'code', placeholder: '["production","workflow"]', - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, mode: 'advanced', }, { @@ -150,7 +167,7 @@ export const LangsmithBlock: BlockConfig = { title: 'Status', type: 'short-input', placeholder: 'success', - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, mode: 'advanced', }, { @@ -158,7 +175,7 @@ export const LangsmithBlock: BlockConfig = { title: 'Error', type: 'long-input', placeholder: 'Error message', - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, mode: 'advanced', }, { @@ -174,7 +191,7 @@ export const LangsmithBlock: BlockConfig = { title: 'Events', type: 'code', placeholder: '[{"event":"token","value":1}]', - condition: { field: 'operation', value: 'langsmith_create_run' }, + condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] }, mode: 'advanced', }, { @@ -207,9 +224,66 @@ Required: id (existing run UUID), name, run_type ("tool"|"chain"|"llm"|"retrieve Common patch fields: outputs, end_time, status, error`, }, }, + { + id: 'key', + title: 'Feedback Key', + type: 'short-input', + placeholder: 'e.g. correctness, user_score', + required: { field: 'operation', value: 'langsmith_create_feedback' }, + condition: { field: 'operation', value: 'langsmith_create_feedback' }, + }, + { + id: 'score', + title: 'Score', + type: 'short-input', + placeholder: 'e.g. 1, 0.5, 0', + condition: { field: 'operation', value: 'langsmith_create_feedback' }, + }, + { + id: 'value', + title: 'Value', + type: 'short-input', + placeholder: 'e.g. good, bad', + condition: { field: 'operation', value: 'langsmith_create_feedback' }, + mode: 'advanced', + }, + { + id: 'comment', + title: 'Comment', + type: 'long-input', + placeholder: 'Explanation for the feedback', + condition: { field: 'operation', value: 'langsmith_create_feedback' }, + mode: 'advanced', + }, + { + id: 'correction', + title: 'Correction', + type: 'code', + placeholder: '{"output":"the corrected value"}', + condition: { field: 'operation', value: 'langsmith_create_feedback' }, + mode: 'advanced', + }, + { + id: 'feedbackSourceType', + title: 'Feedback Source', + type: 'dropdown', + options: [ + { label: 'API', id: 'api' }, + { label: 'App', id: 'app' }, + { label: 'Model', id: 'model' }, + ], + condition: { field: 'operation', value: 'langsmith_create_feedback' }, + mode: 'advanced', + }, ], tools: { - access: ['langsmith_create_run', 'langsmith_create_runs_batch'], + access: [ + 'langsmith_create_run', + 'langsmith_create_runs_batch', + 'langsmith_update_run', + 'langsmith_get_run', + 'langsmith_create_feedback', + ], config: { tool: (params) => params.operation, params: (params) => { @@ -227,6 +301,8 @@ Common patch fields: outputs, end_time, status, error`, return value } + const emptyToUndefined = (value: unknown) => (value === '' ? undefined : value) + if (params.operation === 'langsmith_create_runs_batch') { const post = parseJsonValue(params.post, 'post runs') const patch = parseJsonValue(params.patch, 'patch runs') @@ -242,6 +318,69 @@ Common patch fields: outputs, end_time, status, error`, } } + if (params.operation === 'langsmith_update_run') { + const name = emptyToUndefined(params.name) + const end_time = emptyToUndefined(params.end_time) + const outputs = parseJsonValue(params.outputs, 'outputs') + const extra = parseJsonValue(params.extra, 'metadata') + const tags = parseJsonValue(params.tags, 'tags') + const status = emptyToUndefined(params.status) + const error = emptyToUndefined(params.error) + const events = parseJsonValue(params.events, 'events') + + if ( + [name, end_time, outputs, extra, tags, status, error, events].every( + (value) => value === undefined + ) + ) { + throw new Error('Provide at least one field to update') + } + + return { + apiKey: params.apiKey, + runId: params.runId, + name, + end_time, + outputs, + extra, + tags, + status, + error, + events, + } + } + + if (params.operation === 'langsmith_get_run') { + return { + apiKey: params.apiKey, + runId: params.runId, + } + } + + if (params.operation === 'langsmith_create_feedback') { + const parseScore = (value: unknown) => { + if (value === undefined || value === null || value === '') { + return undefined + } + const parsed = Number(value) + if (Number.isNaN(parsed)) { + throw new Error(`Invalid score: "${value}" is not a number`) + } + return parsed + } + + return { + apiKey: params.apiKey, + runId: params.runId, + key: params.key, + score: parseScore(params.score), + value: params.value, + comment: params.comment, + correction: parseJsonValue(params.correction, 'correction'), + feedbackSourceType: params.feedbackSourceType || undefined, + } + } + return { apiKey: params.apiKey, id: params.id, @@ -269,6 +408,10 @@ Common patch fields: outputs, end_time, status, error`, operation: { type: 'string', description: 'Operation to perform' }, apiKey: { type: 'string', description: 'LangSmith API key' }, id: { type: 'string', description: 'Run identifier' }, + runId: { + type: 'string', + description: 'ID of the run to update, retrieve, or attach feedback to', + }, name: { type: 'string', description: 'Run name' }, run_type: { type: 'string', description: 'Run type' }, start_time: { type: 'string', description: 'Run start time (ISO)' }, @@ -287,13 +430,45 @@ Common patch fields: outputs, end_time, status, error`, events: { type: 'json', description: 'Events array' }, post: { type: 'json', description: 'Runs to ingest in batch' }, patch: { type: 'json', description: 'Runs to update in batch' }, + key: { type: 'string', description: 'Feedback metric name' }, + score: { type: 'string', description: 'Numeric score for the feedback metric' }, + value: { type: 'string', description: 'Categorical value for the feedback metric' }, + comment: { type: 'string', description: 'Comment explaining the feedback' }, + correction: { type: 'json', description: 'Corrected output for the run' }, + feedbackSourceType: { + type: 'string', + description: 'Origin of the feedback (api, app, or model)', + }, }, outputs: { - accepted: { type: 'boolean', description: 'Whether ingestion was accepted' }, - runId: { type: 'string', description: 'Run ID for single run' }, + accepted: { type: 'boolean', description: 'Whether ingestion or the update was accepted' }, + runId: { type: 'string', description: 'Run ID for single-run operations' }, runIds: { type: 'array', description: 'Run IDs for batch ingest' }, message: { type: 'string', description: 'LangSmith response message' }, messages: { type: 'array', description: 'Per-run response messages' }, + id: { type: 'string', description: 'Run ID (get run) or feedback ID (create feedback)' }, + name: { type: 'string', description: 'Run name (get run)' }, + runType: { type: 'string', description: 'Run type (get run)' }, + status: { type: 'string', description: 'Run status (get run)' }, + startTime: { type: 'string', description: 'Run start time (get run)' }, + endTime: { type: 'string', description: 'Run end time (get run)' }, + inputs: { type: 'json', description: 'Run inputs payload (get run)' }, + outputs: { type: 'json', description: 'Run outputs payload (get run)' }, + error: { type: 'string', description: 'Error details (get run)' }, + tags: { type: 'array', description: 'Tags attached to the run (get run)' }, + sessionId: { type: 'string', description: 'Project (session) ID the run belongs to (get run)' }, + traceId: { type: 'string', description: 'Trace ID (get run)' }, + parentRunId: { type: 'string', description: 'Parent run ID (get run)' }, + totalTokens: { type: 'number', description: 'Total tokens consumed by the run (get run)' }, + totalCost: { type: 'string', description: 'Total cost of the run (get run)' }, + key: { type: 'string', description: 'Feedback metric name (create feedback)' }, + score: { type: 'number', description: 'Score recorded for the feedback (create feedback)' }, + value: { + type: 'string', + description: 'Categorical value recorded for the feedback (create feedback)', + }, + comment: { type: 'string', description: 'Comment recorded for the feedback (create feedback)' }, + createdAt: { type: 'string', description: 'When the feedback was created (create feedback)' }, }, } @@ -324,11 +499,20 @@ export const LangsmithBlockMeta = { icon: LangsmithIcon, title: 'LangSmith feedback capture', prompt: - 'Build a workflow that collects user-reported agent failures from a table and forwards each as a tagged LangSmith run with the inputs and expected output for later review.', + 'Build a workflow that collects user-reported agent failures from a table and attaches each as scored LangSmith feedback on the originating run for later review.', modules: ['tables', 'agent', 'workflows'], category: 'engineering', tags: ['engineering', 'automation'], }, + { + icon: LangsmithIcon, + title: 'LangSmith run completion', + prompt: + 'Build a workflow that creates a LangSmith run when an agent step starts, then updates it with outputs, status, and end time once the step finishes so traces always show the full lifecycle.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['engineering', 'monitoring'], + }, { icon: LangsmithIcon, title: 'LangSmith batch run shipper', @@ -381,5 +565,12 @@ export const LangsmithBlockMeta = { content: '# Batch Export Runs\n\nShip multiple completed runs to LangSmith at once instead of one by one.\n\n## Steps\n1. Collect the runs to export, each with name, type, inputs, outputs, and timing.\n2. Assign a shared project so the runs land together.\n3. Submit them as a single batch.\n\n## Output\nReturn how many runs were exported, the project they landed in, and any runs that failed validation.', }, + { + name: 'attach-feedback-to-run', + description: + 'Attach a score, categorical value, or correction to an existing LangSmith run for evaluation.', + content: + '# Attach Feedback to a Run\n\nRecord a human or automated judgment on a run that already exists in LangSmith.\n\n## Steps\n1. Identify the run ID the feedback applies to.\n2. Choose a feedback key (e.g. "correctness", "user_score") and a score, value, or comment.\n3. Include a correction if the expected output is known.\n4. Submit the feedback.\n\n## Output\nConfirm the feedback ID and the run it was attached to.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/loops.ts b/apps/sim/blocks/blocks/loops.ts index 76e27e48cb0..c71ce37e1d4 100644 --- a/apps/sim/blocks/blocks/loops.ts +++ b/apps/sim/blocks/blocks/loops.ts @@ -32,6 +32,9 @@ export const LoopsBlock: BlockConfig = { { label: 'List Transactional Emails', id: 'list_transactional_emails' }, { label: 'Create Contact Property', id: 'create_contact_property' }, { label: 'List Contact Properties', id: 'list_contact_properties' }, + { label: 'Check Contact Suppression', id: 'check_contact_suppression' }, + { label: 'Remove Contact Suppression', id: 'remove_contact_suppression' }, + { label: 'Get Transactional Email', id: 'get_transactional_email' }, ], value: () => 'create_contact', }, @@ -47,7 +50,7 @@ export const LoopsBlock: BlockConfig = { value: ['create_contact', 'send_transactional_email'], }, }, - // Optional email for update, find, delete, send event + // Optional email for update, find, delete, send event, suppression lookups { id: 'contactEmail', title: 'Email', @@ -55,7 +58,14 @@ export const LoopsBlock: BlockConfig = { placeholder: 'Enter email address', condition: { field: 'operation', - value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + value: [ + 'update_contact', + 'find_contact', + 'delete_contact', + 'send_event', + 'check_contact_suppression', + 'remove_contact_suppression', + ], }, }, // User ID for operations that support it @@ -66,7 +76,14 @@ export const LoopsBlock: BlockConfig = { placeholder: 'Enter user ID', condition: { field: 'operation', - value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + value: [ + 'update_contact', + 'find_contact', + 'delete_contact', + 'send_event', + 'check_contact_suppression', + 'remove_contact_suppression', + ], }, }, // Contact fields @@ -199,10 +216,13 @@ Return ONLY the JSON object - no explanations, no extra text.`, title: 'Transactional Email ID', type: 'short-input', placeholder: 'Enter template ID (e.g., clx...)', - required: { field: 'operation', value: 'send_transactional_email' }, + required: { + field: 'operation', + value: ['send_transactional_email', 'get_transactional_email'], + }, condition: { field: 'operation', - value: 'send_transactional_email', + value: ['send_transactional_email', 'get_transactional_email'], }, }, { @@ -426,6 +446,9 @@ Return ONLY the JSON object - no explanations, no extra text.`, 'loops_list_transactional_emails', 'loops_create_contact_property', 'loops_list_contact_properties', + 'loops_check_contact_suppression', + 'loops_remove_contact_suppression', + 'loops_get_transactional_email', ], config: { tool: (params) => `loops_${params.operation}`, @@ -497,6 +520,16 @@ Return ONLY the JSON object - no explanations, no extra text.`, case 'list_contact_properties': if (params.propertyFilter) result.list = params.propertyFilter break + + case 'check_contact_suppression': + case 'remove_contact_suppression': + if (params.contactEmail) result.email = params.contactEmail + if (params.userId) result.userId = params.userId + break + + case 'get_transactional_email': + result.transactionalId = params.transactionalId + break } return result @@ -531,7 +564,10 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, outputs: { success: { type: 'boolean', description: 'Whether the operation succeeded' }, - id: { type: 'string', description: 'Contact ID (create/update operations)' }, + id: { + type: 'string', + description: 'Contact ID (create/update operations) or template ID (get transactional email)', + }, contacts: { type: 'json', description: @@ -544,7 +580,8 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, transactionalEmails: { type: 'json', - description: 'Array of transactional email templates (id, name, lastUpdated, dataVariables)', + description: + 'Array of transactional email templates (id, name, createdAt, updatedAt, lastUpdated (deprecated alias of updatedAt), dataVariables)', }, pagination: { type: 'json', @@ -555,6 +592,50 @@ Return ONLY the JSON object - no explanations, no extra text.`, type: 'json', description: 'Array of contact properties (key, label, type)', }, + isSuppressed: { + type: 'boolean', + description: 'Whether the contact is on the suppression list (check suppression)', + }, + contactId: { + type: 'string', + description: 'The Loops-assigned contact ID (check suppression)', + }, + removalQuotaLimit: { + type: 'number', + description: 'Total suppression-removal quota for the team', + }, + removalQuotaRemaining: { + type: 'number', + description: 'Remaining suppression-removal quota for the team', + }, + name: { + type: 'string', + description: 'Transactional email template name (get transactional email)', + }, + draftEmailMessageId: { + type: 'string', + description: 'ID of the draft email message, if any (get transactional email)', + }, + publishedEmailMessageId: { + type: 'string', + description: 'ID of the published email message, if any (get transactional email)', + }, + transactionalGroupId: { + type: 'string', + description: 'ID of the transactional group, if any (get transactional email)', + }, + createdAt: { + type: 'string', + description: 'Creation timestamp (get transactional email)', + }, + updatedAt: { + type: 'string', + description: 'Last updated timestamp (get transactional email)', + }, + dataVariables: { + type: 'json', + description: 'Template data variable names (get transactional email)', + }, }, } @@ -655,5 +736,12 @@ export const LoopsBlockMeta = { content: '# Send Transactional Email\n\nDeliver a templated transactional email through Loops.\n\n## Steps\n1. Confirm the transactional email template ID to use.\n2. Build the data variables JSON to match the variable names in the template, such as name and a confirmation URL.\n3. Send Transactional Email with the recipient email, template ID, and data variables, attaching files if needed.\n\n## Output\nConfirmation of send success and the template ID and recipient used.', }, + { + name: 'manage-suppression-compliance', + description: + 'Check and clear Loops suppression status for a contact to keep deliverability and unsubscribe compliance in check.', + content: + '# Manage Suppression Compliance\n\nKeep Loops sending compliant and deliverable.\n\n## Steps\n1. Check Contact Suppression by email or user ID to see if the contact bounced, complained, or unsubscribed.\n2. If the contact should be re-enabled (e.g. a confirmed re-opt-in), Remove Contact Suppression for the same identifier, noting the remaining removal quota.\n3. Log the result so support and compliance workflows have an audit trail.\n\n## Output\nThe suppression status before and after the change, plus the remaining removal quota.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/onepassword.ts b/apps/sim/blocks/blocks/onepassword.ts index c5e9b61271d..084ef242d92 100644 --- a/apps/sim/blocks/blocks/onepassword.ts +++ b/apps/sim/blocks/blocks/onepassword.ts @@ -6,7 +6,7 @@ export const OnePasswordBlock: BlockConfig = { name: '1Password', description: 'Manage secrets and items in 1Password vaults', longDescription: - 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.', + 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.', docsLink: 'https://docs.sim.ai/integrations/onepassword', category: 'tools', integrationType: IntegrationType.Security, @@ -24,6 +24,7 @@ export const OnePasswordBlock: BlockConfig = { { label: 'Get Vault', id: 'get_vault' }, { label: 'List Items', id: 'list_items' }, { label: 'Get Item', id: 'get_item' }, + { label: 'Get Item File', id: 'get_item_file' }, { label: 'Create Item', id: 'create_item' }, { label: 'Replace Item', id: 'replace_item' }, { label: 'Update Item', id: 'update_item' }, @@ -56,8 +57,16 @@ export const OnePasswordBlock: BlockConfig = { title: 'Server URL', type: 'short-input', placeholder: 'http://localhost:8080', - required: { field: 'connectionMode', value: 'connect' }, - condition: { field: 'connectionMode', value: 'connect' }, + required: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, + condition: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, }, { id: 'apiKey', @@ -65,8 +74,16 @@ export const OnePasswordBlock: BlockConfig = { type: 'short-input', placeholder: 'Enter your 1Password Connect token', password: true, - required: { field: 'connectionMode', value: 'connect' }, - condition: { field: 'connectionMode', value: 'connect' }, + required: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, + condition: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, }, { id: 'secretReference', @@ -93,13 +110,13 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, title: 'Vault ID', type: 'short-input', placeholder: 'Enter vault UUID', - password: true, required: { field: 'operation', value: [ 'get_vault', 'list_items', 'get_item', + 'get_item_file', 'create_item', 'replace_item', 'update_item', @@ -119,19 +136,38 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, placeholder: 'Enter item UUID', required: { field: 'operation', - value: ['get_item', 'replace_item', 'update_item', 'delete_item'], + value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'], }, condition: { field: 'operation', - value: ['get_item', 'replace_item', 'update_item', 'delete_item'], + value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'], }, }, + { + id: 'fileId', + title: 'File ID', + type: 'short-input', + placeholder: 'Enter file ID (from Get Item output)', + required: { field: 'operation', value: 'get_item_file' }, + condition: { field: 'operation', value: 'get_item_file' }, + }, { id: 'filter', title: 'Filter', type: 'short-input', placeholder: 'SCIM filter (e.g., name eq "My Vault")', condition: { field: 'operation', value: ['list_vaults', 'list_items'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a SCIM filter expression for a 1Password vault or item list based on the user's description. +Examples: +- name eq "My Vault" +- title eq "API Key" +- tag eq "production" + +Return ONLY the SCIM filter expression - no explanations, no quotes, no markdown.`, + }, }, { id: 'category', @@ -147,6 +183,17 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, { label: 'Credit Card', id: 'CREDIT_CARD' }, { label: 'Identity', id: 'IDENTITY' }, { label: 'SSH Key', id: 'SSH_KEY' }, + { label: 'Software License', id: 'SOFTWARE_LICENSE' }, + { label: 'Email Account', id: 'EMAIL_ACCOUNT' }, + { label: 'Membership', id: 'MEMBERSHIP' }, + { label: 'Passport', id: 'PASSPORT' }, + { label: 'Reward Program', id: 'REWARD_PROGRAM' }, + { label: 'Driver License', id: 'DRIVER_LICENSE' }, + { label: 'Bank Account', id: 'BANK_ACCOUNT' }, + { label: 'Medical Record', id: 'MEDICAL_RECORD' }, + { label: 'Outdoor License', id: 'OUTDOOR_LICENSE' }, + { label: 'Wireless Router', id: 'WIRELESS_ROUTER' }, + { label: 'Social Security Number', id: 'SOCIAL_SECURITY_NUMBER' }, ], value: () => 'LOGIN', required: { field: 'operation', value: 'create_item' }, @@ -231,6 +278,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, 'onepassword_get_vault', 'onepassword_list_items', 'onepassword_get_item', + 'onepassword_get_item_file', 'onepassword_create_item', 'onepassword_replace_item', 'onepassword_update_item', @@ -251,6 +299,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, secretReference: { type: 'string', description: 'Secret reference URI (op://...)' }, vaultId: { type: 'string', description: 'Vault UUID' }, itemId: { type: 'string', description: 'Item UUID' }, + fileId: { type: 'string', description: 'File ID of an attachment on the item' }, filter: { type: 'string', description: 'SCIM filter expression' }, category: { type: 'string', description: 'Item category' }, title: { type: 'string', description: 'Item title' }, @@ -263,7 +312,176 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, outputs: { response: { type: 'json', - description: 'Operation response data', + description: + 'Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead.', + }, + vaults: { + type: 'json', + description: + 'List of accessible vaults [{id, name, description, items, type, createdAt, updatedAt}]', + condition: { field: 'operation', value: 'list_vaults' }, + }, + id: { + type: 'string', + description: 'Vault or item ID', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + name: { + type: 'string', + description: 'Vault name', + condition: { field: 'operation', value: 'get_vault' }, + }, + description: { + type: 'string', + description: 'Vault description', + condition: { field: 'operation', value: 'get_vault' }, + }, + items: { + type: 'json', + description: + 'Number of items in the vault (Get Vault) or item summaries [{id, title, category, tags, favorite, version, updatedAt}] (List Items)', + condition: { field: 'operation', value: ['get_vault', 'list_items'] }, + }, + type: { + type: 'string', + description: 'Vault type (USER_CREATED, PERSONAL, or EVERYONE)', + condition: { field: 'operation', value: 'get_vault' }, + }, + title: { + type: 'string', + description: 'Item title', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + category: { + type: 'string', + description: 'Item category (e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE)', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + vault: { + type: 'json', + description: 'Vault reference the item belongs to {id}', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + fields: { + type: 'json', + description: 'Item fields including secrets [{id, label, type, purpose, value}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + sections: { + type: 'json', + description: 'Item sections [{id, label}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + files: { + type: 'json', + description: + 'Files attached to the item [{id, name, size, section}] — fetch content with Get Item File', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + tags: { + type: 'json', + description: 'Item tags', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + urls: { + type: 'json', + description: 'URLs associated with the item [{href, label, primary}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + favorite: { + type: 'boolean', + description: 'Whether the item is favorited', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + version: { + type: 'number', + description: 'Item version number', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + state: { + type: 'string', + description: 'Item state (ARCHIVED, or absent/null when active)', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + lastEditedBy: { + type: 'string', + description: 'ID of the last editor', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + createdAt: { + type: 'string', + description: 'Creation timestamp', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + updatedAt: { + type: 'string', + description: 'Last update timestamp', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + success: { + type: 'boolean', + description: 'Whether the item was successfully deleted', + condition: { field: 'operation', value: 'delete_item' }, + }, + value: { + type: 'string', + description: 'The resolved secret value', + condition: { field: 'operation', value: 'resolve_secret' }, + }, + reference: { + type: 'string', + description: 'The original secret reference URI', + condition: { field: 'operation', value: 'resolve_secret' }, + }, + file: { + type: 'file', + description: 'Downloaded file attachment', + condition: { field: 'operation', value: 'get_item_file' }, }, }, } diff --git a/apps/sim/blocks/blocks/sendgrid.ts b/apps/sim/blocks/blocks/sendgrid.ts index 9378083564d..2e68b9c6f15 100644 --- a/apps/sim/blocks/blocks/sendgrid.ts +++ b/apps/sim/blocks/blocks/sendgrid.ts @@ -1,7 +1,8 @@ import { SendgridIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { IntegrationType } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' +import { toActiveFlag } from '@/tools/sendgrid/create_template_version' import type { SendMailResult } from '@/tools/sendgrid/types' export const SendGridBlock: BlockConfig = { @@ -13,6 +14,7 @@ export const SendGridBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/integrations/sendgrid', category: 'tools', integrationType: IntegrationType.Email, + authMode: AuthMode.ApiKey, bgColor: '#1A82E2', icon: SendgridIcon, @@ -387,6 +389,14 @@ Return ONLY the JSON array.`, condition: { field: 'operation', value: 'list_all_lists' }, mode: 'advanced', }, + { + id: 'listPageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'Page token from a previous response', + condition: { field: 'operation', value: 'list_all_lists' }, + mode: 'advanced', + }, // Template fields { id: 'templateName', @@ -434,6 +444,14 @@ Return ONLY the JSON array.`, condition: { field: 'operation', value: 'list_templates' }, mode: 'advanced', }, + { + id: 'templatePageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'Page token from a previous response (keep Page Size the same)', + condition: { field: 'operation', value: 'list_templates' }, + mode: 'advanced', + }, { id: 'versionName', title: 'Version Name', @@ -579,7 +597,10 @@ Return ONLY the HTML content.`, templateGenerations, listPageSize, templatePageSize, + listPageToken, + templatePageToken, attachments, + active, ...rest } = params @@ -599,7 +620,11 @@ Return ONLY the HTML content.`, ...(templateGenerations && { generations: templateGenerations }), ...(listPageSize && { pageSize: listPageSize }), ...(templatePageSize && { pageSize: templatePageSize }), + ...(operation === 'list_all_lists' && listPageToken && { pageToken: listPageToken }), + ...(operation === 'list_templates' && + templatePageToken && { pageToken: templatePageToken }), ...(normalizedAttachments && { attachments: normalizedAttachments }), + ...(active !== undefined && { active: toActiveFlag(active) }), } }, }, @@ -637,12 +662,14 @@ Return ONLY the HTML content.`, listName: { type: 'string', description: 'List name' }, listId: { type: 'string', description: 'List ID' }, listPageSize: { type: 'number', description: 'Page size for listing lists' }, + listPageToken: { type: 'string', description: 'Page token for listing lists' }, // Template inputs templateName: { type: 'string', description: 'Template name' }, templateId: { type: 'string', description: 'Template ID' }, generation: { type: 'string', description: 'Template generation' }, templateGenerations: { type: 'string', description: 'Filter templates by generation' }, templatePageSize: { type: 'number', description: 'Page size for listing templates' }, + templatePageToken: { type: 'string', description: 'Page token for listing templates' }, versionName: { type: 'string', description: 'Template version name' }, templateSubject: { type: 'string', description: 'Template subject' }, htmlContent: { type: 'string', description: 'HTML content' }, @@ -677,6 +704,10 @@ Return ONLY the HTML content.`, templates: { type: 'json', description: 'Array of templates' }, generation: { type: 'string', description: 'Template generation' }, versions: { type: 'json', description: 'Array of template versions' }, + nextPageToken: { + type: 'string', + description: 'Token for the next page of results (list_all_lists, list_templates)', + }, // Template version outputs templateId: { type: 'string', description: 'Template ID' }, active: { type: 'boolean', description: 'Whether template version is active' }, diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index 489b45ebcee..d73eb1adf09 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -30,12 +30,20 @@ export const SharepointBlock: BlockConfig = { options: [ { label: 'Create Page', id: 'create_page' }, { label: 'Read Page', id: 'read_page' }, + { label: 'Update Page', id: 'update_page' }, + { label: 'Publish Page', id: 'publish_page' }, + { label: 'Delete Page', id: 'delete_page' }, { label: 'List Sites', id: 'list_sites' }, { label: 'Create List', id: 'create_list' }, { label: 'Read List', id: 'read_list' }, { label: 'Update List', id: 'update_list' }, { label: 'Add List Items', id: 'add_list_items' }, + { label: 'Get List Item', id: 'get_list_item' }, + { label: 'Delete List Item', id: 'delete_list_item' }, { label: 'Upload File', id: 'upload_file' }, + { label: 'Download File', id: 'download_file' }, + { label: 'Get Drive Item', id: 'get_drive_item' }, + { label: 'Delete File', id: 'delete_file' }, ], }, { @@ -65,7 +73,7 @@ export const SharepointBlock: BlockConfig = { serviceId: 'sharepoint', selectorKey: 'sharepoint.sites', requiredScopes: getScopesForService('sharepoint'), - mimeType: 'application/vnd.microsoft.graph.folder', + mimeType: 'application/vnd.microsoft.graph.site', placeholder: 'Select a site', dependsOn: ['credential'], mode: 'basic', @@ -74,11 +82,16 @@ export const SharepointBlock: BlockConfig = { value: [ 'create_page', 'read_page', + 'update_page', + 'publish_page', + 'delete_page', 'list_sites', 'create_list', 'read_list', 'update_list', 'add_list_items', + 'get_list_item', + 'delete_list_item', 'upload_file', ], }, @@ -90,14 +103,37 @@ export const SharepointBlock: BlockConfig = { type: 'short-input', placeholder: 'Name of the page', condition: { field: 'operation', value: ['create_page', 'read_page'] }, + required: { field: 'operation', value: 'create_page' }, + }, + + { + id: 'pageTitle', + title: 'Page Title', + type: 'short-input', + placeholder: 'Optional title (defaults to page name)', + condition: { field: 'operation', value: ['create_page', 'update_page'] }, + mode: 'advanced', + }, + + { + id: 'pageContent', + title: 'Page Content', + type: 'long-input', + placeholder: 'Optional text content for the page', + condition: { field: 'operation', value: ['create_page', 'update_page'] }, + mode: 'advanced', }, { id: 'pageId', title: 'Page ID', type: 'short-input', - placeholder: 'Page ID (alternative to page name)', - condition: { field: 'operation', value: 'read_page' }, + placeholder: 'Page ID', + condition: { + field: 'operation', + value: ['read_page', 'update_page', 'publish_page', 'delete_page'], + }, + required: { field: 'operation', value: ['update_page', 'publish_page', 'delete_page'] }, mode: 'advanced', }, @@ -111,7 +147,14 @@ export const SharepointBlock: BlockConfig = { placeholder: 'Select a list', dependsOn: ['credential', 'siteSelector'], mode: 'basic', - condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] }, + condition: { + field: 'operation', + value: ['read_list', 'update_list', 'add_list_items', 'get_list_item', 'delete_list_item'], + }, + required: { + field: 'operation', + value: ['update_list', 'add_list_items', 'get_list_item', 'delete_list_item'], + }, }, { id: 'listId', @@ -120,7 +163,14 @@ export const SharepointBlock: BlockConfig = { canonicalParamId: 'listId', placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.', mode: 'advanced', - condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] }, + condition: { + field: 'operation', + value: ['read_list', 'update_list', 'add_list_items', 'get_list_item', 'delete_list_item'], + }, + required: { + field: 'operation', + value: ['update_list', 'add_list_items', 'get_list_item', 'delete_list_item'], + }, }, { @@ -129,7 +179,11 @@ export const SharepointBlock: BlockConfig = { type: 'short-input', placeholder: 'Enter item ID', canonicalParamId: 'itemId', - condition: { field: 'operation', value: ['update_list'] }, + condition: { + field: 'operation', + value: ['update_list', 'get_list_item', 'delete_list_item'], + }, + required: { field: 'operation', value: ['update_list', 'get_list_item', 'delete_list_item'] }, }, { @@ -138,6 +192,7 @@ export const SharepointBlock: BlockConfig = { type: 'short-input', placeholder: 'Name of the list', condition: { field: 'operation', value: 'create_list' }, + required: { field: 'operation', value: 'create_list' }, }, { @@ -270,11 +325,16 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, value: [ 'create_page', 'read_page', + 'update_page', + 'publish_page', + 'delete_page', 'list_sites', 'create_list', 'read_list', 'update_list', 'add_list_items', + 'get_list_item', + 'delete_list_item', 'upload_file', ], }, @@ -288,6 +348,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, 'Enter list item fields as JSON (e.g., {"Title": "My Item", "Status": "Active"})', canonicalParamId: 'listItemFields', condition: { field: 'operation', value: ['update_list', 'add_list_items'] }, + required: { field: 'operation', value: ['update_list', 'add_list_items'] }, wandConfig: { enabled: true, prompt: `Generate a JSON object for SharePoint list item fields based on the user's description. @@ -330,16 +391,81 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, }, }, - // Upload File operation fields + { + id: 'maxPages', + title: 'Max Pages', + type: 'short-input', + placeholder: 'Default 10, maximum 50', + condition: { field: 'operation', value: 'read_page' }, + mode: 'advanced', + }, + { + id: 'groupId', + title: 'Group ID', + type: 'short-input', + placeholder: 'Optional Microsoft 365 group ID', + condition: { field: 'operation', value: 'list_sites' }, + mode: 'advanced', + }, + { + id: 'includeColumns', + title: 'Include Columns', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'read_list' }, + mode: 'advanced', + }, + { + id: 'includeItems', + title: 'Include Items', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'read_list' }, + mode: 'advanced', + }, + { + id: 'nextPageUrl', + title: 'Next Page URL', + type: 'short-input', + placeholder: 'Paste the @odata.nextLink URL from a previous result', + condition: { + field: 'operation', + value: ['read_page', 'list_sites', 'read_list'], + }, + mode: 'advanced', + }, + + // Upload / Download / Delete File / Get Drive Item operation fields { id: 'driveId', title: 'Document Library ID', type: 'short-input', placeholder: 'Enter document library (drive) ID', canonicalParamId: 'driveId', - condition: { field: 'operation', value: 'upload_file' }, + condition: { + field: 'operation', + value: ['upload_file', 'download_file', 'delete_file', 'get_drive_item'], + }, + required: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] }, mode: 'advanced', }, + { + id: 'driveItemId', + title: 'File ID', + type: 'short-input', + placeholder: 'Enter the file (drive item) ID', + canonicalParamId: 'driveItemId', + condition: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] }, + required: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] }, + }, { id: 'folderPath', title: 'Folder Path', @@ -352,8 +478,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, id: 'fileName', title: 'File Name', type: 'short-input', - placeholder: 'Optional: override uploaded file name', - condition: { field: 'operation', value: 'upload_file' }, + placeholder: 'Optional: override uploaded/downloaded file name', + condition: { field: 'operation', value: ['upload_file', 'download_file'] }, mode: 'advanced', required: false, }, @@ -367,7 +493,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, condition: { field: 'operation', value: 'upload_file' }, mode: 'basic', multiple: true, - required: false, + required: true, }, // Variable reference (advanced mode) { @@ -378,19 +504,27 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, placeholder: 'Reference files from previous blocks', condition: { field: 'operation', value: 'upload_file' }, mode: 'advanced', - required: false, + required: true, }, ], tools: { access: [ 'sharepoint_create_page', 'sharepoint_read_page', + 'sharepoint_update_page', + 'sharepoint_publish_page', + 'sharepoint_delete_page', 'sharepoint_list_sites', 'sharepoint_create_list', 'sharepoint_get_list', 'sharepoint_update_list', 'sharepoint_add_list_items', + 'sharepoint_get_list_item', + 'sharepoint_delete_list_item', 'sharepoint_upload_file', + 'sharepoint_download_file', + 'sharepoint_get_drive_item', + 'sharepoint_delete_file', ], config: { tool: (params) => { @@ -399,6 +533,12 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, return 'sharepoint_create_page' case 'read_page': return 'sharepoint_read_page' + case 'update_page': + return 'sharepoint_update_page' + case 'publish_page': + return 'sharepoint_publish_page' + case 'delete_page': + return 'sharepoint_delete_page' case 'list_sites': return 'sharepoint_list_sites' case 'create_list': @@ -409,8 +549,18 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, return 'sharepoint_update_list' case 'add_list_items': return 'sharepoint_add_list_items' + case 'get_list_item': + return 'sharepoint_get_list_item' + case 'delete_list_item': + return 'sharepoint_delete_list_item' case 'upload_file': return 'sharepoint_upload_file' + case 'download_file': + return 'sharepoint_download_file' + case 'get_drive_item': + return 'sharepoint_get_drive_item' + case 'delete_file': + return 'sharepoint_delete_file' default: throw new Error(`Invalid Sharepoint operation: ${params.operation}`) } @@ -428,6 +578,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, includeItems, files, // canonical param from uploadFiles (basic) or files (advanced) driveId, // canonical param from driveId + driveItemId, // canonical param from driveItemId columnDefinitions, listId, ...others @@ -458,19 +609,16 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, } if (others.operation === 'update_list' || others.operation === 'add_list_items') { - try { - logger.info('SharepointBlock list item param check', { - siteId: effectiveSiteId || undefined, - listId: listId, - listTitle: (others as any)?.listTitle, - itemId: sanitizedItemId, - hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object', - itemFieldKeys: - parsedItemFields && typeof parsedItemFields === 'object' - ? Object.keys(parsedItemFields) - : [], - }) - } catch {} + logger.info('SharepointBlock list item param check', { + siteId: effectiveSiteId || undefined, + listId: listId, + itemId: sanitizedItemId, + hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object', + itemFieldKeys: + parsedItemFields && typeof parsedItemFields === 'object' + ? Object.keys(parsedItemFields) + : [], + }) } // Handle file upload files parameter using canonical param @@ -478,11 +626,10 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, const baseParams: Record = { oauthCredential, siteId: effectiveSiteId || undefined, - pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined, - mimeType: mimeType, ...others, ...(listId ? { listId } : {}), ...(driveId ? { driveId } : {}), + ...(driveItemId ? { driveItemId: String(driveItemId).trim() } : {}), itemId: sanitizedItemId, listItemFields: parsedItemFields, includeColumns: coerceBoolean(includeColumns), @@ -494,7 +641,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, baseParams.files = normalizedFiles } - if (columnDefinitions) { + if (columnDefinitions && others.operation === 'create_list') { baseParams.pageContent = columnDefinitions } @@ -511,14 +658,13 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, description: 'Column definitions for list creation (JSON array)', }, pageTitle: { type: 'string', description: 'Page title' }, + pageContent: { type: 'string', description: 'Page text content' }, pageId: { type: 'string', description: 'Page ID' }, siteId: { type: 'string', description: 'Site ID' }, - pageSize: { type: 'number', description: 'Results per page' }, listDisplayName: { type: 'string', description: 'List display name' }, listDescription: { type: 'string', description: 'List description' }, listTemplate: { type: 'string', description: 'List template' }, listId: { type: 'string', description: 'List ID' }, - listTitle: { type: 'string', description: 'List title' }, includeColumns: { type: 'boolean', description: 'Include columns in response' }, includeItems: { type: 'boolean', description: 'Include items in response' }, itemId: { type: 'string', description: 'List item ID (canonical param)' }, @@ -527,9 +673,16 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, type: 'string', description: 'Document library (drive) ID', }, + driveItemId: { type: 'string', description: 'File (drive item) ID (canonical param)' }, folderPath: { type: 'string', description: 'Folder path for file upload' }, fileName: { type: 'string', description: 'File name override' }, files: { type: 'array', description: 'Files to upload' }, + maxPages: { type: 'number', description: 'Maximum pages to return when reading all pages' }, + groupId: { type: 'string', description: 'Microsoft 365 group ID for group-owned sites' }, + nextPageUrl: { + type: 'string', + description: 'Full Microsoft Graph @odata.nextLink URL from a previous result', + }, }, outputs: { sites: { @@ -537,10 +690,40 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, description: 'An array of SharePoint site objects, each containing details such as id, name, and more.', }, + site: { + type: 'json', + description: 'Single SharePoint site object (id, name, displayName, webUrl)', + }, + page: { + type: 'json', + description: 'SharePoint page object (id, name, title, webUrl, pageLayout)', + }, + pages: { + type: 'json', + description: 'Array of SharePoint pages with content ([{page, content}])', + }, + content: { + type: 'json', + description: 'Content of the SharePoint page (content, canvasLayout)', + }, + totalPages: { + type: 'number', + description: 'Total number of pages found when listing all pages', + }, + nextPageUrl: { + type: 'string', + description: 'Full Microsoft Graph @odata.nextLink URL for the next page of results', + }, + published: { type: 'boolean', description: 'Whether the page was published' }, + deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' }, list: { type: 'json', description: 'SharePoint list object (id, displayName, name, webUrl, etc.)', }, + lists: { + type: 'json', + description: 'Array of SharePoint list objects when no specific list is requested', + }, item: { type: 'json', description: 'SharePoint list item with fields', @@ -549,6 +732,14 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, type: 'json', description: 'Array of SharePoint list items with fields', }, + driveItem: { + type: 'json', + description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)', + }, + file: { + type: 'json', + description: 'Downloaded file stored in execution files', + }, uploadedFiles: { type: 'json', description: 'Array of uploaded file objects with id, name, webUrl, size', @@ -557,6 +748,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, type: 'number', description: 'Number of files uploaded', }, + skippedFiles: { + type: 'json', + description: + 'Files skipped during upload for exceeding the size limit (name, size, limit, reason)', + }, + skippedCount: { + type: 'number', + description: 'Number of files skipped during upload', + }, + errors: { + type: 'json', + description: 'Per-file upload errors ([{name, error, status}])', + }, + itemId: { type: 'string', description: 'ID of the deleted list item or file' }, + pageId: { type: 'string', description: 'ID of the deleted or published page' }, success: { type: 'boolean', description: 'Success status', @@ -571,21 +777,48 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, const SHAREPOINT_V2_TOOL_IDS = [ 'sharepoint_create_page', 'sharepoint_read_page', + 'sharepoint_update_page', + 'sharepoint_publish_page', + 'sharepoint_delete_page', 'sharepoint_list_sites', 'sharepoint_create_list', 'sharepoint_get_list', 'sharepoint_update_list', 'sharepoint_add_list_items', + 'sharepoint_get_list_item', + 'sharepoint_delete_list_item', 'sharepoint_upload_file', + 'sharepoint_download_file', + 'sharepoint_get_drive_item', + 'sharepoint_delete_file', ] as const -const SHAREPOINT_V2_SITE_OPERATIONS = Array.from(SHAREPOINT_V2_TOOL_IDS) +const SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS = [ + 'sharepoint_download_file', + 'sharepoint_delete_file', + 'sharepoint_get_drive_item', +] as const + +const SHAREPOINT_V2_SITE_OPERATIONS = SHAREPOINT_V2_TOOL_IDS.filter( + (id) => !(SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS as readonly string[]).includes(id) +) const SHAREPOINT_V2_LIST_ITEM_OPERATIONS = [ 'sharepoint_update_list', 'sharepoint_add_list_items', ] as const +const SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS = [ + 'sharepoint_get_list_item', + 'sharepoint_delete_list_item', +] as const + +const SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS = [ + 'sharepoint_update_page', + 'sharepoint_publish_page', + 'sharepoint_delete_page', +] as const + export const SharepointV2Block: BlockConfig = { ...SharepointBlock, type: 'sharepoint_v2', @@ -599,12 +832,20 @@ export const SharepointV2Block: BlockConfig = { options: [ { label: 'Create Page', id: 'sharepoint_create_page' }, { label: 'Read Page', id: 'sharepoint_read_page' }, + { label: 'Update Page', id: 'sharepoint_update_page' }, + { label: 'Publish Page', id: 'sharepoint_publish_page' }, + { label: 'Delete Page', id: 'sharepoint_delete_page' }, { label: 'List Sites', id: 'sharepoint_list_sites' }, { label: 'Create List', id: 'sharepoint_create_list' }, { label: 'Read List', id: 'sharepoint_get_list' }, { label: 'Update List Item', id: 'sharepoint_update_list' }, { label: 'Add List Item', id: 'sharepoint_add_list_items' }, + { label: 'Get List Item', id: 'sharepoint_get_list_item' }, + { label: 'Delete List Item', id: 'sharepoint_delete_list_item' }, { label: 'Upload File', id: 'sharepoint_upload_file' }, + { label: 'Download File', id: 'sharepoint_download_file' }, + { label: 'Get Drive Item', id: 'sharepoint_get_drive_item' }, + { label: 'Delete File', id: 'sharepoint_delete_file' }, ], value: () => 'sharepoint_create_page', }, @@ -664,8 +905,12 @@ export const SharepointV2Block: BlockConfig = { id: 'pageId', title: 'Page ID', type: 'short-input', - placeholder: 'Page ID (alternative to page name)', - condition: { field: 'operation', value: 'sharepoint_read_page' }, + placeholder: 'Page ID (alternative to page name for Read Page)', + condition: { + field: 'operation', + value: ['sharepoint_read_page', ...SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS], + }, + required: { field: 'operation', value: [...SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS] }, mode: 'advanced', }, { @@ -673,7 +918,10 @@ export const SharepointV2Block: BlockConfig = { title: 'Page Title', type: 'short-input', placeholder: 'Optional title (defaults to page name)', - condition: { field: 'operation', value: 'sharepoint_create_page' }, + condition: { + field: 'operation', + value: ['sharepoint_create_page', 'sharepoint_update_page'], + }, mode: 'advanced', }, { @@ -681,7 +929,10 @@ export const SharepointV2Block: BlockConfig = { title: 'Page Content', type: 'long-input', placeholder: 'Optional text content for the page', - condition: { field: 'operation', value: 'sharepoint_create_page' }, + condition: { + field: 'operation', + value: ['sharepoint_create_page', 'sharepoint_update_page'], + }, mode: 'advanced', }, { @@ -712,9 +963,19 @@ export const SharepointV2Block: BlockConfig = { mode: 'basic', condition: { field: 'operation', - value: ['sharepoint_get_list', ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS], + value: [ + 'sharepoint_get_list', + ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS, + ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS, + ], + }, + required: { + field: 'operation', + value: [ + ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS, + ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS, + ], }, - required: { field: 'operation', value: [...SHAREPOINT_V2_LIST_ITEM_OPERATIONS] }, }, { id: 'manualListId', @@ -725,9 +986,19 @@ export const SharepointV2Block: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['sharepoint_get_list', ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS], + value: [ + 'sharepoint_get_list', + ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS, + ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS, + ], + }, + required: { + field: 'operation', + value: [ + ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS, + ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS, + ], }, - required: { field: 'operation', value: [...SHAREPOINT_V2_LIST_ITEM_OPERATIONS] }, }, { id: 'includeColumns', @@ -821,8 +1092,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, title: 'Item ID', type: 'short-input', placeholder: 'Enter item ID', - condition: { field: 'operation', value: 'sharepoint_update_list' }, - required: { field: 'operation', value: 'sharepoint_update_list' }, + condition: { + field: 'operation', + value: ['sharepoint_update_list', ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS], + }, + required: { + field: 'operation', + value: ['sharepoint_update_list', ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS], + }, }, { id: 'listItemFields', @@ -848,9 +1125,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, title: 'Document Library ID', type: 'short-input', placeholder: 'Enter document library (drive) ID', - condition: { field: 'operation', value: 'sharepoint_upload_file' }, + condition: { + field: 'operation', + value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS, 'sharepoint_upload_file'], + }, + required: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] }, mode: 'advanced', }, + { + id: 'driveItemId', + title: 'File ID', + type: 'short-input', + placeholder: 'Enter the file (drive item) ID', + condition: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] }, + required: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] }, + }, { id: 'folderPath', title: 'Folder Path', @@ -864,8 +1153,11 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, id: 'fileName', title: 'File Name', type: 'short-input', - placeholder: 'Optional: override uploaded file name', - condition: { field: 'operation', value: 'sharepoint_upload_file' }, + placeholder: 'Optional: override uploaded/downloaded file name', + condition: { + field: 'operation', + value: ['sharepoint_upload_file', 'sharepoint_download_file'], + }, mode: 'advanced', required: false, }, @@ -919,6 +1211,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, files, maxPages, driveId, + driveItemId, ...rest } = params @@ -950,13 +1243,14 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, listId: cleanString(listId), itemId: cleanString(itemId), driveId: cleanString(driveId), + driveItemId: cleanString(driveItemId), includeColumns: coerceBoolean(includeColumns), includeItems: coerceBoolean(includeItems), listItemFields: parseJsonObject(listItemFields), maxPages: maxPages ? Number.parseInt(String(maxPages), 10) : undefined, } - if (columnDefinitions) { + if (columnDefinitions && rest.operation === 'sharepoint_create_list') { result.pageContent = columnDefinitions } if (normalizedFiles) { @@ -991,6 +1285,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, itemId: { type: 'string', description: 'List item ID' }, listItemFields: { type: 'json', description: 'List item fields' }, driveId: { type: 'string', description: 'Document library (drive) ID' }, + driveItemId: { type: 'string', description: 'File (drive item) ID' }, folderPath: { type: 'string', description: 'Folder path for file upload' }, fileName: { type: 'string', description: 'File name override' }, files: { type: 'json', description: 'Files to upload' }, @@ -1017,6 +1312,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, description: 'SharePoint page content (content, canvasLayout)', }, totalPages: { type: 'number', description: 'Number of pages returned' }, + published: { type: 'boolean', description: 'Whether the page was published' }, list: { type: 'json', description: 'SharePoint list object (id, displayName, name, webUrl, columns, items)', @@ -1027,6 +1323,15 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, }, item: { type: 'json', description: 'SharePoint list item with fields' }, items: { type: 'json', description: 'Array of SharePoint list items with fields' }, + deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' }, + driveItem: { + type: 'json', + description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)', + }, + file: { + type: 'json', + description: 'Downloaded file stored in execution files', + }, uploadedFiles: { type: 'json', description: 'Array of uploaded file objects with id, name, webUrl, size', @@ -1041,6 +1346,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`, type: 'json', description: 'Array of per-file upload errors (name, error, status)', }, + itemId: { type: 'string', description: 'ID of the deleted list item or file' }, + pageId: { type: 'string', description: 'ID of the deleted or published page' }, nextPageUrl: { type: 'string', description: 'Microsoft Graph @odata.nextLink URL for the next page of results', diff --git a/apps/sim/blocks/blocks/similarweb.ts b/apps/sim/blocks/blocks/similarweb.ts index 535a706ca69..5810267316a 100644 --- a/apps/sim/blocks/blocks/similarweb.ts +++ b/apps/sim/blocks/blocks/similarweb.ts @@ -26,6 +26,7 @@ export const SimilarwebBlock: BlockConfig = { { label: 'Bounce Rate', id: 'similarweb_bounce_rate' }, { label: 'Pages Per Visit', id: 'similarweb_pages_per_visit' }, { label: 'Visit Duration (Desktop)', id: 'similarweb_visit_duration' }, + { label: 'Page Views', id: 'similarweb_page_views' }, ], value: () => 'similarweb_website_overview', }, @@ -88,6 +89,7 @@ export const SimilarwebBlock: BlockConfig = { title: 'Start Date', type: 'short-input', placeholder: 'YYYY-MM (e.g., 2024-01)', + mode: 'advanced', condition: { field: 'operation', value: 'similarweb_website_overview', @@ -112,6 +114,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e title: 'End Date', type: 'short-input', placeholder: 'YYYY-MM (e.g., 2024-12)', + mode: 'advanced', condition: { field: 'operation', value: 'similarweb_website_overview', @@ -134,6 +137,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e id: 'mainDomainOnly', title: 'Main Domain Only', type: 'switch', + mode: 'advanced', condition: { field: 'operation', value: 'similarweb_website_overview', @@ -157,6 +161,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e 'similarweb_bounce_rate', 'similarweb_pages_per_visit', 'similarweb_visit_duration', + 'similarweb_page_views', ], config: { tool: (params) => params.operation, @@ -197,6 +202,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e bounceRate: { type: 'json', description: 'Bounce rate data over time' }, pagesPerVisit: { type: 'json', description: 'Pages per visit data over time' }, averageVisitDuration: { type: 'json', description: 'Desktop visit duration data over time' }, + pageViews: { type: 'json', description: 'Page view data over time' }, }, } diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts index 9f0d5eac72e..adadb2b1523 100644 --- a/apps/sim/blocks/blocks/supabase.ts +++ b/apps/sim/blocks/blocks/supabase.ts @@ -45,7 +45,10 @@ export const SupabaseBlock: BlockConfig = { { label: 'Storage: Copy File', id: 'storage_copy' }, { label: 'Storage: Get Public URL', id: 'storage_get_public_url' }, { label: 'Storage: Create Signed URL', id: 'storage_create_signed_url' }, + { label: 'Storage: Create Signed Upload URL', id: 'storage_create_signed_upload_url' }, { label: 'Storage: Create Bucket', id: 'storage_create_bucket' }, + { label: 'Storage: Update Bucket', id: 'storage_update_bucket' }, + { label: 'Storage: Empty Bucket', id: 'storage_empty_bucket' }, { label: 'Storage: List Buckets', id: 'storage_list_buckets' }, { label: 'Storage: Delete Bucket', id: 'storage_delete_bucket' }, ], @@ -686,9 +689,12 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e 'storage_move', 'storage_copy', 'storage_create_bucket', + 'storage_update_bucket', + 'storage_empty_bucket', 'storage_delete_bucket', 'storage_get_public_url', 'storage_create_signed_url', + 'storage_create_signed_upload_url', ], }, required: true, @@ -919,19 +925,53 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e value: () => 'false', condition: { field: 'operation', value: 'storage_create_bucket' }, }, + { + id: 'updateIsPublic', + title: 'Public Bucket', + type: 'dropdown', + options: [ + { label: 'Keep Current', id: '' }, + { label: 'False (Private)', id: 'false' }, + { label: 'True (Public)', id: 'true' }, + ], + value: () => '', + condition: { field: 'operation', value: 'storage_update_bucket' }, + }, { id: 'fileSizeLimit', title: 'File Size Limit (bytes)', type: 'short-input', placeholder: '52428800', - condition: { field: 'operation', value: 'storage_create_bucket' }, + condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] }, + mode: 'advanced', }, { id: 'allowedMimeTypes', title: 'Allowed MIME Types (JSON array)', type: 'code', placeholder: '["image/png", "image/jpeg"]', - condition: { field: 'operation', value: 'storage_create_bucket' }, + condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] }, + mode: 'advanced', + }, + { + id: 'path', + title: 'Destination Path', + type: 'short-input', + placeholder: 'folder/file.jpg', + condition: { field: 'operation', value: 'storage_create_signed_upload_url' }, + required: true, + }, + { + id: 'upsert', + title: 'Allow Overwrite', + type: 'dropdown', + options: [ + { label: 'False', id: 'false' }, + { label: 'True', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'storage_create_signed_upload_url' }, + mode: 'advanced', }, ], tools: { @@ -955,10 +995,13 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e 'supabase_storage_move', 'supabase_storage_copy', 'supabase_storage_create_bucket', + 'supabase_storage_update_bucket', + 'supabase_storage_empty_bucket', 'supabase_storage_list_buckets', 'supabase_storage_delete_bucket', 'supabase_storage_get_public_url', 'supabase_storage_create_signed_url', + 'supabase_storage_create_signed_upload_url', ], config: { tool: (params) => { @@ -1001,6 +1044,10 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e return 'supabase_storage_copy' case 'storage_create_bucket': return 'supabase_storage_create_bucket' + case 'storage_update_bucket': + return 'supabase_storage_update_bucket' + case 'storage_empty_bucket': + return 'supabase_storage_empty_bucket' case 'storage_list_buckets': return 'supabase_storage_list_buckets' case 'storage_delete_bucket': @@ -1009,6 +1056,8 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e return 'supabase_storage_get_public_url' case 'storage_create_signed_url': return 'supabase_storage_create_signed_url' + case 'storage_create_signed_upload_url': + return 'supabase_storage_create_signed_upload_url' default: throw new Error(`Invalid Supabase operation: ${params.operation}`) } @@ -1028,6 +1077,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e functionBody, functionHeaders, method, + updateIsPublic, ...rest } = params @@ -1200,6 +1250,16 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e result.isPublic = parsedIsPublic } + // "Keep Current" (empty string) means the caller didn't choose to + // override visibility — omit `isPublic` entirely so the update + // tool preserves the bucket's existing public/private setting + // instead of defaulting to false. + if (operation === 'storage_update_bucket' && updateIsPublic !== undefined) { + if (updateIsPublic === 'true' || updateIsPublic === 'false') { + result.isPublic = updateIsPublic === 'true' + } + } + if (normalizedFileData !== undefined) { result.fileData = normalizedFileData } @@ -1254,6 +1314,11 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e search: { type: 'string', description: 'Search term for filtering' }, expiresIn: { type: 'number', description: 'Expiration time in seconds for signed URL' }, isPublic: { type: 'boolean', description: 'Whether bucket should be public' }, + updateIsPublic: { + type: 'string', + description: + 'Visibility override for bucket update: "" keeps the current value, "true"/"false" overrides it', + }, fileSizeLimit: { type: 'number', description: 'Maximum file size in bytes' }, allowedMimeTypes: { type: 'array', description: 'Array of allowed MIME types' }, }, @@ -1281,7 +1346,15 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e }, signedUrl: { type: 'string', - description: 'Temporary signed URL for storage file', + description: 'Temporary signed URL for storage file (download or upload)', + }, + token: { + type: 'string', + description: 'Upload token embedded in the signed upload URL', + }, + path: { + type: 'string', + description: 'Destination object path for the signed upload URL', }, tables: { type: 'json', @@ -1300,9 +1373,9 @@ export const SupabaseBlockMeta = { templates: [ { icon: SupabaseIcon, - title: 'Supabase user provisioning', + title: 'Supabase customer record sync', prompt: - 'Build a workflow that listens for Stripe new-customer events, provisions a Supabase user with the correct role and metadata, and emails the welcome login link.', + 'Build a workflow that listens for Stripe new-customer events and upserts a row into a Supabase customers table with the correct plan and metadata, then emails a welcome message.', modules: ['agent', 'workflows'], category: 'operations', tags: ['enterprise', 'automation'], diff --git a/apps/sim/blocks/blocks/tailscale.ts b/apps/sim/blocks/blocks/tailscale.ts index 690bb0a772e..a4f3a583172 100644 --- a/apps/sim/blocks/blocks/tailscale.ts +++ b/apps/sim/blocks/blocks/tailscale.ts @@ -29,6 +29,7 @@ export const TailscaleBlock: BlockConfig = { { label: 'Get Device Routes', id: 'get_device_routes' }, { label: 'Set Device Routes', id: 'set_device_routes' }, { label: 'Update Device Key', id: 'update_device_key' }, + { label: 'Expire Device Key', id: 'expire_device_key' }, { label: 'List DNS Nameservers', id: 'list_dns_nameservers' }, { label: 'Set DNS Nameservers', id: 'set_dns_nameservers' }, { label: 'Get DNS Preferences', id: 'get_dns_preferences' }, @@ -36,11 +37,14 @@ export const TailscaleBlock: BlockConfig = { { label: 'Get DNS Search Paths', id: 'get_dns_searchpaths' }, { label: 'Set DNS Search Paths', id: 'set_dns_searchpaths' }, { label: 'List Users', id: 'list_users' }, + { label: 'Suspend User', id: 'suspend_user' }, + { label: 'Delete User', id: 'delete_user' }, { label: 'Create Auth Key', id: 'create_auth_key' }, { label: 'List Auth Keys', id: 'list_auth_keys' }, { label: 'Get Auth Key', id: 'get_auth_key' }, { label: 'Delete Auth Key', id: 'delete_auth_key' }, { label: 'Get ACL', id: 'get_acl' }, + { label: 'Set ACL', id: 'set_acl' }, ], value: () => 'list_devices', }, @@ -74,6 +78,7 @@ export const TailscaleBlock: BlockConfig = { 'get_device_routes', 'set_device_routes', 'update_device_key', + 'expire_device_key', ], }, required: { @@ -86,6 +91,7 @@ export const TailscaleBlock: BlockConfig = { 'get_device_routes', 'set_device_routes', 'update_device_key', + 'expire_device_key', ], }, }, @@ -144,6 +150,11 @@ export const TailscaleBlock: BlockConfig = { placeholder: '8.8.8.8,8.8.4.4', condition: { field: 'operation', value: 'set_dns_nameservers' }, required: { field: 'operation', value: 'set_dns_nameservers' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of DNS nameserver IP addresses (e.g., 8.8.8.8,8.8.4.4). Return ONLY the comma-separated IP addresses - no explanations, no extra text.', + }, }, { id: 'magicDNS', @@ -163,6 +174,11 @@ export const TailscaleBlock: BlockConfig = { placeholder: 'corp.example.com,internal.example.com', condition: { field: 'operation', value: 'set_dns_searchpaths' }, required: { field: 'operation', value: 'set_dns_searchpaths' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of DNS search path domains (e.g., corp.example.com,internal.example.com). Return ONLY the comma-separated domains - no explanations, no extra text.', + }, }, { id: 'keyId', @@ -224,6 +240,30 @@ export const TailscaleBlock: BlockConfig = { condition: { field: 'operation', value: 'create_auth_key' }, mode: 'advanced', }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter user ID', + condition: { field: 'operation', value: ['suspend_user', 'delete_user'] }, + required: { field: 'operation', value: ['suspend_user', 'delete_user'] }, + }, + { + id: 'acl', + title: 'ACL Policy', + type: 'long-input', + placeholder: '{"acls": [{"action": "accept", "users": ["*"], "ports": ["*:*"]}]}', + condition: { field: 'operation', value: 'set_acl' }, + required: { field: 'operation', value: 'set_acl' }, + }, + { + id: 'ifMatch', + title: 'If-Match ETag', + type: 'short-input', + placeholder: 'ETag from Get ACL, or "ts-default"', + condition: { field: 'operation', value: 'set_acl' }, + mode: 'advanced', + }, ], tools: { @@ -236,6 +276,7 @@ export const TailscaleBlock: BlockConfig = { 'tailscale_get_device_routes', 'tailscale_set_device_routes', 'tailscale_update_device_key', + 'tailscale_expire_device_key', 'tailscale_list_dns_nameservers', 'tailscale_set_dns_nameservers', 'tailscale_get_dns_preferences', @@ -243,11 +284,14 @@ export const TailscaleBlock: BlockConfig = { 'tailscale_get_dns_searchpaths', 'tailscale_set_dns_searchpaths', 'tailscale_list_users', + 'tailscale_suspend_user', + 'tailscale_delete_user', 'tailscale_create_auth_key', 'tailscale_list_auth_keys', 'tailscale_get_auth_key', 'tailscale_delete_auth_key', 'tailscale_get_acl', + 'tailscale_set_acl', ], config: { tool: (params) => `tailscale_${params.operation}`, @@ -258,10 +302,13 @@ export const TailscaleBlock: BlockConfig = { } if (params.deviceId) mapped.deviceId = params.deviceId if (params.keyId) mapped.keyId = params.keyId + if (params.userId) mapped.userId = params.userId if (params.tags) mapped.tags = params.tags if (params.routes) mapped.routes = params.routes if (params.dnsServers) mapped.dns = params.dnsServers if (params.searchPaths) mapped.searchPaths = params.searchPaths + if (params.acl) mapped.acl = params.acl + if (params.ifMatch) mapped.ifMatch = params.ifMatch if (params.authorized !== undefined) mapped.authorized = params.authorized === 'true' if (params.keyExpiryDisabled !== undefined) mapped.keyExpiryDisabled = params.keyExpiryDisabled === 'true' @@ -282,6 +329,9 @@ export const TailscaleBlock: BlockConfig = { tailnet: { type: 'string', description: 'Tailnet name' }, deviceId: { type: 'string', description: 'Device ID' }, keyId: { type: 'string', description: 'Auth key ID' }, + userId: { type: 'string', description: 'User ID' }, + acl: { type: 'string', description: 'ACL policy file as a JSON string' }, + ifMatch: { type: 'string', description: 'ETag for optimistic concurrency on ACL updates' }, authorized: { type: 'string', description: 'Authorization status' }, keyExpiryDisabled: { type: 'string', description: 'Whether to disable key expiry' }, tags: { type: 'string', description: 'Comma-separated tags' }, @@ -300,6 +350,7 @@ export const TailscaleBlock: BlockConfig = { devices: { type: 'json', description: 'List of devices in the tailnet' }, count: { type: 'number', description: 'Total count of items returned' }, id: { type: 'string', description: 'Device or auth key ID' }, + nodeId: { type: 'string', description: 'Preferred device ID' }, name: { type: 'string', description: 'Device name' }, hostname: { type: 'string', description: 'Device hostname' }, user: { type: 'string', description: 'Associated user' }, @@ -331,11 +382,12 @@ export const TailscaleBlock: BlockConfig = { key: { type: 'string', description: 'Auth key value (only at creation)' }, keyId: { type: 'string', description: 'Auth key ID' }, description: { type: 'string', description: 'Auth key description' }, - expires: { type: 'string', description: 'Expiration timestamp' }, + expires: { type: 'string', description: 'Device key or auth key expiration timestamp' }, revoked: { type: 'string', description: 'Revocation timestamp' }, capabilities: { type: 'json', description: 'Auth key capabilities' }, acl: { type: 'string', description: 'ACL policy as JSON string' }, etag: { type: 'string', description: 'ACL ETag for conditional updates' }, + userId: { type: 'string', description: 'User ID' }, }, } @@ -356,7 +408,7 @@ export const TailscaleBlockMeta = { icon: TailscaleIcon, title: 'Tailscale ACL drift detector', prompt: - 'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift, and writes the drift report to Slack.', + 'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift to Slack, and, on approval, pushes the corrected policy back with Set ACL.', modules: ['scheduled', 'agent', 'workflows'], category: 'engineering', tags: ['devops', 'monitoring'], @@ -376,7 +428,7 @@ export const TailscaleBlockMeta = { icon: TailscaleIcon, title: 'Tailscale offboarder', prompt: - "Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, and writes the security audit log.", + "Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, suspends their tailnet user account, and writes the security audit log.", modules: ['agent', 'workflows'], category: 'operations', tags: ['hr', 'enterprise'], @@ -429,9 +481,24 @@ export const TailscaleBlockMeta = { }, { name: 'offboard-device', - description: 'Deauthorize or remove a departing user device and revoke its auth keys.', + description: + "Deauthorize or remove a departing user's devices and auth keys, then suspend or delete their tailnet account.", + content: + '# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Use List Users to find the userId, then Suspend User to freeze access reversibly, or Delete User to remove the account entirely.\n5. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted, the auth keys were revoked, and the user account was suspended or deleted for the offboarding audit log.', + }, + { + name: 'update-tailnet-acl', + description: + 'Push an updated ACL policy file to the tailnet using ETag-guarded writes to avoid clobbering concurrent edits.', + content: + '# Update the Tailnet ACL as Policy-as-Code\n\nApply a reviewed ACL change programmatically instead of editing it by hand in the admin console.\n\n## Steps\n1. Use Get ACL to fetch the current policy file and its etag.\n2. Compute or generate the new policy JSON from your source of truth (git, agent-authored rules, etc.).\n3. Use Set ACL with the new ACL Policy JSON, passing the etag from step 1 in If-Match to guard against concurrent updates (use "ts-default" instead if you only want to replace an untouched default policy).\n4. If the write fails with a precondition error, re-fetch the ACL and retry.\n\n## Output\nReturn the updated ACL JSON and its new etag, plus a summary of what changed for the change-management record.', + }, + { + name: 'lock-down-compromised-device', + description: + "Immediately expire a suspected-compromised device's node key so it must re-authenticate before rejoining the tailnet.", content: - '# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted and list the revoked auth keys for the offboarding audit log.', + "# Lock Down a Compromised Device\n\nCut off a device the moment it looks compromised, without waiting for its key to expire naturally.\n\n## Steps\n1. Use Get Device or List Devices to confirm the deviceId and review its tags, addresses, and lastSeen.\n2. Use Expire Device Key to immediately invalidate the device's node key so it can no longer connect until it re-authenticates.\n3. For a harder block, follow up with Authorize Device set to Deauthorize, or Delete Device to remove it outright.\n4. Log the deviceId, hostname, and user in the incident record.\n\n## Output\nConfirm the key was expired (and the device deauthorized/deleted if applicable) for the security incident log.", }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 371ab9dcaa9..a9226ef3380 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -55,10 +55,10 @@ function parseStringArray(value: unknown): string[] | undefined { export const TrelloBlock: BlockConfig = { type: 'trello', name: 'Trello', - description: 'Manage Trello lists, cards, and activity', + description: 'Manage Trello lists, cards, checklists, and activity', authMode: AuthMode.OAuth, longDescription: - 'Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.', + 'Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments.', docsLink: 'https://docs.sim.ai/integrations/trello', category: 'tools', integrationType: IntegrationType.Productivity, @@ -72,17 +72,25 @@ export const TrelloBlock: BlockConfig = { options: [ { label: 'Get Lists', id: 'trello_list_lists' }, { label: 'List Cards', id: 'trello_list_cards' }, + { label: 'Search', id: 'trello_search' }, { label: 'Create Card', id: 'trello_create_card' }, { label: 'Get Card', id: 'trello_get_card' }, { label: 'Update Card', id: 'trello_update_card' }, + { label: 'Delete Card', id: 'trello_delete_card' }, { label: 'Get Actions', id: 'trello_get_actions' }, { label: 'Add Comment', id: 'trello_add_comment' }, { label: 'Add Checklist', id: 'trello_add_checklist' }, + { label: 'Add Checklist Item', id: 'trello_add_checklist_item' }, + { label: 'Update Checklist Item', id: 'trello_update_checklist_item' }, { label: 'Add Label', id: 'trello_add_label' }, + { label: 'Remove Label', id: 'trello_remove_label' }, { label: 'Add Member', id: 'trello_add_member' }, + { label: 'Remove Member', id: 'trello_remove_member' }, + { label: 'List Members', id: 'trello_list_members' }, { label: 'Create Board', id: 'trello_create_board' }, { label: 'Get Board', id: 'trello_get_board' }, { label: 'Create List', id: 'trello_create_list' }, + { label: 'Update List', id: 'trello_update_list' }, ], value: () => 'trello_list_lists', }, @@ -124,11 +132,17 @@ export const TrelloBlock: BlockConfig = { 'trello_get_actions', 'trello_get_board', 'trello_create_list', + 'trello_list_members', ], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'], + value: [ + 'trello_list_lists', + 'trello_get_board', + 'trello_create_list', + 'trello_list_members', + ], }, }, { @@ -147,11 +161,17 @@ export const TrelloBlock: BlockConfig = { 'trello_get_actions', 'trello_get_board', 'trello_create_list', + 'trello_list_members', ], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'], + value: [ + 'trello_list_lists', + 'trello_get_board', + 'trello_create_list', + 'trello_list_members', + ], }, }, { @@ -161,11 +181,43 @@ export const TrelloBlock: BlockConfig = { placeholder: 'Enter Trello list ID', condition: { field: 'operation', - value: ['trello_list_cards', 'trello_create_card'], + value: ['trello_list_cards', 'trello_create_card', 'trello_update_list'], }, required: { field: 'operation', - value: 'trello_create_card', + value: ['trello_create_card', 'trello_update_list'], + }, + }, + { + id: 'listFilter', + title: 'List Filter', + type: 'dropdown', + options: [ + { label: 'Open (default)', id: '' }, + { label: 'Closed', id: 'closed' }, + { label: 'All', id: 'all' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_list_lists', + }, + }, + { + id: 'cardFilter', + title: 'Card Filter', + type: 'dropdown', + options: [ + { label: 'Open (default)', id: '' }, + { label: 'Closed', id: 'closed' }, + { label: 'All', id: 'all' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_list_cards', }, }, { @@ -177,23 +229,31 @@ export const TrelloBlock: BlockConfig = { field: 'operation', value: [ 'trello_update_card', + 'trello_delete_card', 'trello_get_actions', 'trello_add_comment', 'trello_get_card', 'trello_add_checklist', + 'trello_update_checklist_item', 'trello_add_label', + 'trello_remove_label', 'trello_add_member', + 'trello_remove_member', ], }, required: { field: 'operation', value: [ 'trello_update_card', + 'trello_delete_card', 'trello_add_comment', 'trello_get_card', 'trello_add_checklist', + 'trello_update_checklist_item', 'trello_add_label', + 'trello_remove_label', 'trello_add_member', + 'trello_remove_member', ], }, }, @@ -290,6 +350,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, placeholder: 'Describe the label IDs to include...', }, }, + { + id: 'memberIds', + title: 'Member IDs', + type: 'short-input', + placeholder: 'Comma-separated member IDs', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_create_card', + }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Trello member IDs. Return ONLY the comma-separated values - no explanations, no extra text.', + placeholder: 'Describe the member IDs to assign...', + }, + }, { id: 'closed', title: 'Archive Status', @@ -311,7 +388,6 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, title: 'Move to List ID', type: 'short-input', placeholder: 'Enter Trello list ID', - mode: 'advanced', condition: { field: 'operation', value: 'trello_update_card', @@ -350,6 +426,52 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, value: 'trello_get_actions', }, }, + { + id: 'since', + title: 'Since', + type: 'short-input', + placeholder: 'ISO 8601 timestamp or action ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_get_actions', + }, + wandConfig: { + enabled: true, + prompt: `Generate a date or timestamp based on the user's description. +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. +Examples: +- "yesterday" -> Calculate yesterday's date in YYYY-MM-DD format +- "1 week ago" -> Calculate the date 1 week ago in YYYY-MM-DD format + +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the start of the range (e.g. "1 week ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'before', + title: 'Before', + type: 'short-input', + placeholder: 'ISO 8601 timestamp or action ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_get_actions', + }, + wandConfig: { + enabled: true, + prompt: `Generate a date or timestamp based on the user's description. +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. +Examples: +- "today" -> Calculate today's date in YYYY-MM-DD format +- "end of last month" -> Calculate the last day of the previous month + +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the end of the range (e.g. "today")...', + generationType: 'timestamp', + }, + }, { id: 'text', title: 'Comment', @@ -415,10 +537,13 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'short-input', placeholder: 'Enter list name', condition: { + field: 'operation', + value: ['trello_create_list', 'trello_update_list'], + }, + required: { field: 'operation', value: 'trello_create_list', }, - required: true, }, { id: 'listPos', @@ -428,7 +553,34 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: 'trello_create_list', + value: ['trello_create_list', 'trello_update_list'], + }, + }, + { + id: 'listClosed', + title: 'Archive Status', + type: 'dropdown', + options: [ + { label: 'Leave Unchanged', id: '' }, + { label: 'Archive List', id: 'true' }, + { label: 'Reopen List', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_list', + }, + }, + { + id: 'moveListToBoardId', + title: 'Move to Board ID', + type: 'short-input', + placeholder: 'Enter Trello board ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_list', }, }, { @@ -453,6 +605,91 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, value: 'trello_add_checklist', }, }, + { + id: 'checklistId', + title: 'Checklist ID', + type: 'short-input', + placeholder: 'Enter Trello checklist ID', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + required: true, + }, + { + id: 'itemName', + title: 'Item Name', + type: 'short-input', + placeholder: 'Enter checklist item name', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + required: true, + }, + { + id: 'itemPos', + title: 'Item Position', + type: 'short-input', + placeholder: 'top, bottom, or a positive float', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + }, + { + id: 'itemChecked', + title: 'Start Checked', + type: 'dropdown', + options: [ + { label: 'Unchecked', id: '' }, + { label: 'Checked', id: 'true' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + }, + { + id: 'checkItemId', + title: 'Checklist Item ID', + type: 'short-input', + placeholder: 'Enter checklist item ID', + condition: { + field: 'operation', + value: 'trello_update_checklist_item', + }, + required: true, + }, + { + id: 'checkItemState', + title: 'State', + type: 'dropdown', + options: [ + { label: 'Leave Unchanged', id: '' }, + { label: 'Complete', id: 'complete' }, + { label: 'Incomplete', id: 'incomplete' }, + ], + value: () => '', + condition: { + field: 'operation', + value: 'trello_update_checklist_item', + }, + }, + { + id: 'checkItemName', + title: 'New Item Name', + type: 'short-input', + placeholder: 'Enter new checklist item name', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_checklist_item', + }, + }, { id: 'labelId', title: 'Label ID', @@ -460,7 +697,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, placeholder: 'Enter Trello label ID', condition: { field: 'operation', - value: 'trello_add_label', + value: ['trello_add_label', 'trello_remove_label'], }, required: true, }, @@ -471,26 +708,82 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, placeholder: 'Enter Trello member ID', condition: { field: 'operation', - value: 'trello_add_member', + value: ['trello_add_member', 'trello_remove_member'], }, required: true, }, + { + id: 'searchQuery', + title: 'Search Query', + type: 'long-input', + placeholder: 'Enter search text (supports Trello operators like board:, list:, due:)', + condition: { + field: 'operation', + value: 'trello_search', + }, + required: true, + }, + { + id: 'searchModelTypes', + title: 'Search Scope', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Cards Only', id: 'cards' }, + { label: 'Boards Only', id: 'boards' }, + ], + value: () => 'all', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, + { + id: 'searchBoardIds', + title: 'Restrict to Board IDs', + type: 'short-input', + placeholder: 'Comma-separated board IDs', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, + { + id: 'searchCardsLimit', + title: 'Card Result Limit', + type: 'short-input', + placeholder: 'Maximum number of cards to return (1-1000, default 10)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, ], tools: { access: [ 'trello_list_lists', 'trello_list_cards', + 'trello_search', 'trello_create_card', 'trello_update_card', + 'trello_delete_card', 'trello_get_actions', 'trello_add_comment', 'trello_create_board', 'trello_get_board', 'trello_create_list', + 'trello_update_list', 'trello_get_card', 'trello_add_checklist', + 'trello_add_checklist_item', + 'trello_update_checklist_item', 'trello_add_label', + 'trello_remove_label', 'trello_add_member', + 'trello_remove_member', + 'trello_list_members', ], config: { tool: (params) => getTrimmedString(params.operation) ?? 'trello_list_lists', @@ -511,6 +804,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, return { ...baseParams, boardId, + filter: getTrimmedString(params.listFilter), } } @@ -530,6 +824,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, ...baseParams, boardId, listId, + filter: getTrimmedString(params.cardFilter), + } + } + + case 'trello_search': { + const query = getTrimmedString(params.searchQuery) + + if (!query) { + throw new Error('Search query is required.') + } + + return { + ...baseParams, + query, + idBoards: parseStringArray(params.searchBoardIds), + modelTypes: getTrimmedString(params.searchModelTypes), + cardsLimit: parseOptionalNumberInput(params.searchCardsLimit, 'cardsLimit'), } } @@ -554,6 +865,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, due: getTrimmedString(params.due), dueComplete: parseOptionalBooleanInput(params.dueComplete), labelIds: parseStringArray(params.labelIds), + memberIds: parseStringArray(params.memberIds), } } @@ -576,6 +888,19 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_delete_card': { + const cardId = getTrimmedString(params.cardId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + return { + ...baseParams, + cardId, + } + } + case 'trello_get_actions': { const boardId = getTrimmedString(params.boardId) const cardId = getTrimmedString(params.cardId) @@ -595,6 +920,8 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, filter: getTrimmedString(params.filter), limit: parseOptionalNumberInput(params.limit, 'limit'), page: parseOptionalNumberInput(params.page, 'page'), + since: getTrimmedString(params.since), + before: getTrimmedString(params.before), } } @@ -666,6 +993,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_update_list': { + const listId = getTrimmedString(params.listId) + + if (!listId) { + throw new Error('List ID is required.') + } + + return { + ...baseParams, + listId, + name: getTrimmedString(params.listName), + closed: parseOptionalBooleanInput(params.listClosed), + idBoard: getTrimmedString(params.moveListToBoardId), + pos: getTrimmedString(params.listPos), + } + } + case 'trello_get_card': { const cardId = getTrimmedString(params.cardId) @@ -699,6 +1043,57 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_add_checklist_item': { + const checklistId = getTrimmedString(params.checklistId) + const name = getTrimmedString(params.itemName) + + if (!checklistId) { + throw new Error('Checklist ID is required.') + } + + if (!name) { + throw new Error('Item name is required.') + } + + return { + ...baseParams, + checklistId, + name, + pos: getTrimmedString(params.itemPos), + checked: parseOptionalBooleanInput(params.itemChecked), + } + } + + case 'trello_update_checklist_item': { + const cardId = getTrimmedString(params.cardId) + const checkItemId = getTrimmedString(params.checkItemId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!checkItemId) { + throw new Error('Checklist item ID is required.') + } + + const state = getTrimmedString(params.checkItemState) + const normalizedState = + state === 'complete' || state === 'incomplete' ? state : undefined + const name = getTrimmedString(params.checkItemName) + + if (!normalizedState && !name) { + throw new Error('Provide a State or a New Item Name to update.') + } + + return { + ...baseParams, + cardId, + checkItemId, + state: normalizedState, + name, + } + } + case 'trello_add_label': { const cardId = getTrimmedString(params.cardId) const labelId = getTrimmedString(params.labelId) @@ -718,6 +1113,25 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_remove_label': { + const cardId = getTrimmedString(params.cardId) + const labelId = getTrimmedString(params.labelId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!labelId) { + throw new Error('Label ID is required.') + } + + return { + ...baseParams, + cardId, + labelId, + } + } + case 'trello_add_member': { const cardId = getTrimmedString(params.cardId) const memberId = getTrimmedString(params.memberId) @@ -737,6 +1151,38 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_remove_member': { + const cardId = getTrimmedString(params.cardId) + const memberId = getTrimmedString(params.memberId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!memberId) { + throw new Error('Member ID is required.') + } + + return { + ...baseParams, + cardId, + memberId, + } + } + + case 'trello_list_members': { + const boardId = getTrimmedString(params.boardId) + + if (!boardId) { + throw new Error('Board ID is required.') + } + + return { + ...baseParams, + boardId, + } + } + default: return baseParams } @@ -758,11 +1204,17 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'json', description: 'Label IDs as an array or comma-separated string', }, + memberIds: { + type: 'json', + description: 'Member IDs as an array or comma-separated string, to assign on card creation', + }, closed: { type: 'boolean', description: 'Whether the card should be archived or reopened' }, idList: { type: 'string', description: 'List ID to move the card to' }, filter: { type: 'string', description: 'Trello action filter' }, limit: { type: 'number', description: 'Maximum number of board actions to return' }, page: { type: 'number', description: 'Page number for action results' }, + since: { type: 'string', description: 'Only return actions after this date or action ID' }, + before: { type: 'string', description: 'Only return actions before this date or action ID' }, text: { type: 'string', description: 'Comment text' }, boardName: { type: 'string', description: 'Board name' }, boardDesc: { type: 'string', description: 'Board description' }, @@ -776,13 +1228,34 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, }, listName: { type: 'string', description: 'List name' }, listPos: { type: 'string', description: 'List position (top, bottom, or positive float)' }, + listClosed: { type: 'boolean', description: 'Whether the list should be archived or reopened' }, + moveListToBoardId: { type: 'string', description: 'Board ID to move the list to' }, + listFilter: { type: 'string', description: 'Which lists to return: open, closed, or all' }, + cardFilter: { type: 'string', description: 'Which cards to return: open, closed, or all' }, checklistName: { type: 'string', description: 'Checklist name' }, checklistPos: { type: 'string', description: 'Checklist position (top, bottom, or positive float)', }, - labelId: { type: 'string', description: 'Label ID to attach to a card' }, - memberId: { type: 'string', description: 'Member ID to assign to a card' }, + checklistId: { type: 'string', description: 'Checklist ID to add an item to' }, + itemName: { type: 'string', description: 'Checklist item name' }, + itemPos: { + type: 'string', + description: 'Checklist item position (top, bottom, or positive float)', + }, + itemChecked: { type: 'boolean', description: 'Whether the checklist item starts checked' }, + checkItemId: { type: 'string', description: 'Checklist item ID to update' }, + checkItemState: { type: 'string', description: 'Checklist item state: complete or incomplete' }, + checkItemName: { type: 'string', description: 'New name for a checklist item' }, + labelId: { type: 'string', description: 'Label ID to attach to or remove from a card' }, + memberId: { type: 'string', description: 'Member ID to assign to or remove from a card' }, + searchQuery: { type: 'string', description: 'Trello search query text' }, + searchModelTypes: { type: 'string', description: 'Search scope: all, cards, or boards' }, + searchBoardIds: { + type: 'json', + description: 'Board IDs to restrict the search to, as an array or comma-separated string', + }, + searchCardsLimit: { type: 'number', description: 'Maximum number of cards to return' }, }, outputs: { lists: { @@ -811,6 +1284,10 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'json', description: 'Created checklist (id, name, idCard, idBoard, pos)', }, + item: { + type: 'json', + description: 'Created or updated checklist item (id, name, state, pos, idChecklist)', + }, labelIds: { type: 'json', description: 'Label IDs applied to a card after adding a label', @@ -819,6 +1296,14 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'json', description: 'Member IDs assigned to a card after adding a member', }, + members: { + type: 'json', + description: 'Board members (id, fullName, username)', + }, + boards: { + type: 'json', + description: 'Boards matching a search query (id, name, desc, url, closed, idOrganization)', + }, actions: { type: 'json', description: @@ -831,7 +1316,12 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, }, count: { type: 'number', - description: 'Number of returned lists, cards, or actions', + description: 'Number of returned lists, cards, boards, actions, or members', + }, + success: { + type: 'boolean', + description: + 'Whether a delete/remove operation succeeded (delete card, remove label, remove member)', }, error: { type: 'string', @@ -939,5 +1429,19 @@ export const TrelloBlockMeta = { content: '# Review Trello Card Activity\n\nInspect what has happened recently on a board or card to build a digest or audit.\n\n## Steps\n1. Use the Get Actions operation with either a Board ID or a Card ID (one or the other, not both).\n2. Set an Action Filter such as commentCard,updateCard,createCard to focus on the events you care about.\n3. Use Board Action Limit and Action Page to page through longer histories.\n\n## Output\nReturn the actions with their type, date, author, and text, summarized into a short activity recap.', }, + { + name: 'build-and-track-checklist', + description: + 'Add a checklist to a card, populate it with items, and check items off as work completes.', + content: + '# Build and Track a Trello Checklist\n\nGive a card a task list and keep it up to date as steps finish.\n\n## Steps\n1. Use Add Checklist on the target Card ID to create an empty checklist, and note the returned Checklist ID.\n2. Use Add Checklist Item once per task, providing the Checklist ID and Item Name.\n3. As each task completes, use Update Checklist Item with the Card ID and Checklist Item ID, setting State to Complete.\n\n## Output\nReturn the checklist and item IDs created, and confirm which items were marked complete.', + }, + { + name: 'find-and-clean-up-cards', + description: + 'Search Trello for cards matching a query, then delete or archive the ones that no longer belong.', + content: + '# Find and Clean Up Trello Cards\n\nLocate cards by keyword without already knowing their IDs, then remove the ones that should not remain.\n\n## Steps\n1. Use the Search operation with a Search Query (Trello operators like board:, list:, or due: are supported) and optionally restrict to specific Board IDs.\n2. Review the matching cards and decide which should be archived (Update Card with Archive Status) or permanently removed (Delete Card).\n3. Use Delete Card only for cards that should be gone for good — prefer archiving when the history should be kept.\n\n## Output\nReturn how many cards matched, and which were archived versus deleted.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/vercel.ts b/apps/sim/blocks/blocks/vercel.ts index 65e0513c63b..b754122b061 100644 --- a/apps/sim/blocks/blocks/vercel.ts +++ b/apps/sim/blocks/blocks/vercel.ts @@ -103,7 +103,7 @@ export const VercelBlock: BlockConfig = { }, { - id: 'projectId', + id: 'deploymentsProjectId', title: 'Project ID', type: 'short-input', placeholder: 'Filter by project ID or name (optional)', @@ -137,6 +137,38 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: 'list_deployments' }, mode: 'advanced', }, + { + id: 'deploymentsApp', + title: 'App Name', + type: 'short-input', + placeholder: 'Filter by deployment name (optional)', + condition: { field: 'operation', value: 'list_deployments' }, + mode: 'advanced', + }, + { + id: 'deploymentsSince', + title: 'Since', + type: 'short-input', + placeholder: 'Only deployments created after this timestamp, in ms (optional)', + condition: { field: 'operation', value: 'list_deployments' }, + mode: 'advanced', + }, + { + id: 'deploymentsUntil', + title: 'Until', + type: 'short-input', + placeholder: 'Only deployments created before this timestamp, in ms (optional)', + condition: { field: 'operation', value: 'list_deployments' }, + mode: 'advanced', + }, + { + id: 'deploymentsLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Maximum number of deployments to return (optional)', + condition: { field: 'operation', value: 'list_deployments' }, + mode: 'advanced', + }, { id: 'deploymentId', title: 'Deployment ID', @@ -165,6 +197,63 @@ export const VercelBlock: BlockConfig = { ], }, }, + { + id: 'withGitRepoInfo', + title: 'Include Git Repo Info', + type: 'dropdown', + options: [ + { label: 'No', id: '' }, + { label: 'Yes', id: 'true' }, + ], + condition: { field: 'operation', value: 'get_deployment' }, + mode: 'advanced', + }, + { + id: 'eventsDirection', + title: 'Direction', + type: 'dropdown', + options: [ + { label: 'Forward (default)', id: '' }, + { label: 'Backward', id: 'backward' }, + ], + condition: { field: 'operation', value: 'get_deployment_events' }, + mode: 'advanced', + }, + { + id: 'eventsFollow', + title: 'Follow Live Events', + type: 'dropdown', + options: [ + { label: 'No', id: '' }, + { label: 'Yes', id: '1' }, + ], + condition: { field: 'operation', value: 'get_deployment_events' }, + mode: 'advanced', + }, + { + id: 'eventsLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Maximum number of events to return, -1 for all (optional)', + condition: { field: 'operation', value: 'get_deployment_events' }, + mode: 'advanced', + }, + { + id: 'eventsSince', + title: 'Since', + type: 'short-input', + placeholder: 'Timestamp to start pulling build logs from (optional)', + condition: { field: 'operation', value: 'get_deployment_events' }, + mode: 'advanced', + }, + { + id: 'eventsUntil', + title: 'Until', + type: 'short-input', + placeholder: 'Timestamp to stop pulling build logs at (optional)', + condition: { field: 'operation', value: 'get_deployment_events' }, + mode: 'advanced', + }, { id: 'name', title: 'Project Name', @@ -201,6 +290,25 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: 'create_deployment' }, mode: 'advanced', }, + { + id: 'deploymentGitSource', + title: 'Git Source', + type: 'code', + placeholder: '{"type":"github","repo":"owner/repo","ref":"main"}', + condition: { field: 'operation', value: 'create_deployment' }, + mode: 'advanced', + }, + { + id: 'deploymentForceNew', + title: 'Force New Deployment', + type: 'dropdown', + options: [ + { label: 'No', id: '' }, + { label: 'Yes', id: '1' }, + ], + condition: { field: 'operation', value: 'create_deployment' }, + mode: 'advanced', + }, { id: 'search', @@ -210,6 +318,14 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: 'list_projects' }, mode: 'advanced', }, + { + id: 'projectsFrom', + title: 'From', + type: 'short-input', + placeholder: "Continuation token from the previous response's nextFrom output (optional)", + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, { id: 'projectId', title: 'Project ID', @@ -264,6 +380,14 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: 'create_project' }, required: { field: 'operation', value: 'create_project' }, }, + { + id: 'updateProjectName', + title: 'New Project Name', + type: 'short-input', + placeholder: 'Rename the project (optional — leave blank to keep)', + condition: { field: 'operation', value: 'update_project' }, + mode: 'advanced', + }, { id: 'framework', title: 'Framework', @@ -306,7 +430,39 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: ['create_project', 'update_project'] }, mode: 'advanced', }, + { + id: 'rootDirectory', + title: 'Root Directory', + type: 'short-input', + placeholder: 'Subdirectory of the repo to deploy from (optional)', + condition: { field: 'operation', value: ['create_project', 'update_project'] }, + mode: 'advanced', + }, + { + id: 'nodeVersion', + title: 'Node.js Version', + type: 'short-input', + placeholder: 'e.g. 22.x, 20.x, 18.x (optional)', + condition: { field: 'operation', value: ['create_project', 'update_project'] }, + mode: 'advanced', + }, + { + id: 'devCommand', + title: 'Dev Command', + type: 'short-input', + placeholder: 'Custom dev server command (optional)', + condition: { field: 'operation', value: ['create_project', 'update_project'] }, + mode: 'advanced', + }, + { + id: 'projectDomainsLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Maximum number of domains to return (optional)', + condition: { field: 'operation', value: 'list_project_domains' }, + mode: 'advanced', + }, { id: 'domainName', title: 'Domain', @@ -351,7 +507,7 @@ export const VercelBlock: BlockConfig = { title: 'Redirect To', type: 'short-input', placeholder: 'Target domain to redirect to (optional)', - condition: { field: 'operation', value: 'update_project_domain' }, + condition: { field: 'operation', value: ['update_project_domain', 'add_project_domain'] }, mode: 'advanced', }, { @@ -365,7 +521,7 @@ export const VercelBlock: BlockConfig = { { label: '307 (Temporary)', id: '307' }, { label: '308 (Permanent)', id: '308' }, ], - condition: { field: 'operation', value: 'update_project_domain' }, + condition: { field: 'operation', value: ['update_project_domain', 'add_project_domain'] }, mode: 'advanced', }, { @@ -373,7 +529,15 @@ export const VercelBlock: BlockConfig = { title: 'Git Branch', type: 'short-input', placeholder: 'Git branch to link the domain to (optional)', - condition: { field: 'operation', value: 'update_project_domain' }, + condition: { field: 'operation', value: ['update_project_domain', 'add_project_domain'] }, + mode: 'advanced', + }, + { + id: 'dnsRecordsLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Maximum number of records to return (optional)', + condition: { field: 'operation', value: 'list_dns_records' }, mode: 'advanced', }, @@ -422,6 +586,41 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: ['create_env_var', 'update_env_var'] }, mode: 'advanced', }, + { + id: 'envGitBranch', + title: 'Git Branch', + type: 'short-input', + placeholder: 'Git branch to associate with the variable (requires preview target)', + condition: { field: 'operation', value: ['create_env_var', 'update_env_var'] }, + mode: 'advanced', + }, + { + id: 'envComment', + title: 'Comment', + type: 'short-input', + placeholder: 'Context for this variable (max 500 chars)', + condition: { field: 'operation', value: ['create_env_var', 'update_env_var'] }, + mode: 'advanced', + }, + { + id: 'envVarsDecrypt', + title: 'Decrypt Values', + type: 'dropdown', + options: [ + { label: 'No', id: '' }, + { label: 'Yes', id: 'true' }, + ], + condition: { field: 'operation', value: 'get_env_vars' }, + mode: 'advanced', + }, + { + id: 'envVarsGitBranch', + title: 'Git Branch', + type: 'short-input', + placeholder: 'Filter by git branch (requires preview target)', + condition: { field: 'operation', value: 'get_env_vars' }, + mode: 'advanced', + }, { id: 'recordName', @@ -445,6 +644,7 @@ export const VercelBlock: BlockConfig = { { label: 'ALIAS', id: 'ALIAS' }, { label: 'SRV', id: 'SRV' }, { label: 'CAA', id: 'CAA' }, + { label: 'HTTPS', id: 'HTTPS' }, ], condition: { field: 'operation', value: 'create_dns_record' }, required: { field: 'operation', value: 'create_dns_record' }, @@ -454,8 +654,148 @@ export const VercelBlock: BlockConfig = { title: 'Value', type: 'short-input', placeholder: 'Record value (e.g., IP address)', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: ['SRV', 'HTTPS'], not: true }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: ['SRV', 'HTTPS'], not: true }, + }, + }, + { + id: 'recordMxPriority', + title: 'MX Priority', + type: 'short-input', + placeholder: 'Priority for the MX record', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'MX' }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'MX' }, + }, + }, + { + id: 'srvTarget', + title: 'SRV Target', + type: 'short-input', + placeholder: 'Target hostname for the SRV record', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + }, + { + id: 'srvWeight', + title: 'SRV Weight', + type: 'short-input', + placeholder: 'Weight for the SRV record', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + }, + { + id: 'srvPort', + title: 'SRV Port', + type: 'short-input', + placeholder: 'Port for the SRV record', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + }, + { + id: 'srvPriority', + title: 'SRV Priority', + type: 'short-input', + placeholder: 'Priority for the SRV record', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'SRV' }, + }, + }, + { + id: 'httpsTarget', + title: 'HTTPS Target', + type: 'short-input', + placeholder: 'Target hostname for the HTTPS record', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'HTTPS' }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'HTTPS' }, + }, + }, + { + id: 'httpsPriority', + title: 'HTTPS Priority', + type: 'short-input', + placeholder: 'Priority for the HTTPS record', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'HTTPS' }, + }, + required: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'HTTPS' }, + }, + }, + { + id: 'httpsParams', + title: 'HTTPS Params', + type: 'short-input', + placeholder: 'Optional service parameters (e.g., "alpn=h2,h3")', + condition: { + field: 'operation', + value: 'create_dns_record', + and: { field: 'recordType', value: 'HTTPS' }, + }, + mode: 'advanced', + }, + { + id: 'recordComment', + title: 'Comment', + type: 'short-input', + placeholder: 'Context for this DNS record (max 500 chars)', condition: { field: 'operation', value: 'create_dns_record' }, - required: { field: 'operation', value: 'create_dns_record' }, + mode: 'advanced', }, { id: 'recordId', @@ -495,8 +835,13 @@ export const VercelBlock: BlockConfig = { id: 'updateRecordValue', title: 'Value', type: 'short-input', - placeholder: 'New record value — leave blank to keep', - condition: { field: 'operation', value: 'update_dns_record' }, + placeholder: + 'New record value — leave blank to keep. Has no effect if the existing record is SRV or HTTPS; explicitly set Record Type to update those.', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: ['SRV', 'HTTPS'], not: true }, + }, }, { id: 'updateRecordTtl', @@ -514,6 +859,114 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: 'update_dns_record' }, mode: 'advanced', }, + { + id: 'updateSrvTarget', + title: 'SRV Target', + type: 'short-input', + placeholder: 'Target hostname for the SRV record', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + }, + { + id: 'updateSrvWeight', + title: 'SRV Weight', + type: 'short-input', + placeholder: 'Weight for the SRV record', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + }, + { + id: 'updateSrvPort', + title: 'SRV Port', + type: 'short-input', + placeholder: 'Port for the SRV record', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + }, + { + id: 'updateSrvPriority', + title: 'SRV Priority', + type: 'short-input', + placeholder: 'Priority for the SRV record', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + required: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'SRV' }, + }, + }, + { + id: 'updateHttpsTarget', + title: 'HTTPS Target', + type: 'short-input', + placeholder: 'Target hostname for the HTTPS record', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'HTTPS' }, + }, + required: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'HTTPS' }, + }, + }, + { + id: 'updateHttpsPriority', + title: 'HTTPS Priority', + type: 'short-input', + placeholder: 'Priority for the HTTPS record', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'HTTPS' }, + }, + required: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'HTTPS' }, + }, + }, + { + id: 'updateHttpsParams', + title: 'HTTPS Params', + type: 'short-input', + placeholder: 'Optional service parameters (e.g., "alpn=h2,h3")', + condition: { + field: 'operation', + value: 'update_dns_record', + and: { field: 'updateRecordType', value: 'HTTPS' }, + }, + mode: 'advanced', + }, { id: 'updateRecordComment', title: 'Comment', @@ -547,6 +1000,14 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: 'create_alias' }, required: { field: 'operation', value: 'create_alias' }, }, + { + id: 'aliasRedirect', + title: 'Redirect To', + type: 'short-input', + placeholder: 'Hostname to 307-redirect the alias to (optional)', + condition: { field: 'operation', value: 'create_alias' }, + mode: 'advanced', + }, { id: 'edgeConfigId', @@ -708,6 +1169,44 @@ export const VercelBlock: BlockConfig = { ], condition: { field: 'operation', value: 'update_check' }, }, + { + id: 'checkExternalId', + title: 'External ID', + type: 'short-input', + placeholder: 'External identifier for the check (optional)', + condition: { field: 'operation', value: ['create_check', 'update_check'] }, + mode: 'advanced', + }, + { + id: 'checkRerequestable', + title: 'Rerequestable', + type: 'dropdown', + options: [ + { label: 'No', id: '' }, + { label: 'Yes', id: 'true' }, + ], + condition: { field: 'operation', value: 'create_check' }, + mode: 'advanced', + }, + { + id: 'checkOutput', + title: 'Output', + type: 'code', + placeholder: '{"metrics":{"FCP":{"value":1200,"source":"web-vitals"}}}', + condition: { field: 'operation', value: 'update_check' }, + mode: 'advanced', + }, + { + id: 'checkAutoUpdate', + title: 'Auto Update', + type: 'dropdown', + options: [ + { label: 'No', id: '' }, + { label: 'Yes', id: 'true' }, + ], + condition: { field: 'operation', value: 'rerequest_check' }, + mode: 'advanced', + }, { id: 'teamIdParam', @@ -732,25 +1231,126 @@ export const VercelBlock: BlockConfig = { condition: { field: 'operation', value: 'list_team_members' }, mode: 'advanced', }, + { + id: 'teamMembersLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Maximum number of members to return (optional)', + condition: { field: 'operation', value: 'list_team_members' }, + mode: 'advanced', + }, + { + id: 'teamMembersSince', + title: 'Since', + type: 'short-input', + placeholder: 'Only members added since this timestamp, in ms (optional)', + condition: { field: 'operation', value: 'list_team_members' }, + mode: 'advanced', + }, + { + id: 'teamMembersUntil', + title: 'Until', + type: 'short-input', + placeholder: 'Only members added until this timestamp, in ms (optional)', + condition: { field: 'operation', value: 'list_team_members' }, + mode: 'advanced', + }, + { + id: 'teamMembersSearch', + title: 'Search', + type: 'short-input', + placeholder: 'Search members by name, username, or email (optional)', + condition: { field: 'operation', value: 'list_team_members' }, + mode: 'advanced', + }, + { + id: 'teamsLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Maximum number of teams to return (optional)', + condition: { field: 'operation', value: 'list_teams' }, + mode: 'advanced', + }, + { + id: 'teamsSince', + title: 'Since', + type: 'short-input', + placeholder: 'Only teams created since this timestamp, in ms (optional)', + condition: { field: 'operation', value: 'list_teams' }, + mode: 'advanced', + }, + { + id: 'teamsUntil', + title: 'Until', + type: 'short-input', + placeholder: 'Only teams created until this timestamp, in ms (optional)', + condition: { field: 'operation', value: 'list_teams' }, + mode: 'advanced', + }, { id: 'teamId', title: 'Team ID (Scope)', type: 'short-input', placeholder: 'Team ID to scope request (optional)', + condition: { + field: 'operation', + value: ['get_team', 'list_team_members', 'get_user', 'list_teams'], + not: true, + }, + mode: 'advanced', + }, + { + id: 'teamSlug', + title: 'Team Slug (Scope)', + type: 'short-input', + placeholder: 'Team slug to scope request, alternative to Team ID (optional)', condition: { field: 'operation', value: [ - 'get_team', - 'list_team_members', - 'get_user', + 'add_project_domain', + 'create_env_var', + 'create_project', + 'delete_env_var', + 'delete_project', + 'get_env_vars', + 'get_project', + 'list_project_domains', + 'list_projects', + 'pause_project', + 'remove_project_domain', + 'unpause_project', + 'update_env_var', + 'update_project_domain', + 'update_project', + 'verify_project_domain', + 'create_deployment', + 'get_deployment', + 'list_deployments', + 'cancel_deployment', + 'delete_deployment', + 'promote_deployment', + 'get_deployment_events', + 'list_deployment_files', 'create_check', 'get_check', 'list_checks', 'update_check', 'rerequest_check', + 'list_domains', + 'get_domain', + 'add_domain', + 'delete_domain', + 'get_domain_config', + 'list_dns_records', + 'create_dns_record', + 'update_dns_record', + 'delete_dns_record', + 'list_aliases', + 'get_alias', + 'create_alias', + 'delete_alias', ], - not: true, }, mode: 'advanced', }, @@ -829,23 +1429,61 @@ export const VercelBlock: BlockConfig = { const { apiKey, operation, + deploymentsProjectId, + deploymentsApp, + deploymentsSince, + deploymentsUntil, + deploymentsLimit, redeployId, deployTarget, + deploymentGitSource, + deploymentForceNew, + withGitRepoInfo, + eventsDirection, + eventsFollow, + eventsLimit, + eventsSince, + eventsUntil, projectName, + updateProjectName, domainName, + dnsRecordsLimit, + projectDomainsLimit, envKey, envValue, envTarget, envType, + envGitBranch, + envComment, + envVarsDecrypt, + envVarsGitBranch, + projectsFrom, + teamSlug, recordName, recordType, recordValue, recordId, + recordMxPriority, + srvTarget, + srvWeight, + srvPort, + srvPriority, + httpsTarget, + httpsPriority, + httpsParams, + recordComment, updateRecordName, updateRecordType, updateRecordValue, updateRecordTtl, updateRecordMxPriority, + updateSrvTarget, + updateSrvWeight, + updateSrvPort, + updateSrvPriority, + updateHttpsTarget, + updateHttpsPriority, + updateHttpsParams, updateRecordComment, updateDomainRedirect, updateDomainRedirectStatusCode, @@ -853,6 +1491,7 @@ export const VercelBlock: BlockConfig = { aliasId, aliasDeploymentId, aliasName, + aliasRedirect, edgeConfigId, edgeConfigSlug, edgeConfigItems, @@ -868,25 +1507,69 @@ export const VercelBlock: BlockConfig = { checkDetailsUrl, checkStatus, checkConclusion, + checkExternalId, + checkRerequestable, + checkOutput, + checkAutoUpdate, teamIdParam, memberRole, + teamMembersLimit, + teamMembersSince, + teamMembersUntil, + teamMembersSearch, + teamsLimit, + teamsSince, + teamsUntil, ...rest } = params - const base = { ...rest, apiKey } + const base = { ...rest, apiKey, ...(teamSlug ? { slug: teamSlug } : {}) } switch (operation) { + case 'list_deployments': + return { + ...base, + projectId: deploymentsProjectId || undefined, + ...(deploymentsApp ? { app: deploymentsApp } : {}), + ...(deploymentsSince ? { since: Number(deploymentsSince) } : {}), + ...(deploymentsUntil ? { until: Number(deploymentsUntil) } : {}), + ...(deploymentsLimit ? { limit: Number(deploymentsLimit) } : {}), + } + case 'get_deployment': + return { ...base, ...(withGitRepoInfo ? { withGitRepoInfo } : {}) } + case 'get_deployment_events': + return { + ...base, + ...(eventsDirection ? { direction: eventsDirection } : {}), + ...(eventsFollow ? { follow: Number(eventsFollow) } : {}), + ...(eventsLimit ? { limit: Number(eventsLimit) } : {}), + ...(eventsSince ? { since: Number(eventsSince) } : {}), + ...(eventsUntil ? { until: Number(eventsUntil) } : {}), + } case 'create_deployment': return { ...base, - ...(redeployId ? { deploymentId: redeployId } : {}), - ...(deployTarget ? { target: deployTarget } : {}), + deploymentId: redeployId || undefined, + target: deployTarget || undefined, + ...(deploymentGitSource ? { gitSource: deploymentGitSource } : {}), + ...(deploymentForceNew ? { forceNew: deploymentForceNew } : {}), } case 'create_project': return { ...base, name: projectName } case 'update_project': - return base + return { ...base, name: updateProjectName || undefined } + case 'list_projects': + return { ...base, ...(projectsFrom ? { from: projectsFrom } : {}) } case 'add_project_domain': + return { + ...base, + domain: domainName, + ...(updateDomainRedirect ? { redirect: updateDomainRedirect } : {}), + ...(updateDomainRedirectStatusCode + ? { redirectStatusCode: Number(updateDomainRedirectStatusCode) } + : {}), + ...(updateDomainGitBranch ? { gitBranch: updateDomainGitBranch } : {}), + } case 'remove_project_domain': case 'verify_project_domain': return { ...base, domain: domainName } @@ -896,7 +1579,7 @@ export const VercelBlock: BlockConfig = { domain: domainName, ...(updateDomainRedirect ? { redirect: updateDomainRedirect } : {}), ...(updateDomainRedirectStatusCode - ? { redirectStatusCode: updateDomainRedirectStatusCode } + ? { redirectStatusCode: Number(updateDomainRedirectStatusCode) } : {}), ...(updateDomainGitBranch ? { gitBranch: updateDomainGitBranch } : {}), } @@ -907,37 +1590,105 @@ export const VercelBlock: BlockConfig = { case 'add_domain': return { ...base, name: domainName } case 'list_dns_records': - return { ...base, domain: domainName } + return { + ...base, + domain: domainName, + ...(dnsRecordsLimit ? { limit: Number(dnsRecordsLimit) } : {}), + } + case 'list_project_domains': + return { + ...base, + ...(projectDomainsLimit ? { limit: Number(projectDomainsLimit) } : {}), + } case 'create_dns_record': - return { ...base, domain: domainName, recordName, recordType, value: recordValue } + return { + ...base, + domain: domainName, + recordName, + recordType, + ...(recordValue ? { value: recordValue } : {}), + ...(recordMxPriority !== '' && recordMxPriority != null + ? { mxPriority: Number(recordMxPriority) } + : {}), + ...(srvTarget ? { srvTarget } : {}), + ...(srvWeight !== '' && srvWeight != null ? { srvWeight: Number(srvWeight) } : {}), + ...(srvPort !== '' && srvPort != null ? { srvPort: Number(srvPort) } : {}), + ...(srvPriority !== '' && srvPriority != null + ? { srvPriority: Number(srvPriority) } + : {}), + ...(httpsTarget ? { httpsTarget } : {}), + ...(httpsPriority !== '' && httpsPriority != null + ? { httpsPriority: Number(httpsPriority) } + : {}), + ...(httpsParams ? { httpsParams } : {}), + ...(recordComment ? { comment: recordComment } : {}), + } case 'delete_dns_record': return { ...base, domain: domainName, recordId } case 'update_dns_record': return { ...base, recordId, - ...(updateRecordName ? { name: updateRecordName } : {}), + name: updateRecordName || undefined, ...(updateRecordType ? { type: updateRecordType } : {}), ...(updateRecordValue ? { value: updateRecordValue } : {}), ...(updateRecordTtl ? { ttl: updateRecordTtl } : {}), - ...(updateRecordMxPriority ? { mxPriority: updateRecordMxPriority } : {}), + ...(updateRecordMxPriority !== '' && updateRecordMxPriority != null + ? { mxPriority: updateRecordMxPriority } + : {}), + ...(updateSrvTarget ? { srvTarget: updateSrvTarget } : {}), + ...(updateSrvWeight !== '' && updateSrvWeight != null + ? { srvWeight: Number(updateSrvWeight) } + : {}), + ...(updateSrvPort !== '' && updateSrvPort != null + ? { srvPort: Number(updateSrvPort) } + : {}), + ...(updateSrvPriority !== '' && updateSrvPriority != null + ? { srvPriority: Number(updateSrvPriority) } + : {}), + ...(updateHttpsTarget ? { httpsTarget: updateHttpsTarget } : {}), + ...(updateHttpsPriority !== '' && updateHttpsPriority != null + ? { httpsPriority: Number(updateHttpsPriority) } + : {}), + ...(updateHttpsParams ? { httpsParams: updateHttpsParams } : {}), ...(updateRecordComment ? { comment: updateRecordComment } : {}), } + case 'get_env_vars': + return { + ...base, + ...(envVarsDecrypt ? { decrypt: envVarsDecrypt === 'true' } : {}), + ...(envVarsGitBranch ? { gitBranch: envVarsGitBranch } : {}), + } case 'create_env_var': - return { ...base, key: envKey, value: envValue, target: envTarget, type: envType } + return { + ...base, + key: envKey, + value: envValue, + target: envTarget, + type: envType, + ...(envGitBranch ? { gitBranch: envGitBranch } : {}), + ...(envComment ? { comment: envComment } : {}), + } case 'update_env_var': return { ...base, ...(envKey ? { key: envKey } : {}), ...(envValue ? { value: envValue } : {}), - ...(envTarget ? { target: envTarget } : {}), + target: envTarget || undefined, ...(envType ? { type: envType } : {}), + ...(envGitBranch ? { gitBranch: envGitBranch } : {}), + ...(envComment ? { comment: envComment } : {}), } case 'get_alias': case 'delete_alias': return { ...base, aliasId } case 'create_alias': - return { ...base, deploymentId: aliasDeploymentId, alias: aliasName } + return { + ...base, + deploymentId: aliasDeploymentId, + alias: aliasName, + ...(aliasRedirect ? { redirect: aliasRedirect } : {}), + } case 'get_edge_config': case 'get_edge_config_items': case 'delete_edge_config': @@ -964,10 +1715,18 @@ export const VercelBlock: BlockConfig = { blocking: checkBlocking === 'true', ...(checkPath ? { path: checkPath } : {}), ...(checkDetailsUrl ? { detailsUrl: checkDetailsUrl } : {}), + ...(checkExternalId ? { externalId: checkExternalId } : {}), + ...(checkRerequestable ? { rerequestable: checkRerequestable === 'true' } : {}), } case 'get_check': - case 'rerequest_check': return { ...base, deploymentId: checkDeploymentId, checkId } + case 'rerequest_check': + return { + ...base, + deploymentId: checkDeploymentId, + checkId, + ...(checkAutoUpdate ? { autoUpdate: checkAutoUpdate === 'true' } : {}), + } case 'list_checks': return { ...base, deploymentId: checkDeploymentId } case 'update_check': @@ -975,16 +1734,33 @@ export const VercelBlock: BlockConfig = { ...base, deploymentId: checkDeploymentId, checkId, - ...(checkName ? { name: checkName } : {}), + name: checkName || undefined, ...(checkStatus ? { status: checkStatus } : {}), ...(checkConclusion ? { conclusion: checkConclusion } : {}), ...(checkPath ? { path: checkPath } : {}), ...(checkDetailsUrl ? { detailsUrl: checkDetailsUrl } : {}), + ...(checkExternalId ? { externalId: checkExternalId } : {}), + ...(checkOutput ? { output: checkOutput } : {}), } case 'get_team': return { ...base, teamId: teamIdParam } case 'list_team_members': - return { ...base, teamId: teamIdParam, ...(memberRole ? { role: memberRole } : {}) } + return { + ...base, + teamId: teamIdParam, + ...(memberRole ? { role: memberRole } : {}), + ...(teamMembersLimit ? { limit: Number(teamMembersLimit) } : {}), + ...(teamMembersSince ? { since: Number(teamMembersSince) } : {}), + ...(teamMembersUntil ? { until: Number(teamMembersUntil) } : {}), + search: teamMembersSearch || undefined, + } + case 'list_teams': + return { + ...base, + ...(teamsLimit ? { limit: Number(teamsLimit) } : {}), + ...(teamsSince ? { since: Number(teamsSince) } : {}), + ...(teamsUntil ? { until: Number(teamsUntil) } : {}), + } default: return base } @@ -995,34 +1771,83 @@ export const VercelBlock: BlockConfig = { operation: { type: 'string', description: 'Operation to perform' }, apiKey: { type: 'string', description: 'Vercel access token' }, projectId: { type: 'string', description: 'Project ID or name' }, + deploymentsProjectId: { + type: 'string', + description: 'Filter deployments by project ID or name', + }, deploymentId: { type: 'string', description: 'Deployment ID or hostname' }, name: { type: 'string', description: 'Project name' }, projectName: { type: 'string', description: 'New project name' }, + updateProjectName: { type: 'string', description: 'Renamed project name for update_project' }, project: { type: 'string', description: 'Project ID override' }, redeployId: { type: 'string', description: 'Deployment ID to redeploy' }, target: { type: 'string', description: 'Target environment filter' }, deployTarget: { type: 'string', description: 'Deployment target environment' }, + deploymentGitSource: { type: 'string', description: 'JSON git source for the deployment' }, + deploymentForceNew: { type: 'string', description: 'Whether to force a new deployment' }, + withGitRepoInfo: { type: 'string', description: 'Whether to include git repo info' }, + eventsDirection: { type: 'string', description: 'Order of deployment events' }, + eventsFollow: { type: 'string', description: 'Whether to follow live deployment events' }, + eventsLimit: { type: 'string', description: 'Maximum number of deployment events to return' }, + eventsSince: { type: 'string', description: 'Only events after this timestamp' }, + eventsUntil: { type: 'string', description: 'Only events before this timestamp' }, state: { type: 'string', description: 'Deployment state filter' }, search: { type: 'string', description: 'Project search query' }, + projectsFrom: { type: 'string', description: 'Pagination continuation token' }, + deploymentsApp: { type: 'string', description: 'Filter deployments by deployment name' }, + deploymentsSince: { type: 'string', description: 'Only deployments after this timestamp' }, + deploymentsUntil: { type: 'string', description: 'Only deployments before this timestamp' }, + deploymentsLimit: { type: 'string', description: 'Maximum number of deployments to return' }, framework: { type: 'string', description: 'Project framework' }, buildCommand: { type: 'string', description: 'Build command' }, outputDirectory: { type: 'string', description: 'Output directory' }, installCommand: { type: 'string', description: 'Install command' }, + rootDirectory: { type: 'string', description: 'Root directory of the project' }, + nodeVersion: { type: 'string', description: 'Node.js version' }, + devCommand: { type: 'string', description: 'Dev command' }, domainName: { type: 'string', description: 'Domain name' }, + dnsRecordsLimit: { type: 'string', description: 'Maximum number of DNS records to return' }, + projectDomainsLimit: { + type: 'string', + description: 'Maximum number of project domains to return', + }, envId: { type: 'string', description: 'Environment variable ID' }, envKey: { type: 'string', description: 'Environment variable key' }, envValue: { type: 'string', description: 'Environment variable value' }, envTarget: { type: 'string', description: 'Target environments' }, envType: { type: 'string', description: 'Variable type' }, + envGitBranch: { type: 'string', description: 'Git branch for the environment variable' }, + envComment: { type: 'string', description: 'Comment for the environment variable' }, + envVarsDecrypt: { type: 'string', description: 'Whether to return decrypted values' }, + envVarsGitBranch: { type: 'string', description: 'Filter environment variables by git branch' }, recordName: { type: 'string', description: 'DNS record name' }, recordType: { type: 'string', description: 'DNS record type' }, recordValue: { type: 'string', description: 'DNS record value' }, recordId: { type: 'string', description: 'DNS record ID' }, + recordMxPriority: { type: 'string', description: 'Priority for MX records' }, + srvTarget: { type: 'string', description: 'Target hostname for SRV records' }, + srvWeight: { type: 'string', description: 'Weight for SRV records' }, + srvPort: { type: 'string', description: 'Port for SRV records' }, + srvPriority: { type: 'string', description: 'Priority for SRV records' }, + httpsTarget: { type: 'string', description: 'Target hostname for HTTPS records' }, + httpsPriority: { type: 'string', description: 'Priority for HTTPS records' }, + httpsParams: { type: 'string', description: 'Optional service parameters for HTTPS records' }, + recordComment: { type: 'string', description: 'Comment for the new DNS record' }, updateRecordName: { type: 'string', description: 'Updated DNS record name' }, updateRecordType: { type: 'string', description: 'Updated DNS record type' }, updateRecordValue: { type: 'string', description: 'Updated DNS record value' }, updateRecordTtl: { type: 'string', description: 'Updated DNS record TTL' }, updateRecordMxPriority: { type: 'string', description: 'Updated MX record priority' }, + updateSrvTarget: { type: 'string', description: 'Updated target hostname for SRV records' }, + updateSrvWeight: { type: 'string', description: 'Updated weight for SRV records' }, + updateSrvPort: { type: 'string', description: 'Updated port for SRV records' }, + updateSrvPriority: { type: 'string', description: 'Updated priority for SRV records' }, + updateHttpsTarget: { type: 'string', description: 'Updated target hostname for HTTPS records' }, + updateHttpsPriority: { type: 'string', description: 'Updated priority for HTTPS records' }, + updateHttpsParams: { + type: 'string', + description: 'Updated service parameters for HTTPS records', + }, updateRecordComment: { type: 'string', description: 'Updated DNS record comment' }, updateDomainRedirect: { type: 'string', description: 'Project domain redirect target' }, updateDomainRedirectStatusCode: { @@ -1033,12 +1858,24 @@ export const VercelBlock: BlockConfig = { aliasId: { type: 'string', description: 'Alias ID' }, aliasDeploymentId: { type: 'string', description: 'Deployment ID for alias' }, aliasName: { type: 'string', description: 'Alias domain' }, + aliasRedirect: { type: 'string', description: 'Hostname to 307-redirect the alias to' }, edgeConfigId: { type: 'string', description: 'Edge Config ID' }, edgeConfigSlug: { type: 'string', description: 'Edge Config slug' }, edgeConfigItems: { type: 'string', description: 'Edge Config items JSON' }, teamId: { type: 'string', description: 'Team ID for scoping' }, + teamSlug: { type: 'string', description: 'Team slug for scoping (alternative to Team ID)' }, teamIdParam: { type: 'string', description: 'Team ID parameter' }, memberRole: { type: 'string', description: 'Team member role filter' }, + teamMembersLimit: { type: 'string', description: 'Maximum number of team members to return' }, + teamMembersSince: { type: 'string', description: 'Only members added since this timestamp' }, + teamMembersUntil: { type: 'string', description: 'Only members added until this timestamp' }, + teamMembersSearch: { + type: 'string', + description: 'Search team members by name, username, or email', + }, + teamsLimit: { type: 'string', description: 'Maximum number of teams to return' }, + teamsSince: { type: 'string', description: 'Only teams created since this timestamp' }, + teamsUntil: { type: 'string', description: 'Only teams created until this timestamp' }, webhookId: { type: 'string', description: 'Webhook ID' }, webhookUrl: { type: 'string', description: 'Webhook URL' }, webhookEvents: { type: 'string', description: 'Comma-separated event names' }, @@ -1051,6 +1888,13 @@ export const VercelBlock: BlockConfig = { checkDetailsUrl: { type: 'string', description: 'URL for check details' }, checkStatus: { type: 'string', description: 'Check status' }, checkConclusion: { type: 'string', description: 'Check conclusion' }, + checkExternalId: { type: 'string', description: 'External identifier for the check' }, + checkRerequestable: { type: 'string', description: 'Whether the check can be rerequested' }, + checkOutput: { type: 'string', description: 'JSON check output metrics' }, + checkAutoUpdate: { + type: 'string', + description: 'Whether to mark the check as running immediately on rerequest', + }, }, outputs: { deployments: { @@ -1189,6 +2033,11 @@ export const VercelBlock: BlockConfig = { type: 'boolean', description: 'Whether more results are available', }, + nextFrom: { + type: 'string', + description: 'Continuation token to pass as From to fetch the next page of projects', + condition: { field: 'operation', value: 'list_projects' }, + }, }, } diff --git a/apps/sim/blocks/blocks/wordpress.ts b/apps/sim/blocks/blocks/wordpress.ts index b451b6a1a41..cb38487859e 100644 --- a/apps/sim/blocks/blocks/wordpress.ts +++ b/apps/sim/blocks/blocks/wordpress.ts @@ -11,7 +11,7 @@ export const WordPressBlock: BlockConfig = { description: 'Manage WordPress content', authMode: AuthMode.OAuth, longDescription: - 'Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication.', + 'Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth.', docsLink: 'https://docs.sim.ai/integrations/wordpress', category: 'tools', integrationType: IntegrationType.Marketing, @@ -49,9 +49,15 @@ export const WordPressBlock: BlockConfig = { { label: 'Delete Comment', id: 'wordpress_delete_comment' }, // Categories { label: 'Create Category', id: 'wordpress_create_category' }, + { label: 'Update Category', id: 'wordpress_update_category' }, + { label: 'Delete Category', id: 'wordpress_delete_category' }, + { label: 'Get Category', id: 'wordpress_get_category' }, { label: 'List Categories', id: 'wordpress_list_categories' }, // Tags { label: 'Create Tag', id: 'wordpress_create_tag' }, + { label: 'Update Tag', id: 'wordpress_update_tag' }, + { label: 'Delete Tag', id: 'wordpress_delete_tag' }, + { label: 'Get Tag', id: 'wordpress_get_tag' }, { label: 'List Tags', id: 'wordpress_list_tags' }, // Users { label: 'Get Current User', id: 'wordpress_get_current_user' }, @@ -208,7 +214,7 @@ export const WordPressBlock: BlockConfig = { }, }, - // Categories (for posts only) + // Categories (for posts) { id: 'categories', title: 'Categories', @@ -217,11 +223,11 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['wordpress_create_post', 'wordpress_update_post'], + value: ['wordpress_create_post', 'wordpress_update_post', 'wordpress_list_posts'], }, }, - // Tags (for posts only) + // Tags (for posts) { id: 'tags', title: 'Tags', @@ -230,10 +236,20 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['wordpress_create_post', 'wordpress_update_post'], + value: ['wordpress_create_post', 'wordpress_update_post', 'wordpress_list_posts'], }, }, + // List Posts: Author filter + { + id: 'listAuthor', + title: 'Author ID', + type: 'short-input', + placeholder: 'Filter by author ID', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_list_posts' }, + }, + // Featured Media ID { id: 'featuredMedia', @@ -277,7 +293,7 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['wordpress_create_page', 'wordpress_update_page'], + value: ['wordpress_create_page', 'wordpress_update_page', 'wordpress_list_pages'], }, }, @@ -349,6 +365,14 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', value: 'wordpress_upload_media' }, }, + { + id: 'mediaDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Media description', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_upload_media' }, + }, { id: 'mediaId', title: 'Media ID', @@ -385,7 +409,10 @@ export const WordPressBlock: BlockConfig = { title: 'Post ID', type: 'short-input', placeholder: 'Post ID to comment on', - condition: { field: 'operation', value: 'wordpress_create_comment' }, + condition: { + field: 'operation', + value: ['wordpress_create_comment', 'wordpress_list_comments'], + }, required: { field: 'operation', value: 'wordpress_create_comment' }, }, { @@ -399,6 +426,38 @@ export const WordPressBlock: BlockConfig = { }, required: { field: 'operation', value: 'wordpress_create_comment' }, }, + { + id: 'commentParent', + title: 'Parent Comment ID', + type: 'short-input', + placeholder: 'Parent comment ID (for replies)', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, + { + id: 'commentAuthorName', + title: 'Author Name', + type: 'short-input', + placeholder: 'Comment author display name', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, + { + id: 'commentAuthorEmail', + title: 'Author Email', + type: 'short-input', + placeholder: 'Comment author email', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, + { + id: 'commentAuthorUrl', + title: 'Author URL', + type: 'short-input', + placeholder: 'Comment author URL', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, { id: 'commentId', title: 'Comment ID', @@ -429,12 +488,29 @@ export const WordPressBlock: BlockConfig = { }, // Category Operations + { + id: 'categoryId', + title: 'Category ID', + type: 'short-input', + placeholder: 'Enter category ID', + condition: { + field: 'operation', + value: ['wordpress_get_category', 'wordpress_update_category', 'wordpress_delete_category'], + }, + required: { + field: 'operation', + value: ['wordpress_get_category', 'wordpress_update_category', 'wordpress_delete_category'], + }, + }, { id: 'categoryName', title: 'Category Name', type: 'short-input', placeholder: 'Category name', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, required: { field: 'operation', value: 'wordpress_create_category' }, }, { @@ -443,7 +519,10 @@ export const WordPressBlock: BlockConfig = { type: 'long-input', placeholder: 'Category description', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, }, { id: 'categoryParent', @@ -451,7 +530,10 @@ export const WordPressBlock: BlockConfig = { type: 'short-input', placeholder: 'Parent category ID', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, }, { id: 'categorySlug', @@ -459,16 +541,36 @@ export const WordPressBlock: BlockConfig = { type: 'short-input', placeholder: 'URL slug (optional)', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, }, // Tag Operations + { + id: 'tagId', + title: 'Tag ID', + type: 'short-input', + placeholder: 'Enter tag ID', + condition: { + field: 'operation', + value: ['wordpress_get_tag', 'wordpress_update_tag', 'wordpress_delete_tag'], + }, + required: { + field: 'operation', + value: ['wordpress_get_tag', 'wordpress_update_tag', 'wordpress_delete_tag'], + }, + }, { id: 'tagName', title: 'Tag Name', type: 'short-input', placeholder: 'Tag name', - condition: { field: 'operation', value: 'wordpress_create_tag' }, + condition: { + field: 'operation', + value: ['wordpress_create_tag', 'wordpress_update_tag'], + }, required: { field: 'operation', value: 'wordpress_create_tag' }, }, { @@ -477,7 +579,10 @@ export const WordPressBlock: BlockConfig = { type: 'long-input', placeholder: 'Tag description', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_tag' }, + condition: { + field: 'operation', + value: ['wordpress_create_tag', 'wordpress_update_tag'], + }, }, { id: 'tagSlug', @@ -485,7 +590,10 @@ export const WordPressBlock: BlockConfig = { type: 'short-input', placeholder: 'URL slug (optional)', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_tag' }, + condition: { + field: 'operation', + value: ['wordpress_create_tag', 'wordpress_update_tag'], + }, }, // User Operations @@ -523,7 +631,6 @@ export const WordPressBlock: BlockConfig = { { label: 'All Types', id: '' }, { label: 'Post', id: 'post' }, { label: 'Page', id: 'page' }, - { label: 'Attachment', id: 'attachment' }, ], value: () => '', mode: 'advanced', @@ -665,12 +772,7 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: [ - 'wordpress_delete_post', - 'wordpress_delete_page', - 'wordpress_delete_media', - 'wordpress_delete_comment', - ], + value: ['wordpress_delete_post', 'wordpress_delete_page', 'wordpress_delete_comment'], }, }, ], @@ -696,8 +798,14 @@ export const WordPressBlock: BlockConfig = { 'wordpress_delete_comment', 'wordpress_create_category', 'wordpress_list_categories', + 'wordpress_get_category', + 'wordpress_update_category', + 'wordpress_delete_category', 'wordpress_create_tag', 'wordpress_list_tags', + 'wordpress_get_tag', + 'wordpress_update_tag', + 'wordpress_delete_tag', 'wordpress_get_current_user', 'wordpress_list_users', 'wordpress_get_user', @@ -723,7 +831,10 @@ export const WordPressBlock: BlockConfig = { slug: params.slug, categories: params.categories, tags: params.tags, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_update_post': return { @@ -736,7 +847,10 @@ export const WordPressBlock: BlockConfig = { slug: params.slug, categories: params.categories, tags: params.tags, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_delete_post': return { @@ -760,6 +874,10 @@ export const WordPressBlock: BlockConfig = { order: params.order, categories: params.categories, tags: params.tags, + author: + params.listAuthor !== undefined && params.listAuthor !== '' + ? Number(params.listAuthor) + : undefined, } case 'wordpress_create_page': return { @@ -769,9 +887,18 @@ export const WordPressBlock: BlockConfig = { status: params.status, excerpt: params.excerpt, slug: params.slug, - parent: params.parent ? Number(params.parent) : undefined, - menuOrder: params.menuOrder ? Number(params.menuOrder) : undefined, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + parent: + params.parent !== undefined && params.parent !== '' + ? Number(params.parent) + : undefined, + menuOrder: + params.menuOrder !== undefined && params.menuOrder !== '' + ? Number(params.menuOrder) + : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_update_page': return { @@ -782,9 +909,18 @@ export const WordPressBlock: BlockConfig = { status: params.status, excerpt: params.excerpt, slug: params.slug, - parent: params.parent ? Number(params.parent) : undefined, - menuOrder: params.menuOrder ? Number(params.menuOrder) : undefined, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + parent: + params.parent !== undefined && params.parent !== '' + ? Number(params.parent) + : undefined, + menuOrder: + params.menuOrder !== undefined && params.menuOrder !== '' + ? Number(params.menuOrder) + : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_delete_page': return { @@ -806,7 +942,10 @@ export const WordPressBlock: BlockConfig = { search: params.search, orderBy: params.orderBy, order: params.order, - parent: params.parent ? Number(params.parent) : undefined, + parent: + params.parent !== undefined && params.parent !== '' + ? Number(params.parent) + : undefined, } case 'wordpress_upload_media': // file is the canonical param for both basic (fileUpload) and advanced modes @@ -817,6 +956,7 @@ export const WordPressBlock: BlockConfig = { title: params.mediaTitle, caption: params.caption, altText: params.altText, + description: params.mediaDescription || undefined, } case 'wordpress_get_media': return { @@ -837,13 +977,19 @@ export const WordPressBlock: BlockConfig = { return { ...baseParams, mediaId: Number(params.mediaId), - force: params.force, } case 'wordpress_create_comment': return { ...baseParams, postId: Number(params.commentPostId), content: params.commentContent, + parent: + params.commentParent !== undefined && params.commentParent !== '' + ? Number(params.commentParent) + : undefined, + authorName: params.commentAuthorName || undefined, + authorEmail: params.commentAuthorEmail || undefined, + authorUrl: params.commentAuthorUrl || undefined, } case 'wordpress_list_comments': return { @@ -873,7 +1019,10 @@ export const WordPressBlock: BlockConfig = { ...baseParams, name: params.categoryName, description: params.categoryDescription, - parent: params.categoryParent ? Number(params.categoryParent) : undefined, + parent: + params.categoryParent !== undefined && params.categoryParent !== '' + ? Number(params.categoryParent) + : undefined, slug: params.categorySlug, } case 'wordpress_list_categories': @@ -884,6 +1033,28 @@ export const WordPressBlock: BlockConfig = { search: params.search, order: params.order, } + case 'wordpress_get_category': + return { + ...baseParams, + categoryId: Number(params.categoryId), + } + case 'wordpress_update_category': + return { + ...baseParams, + categoryId: Number(params.categoryId), + name: params.categoryName, + description: params.categoryDescription, + parent: + params.categoryParent !== undefined && params.categoryParent !== '' + ? Number(params.categoryParent) + : undefined, + slug: params.categorySlug, + } + case 'wordpress_delete_category': + return { + ...baseParams, + categoryId: Number(params.categoryId), + } case 'wordpress_create_tag': return { ...baseParams, @@ -899,6 +1070,24 @@ export const WordPressBlock: BlockConfig = { search: params.search, order: params.order, } + case 'wordpress_get_tag': + return { + ...baseParams, + tagId: Number(params.tagId), + } + case 'wordpress_update_tag': + return { + ...baseParams, + tagId: Number(params.tagId), + name: params.tagName, + description: params.tagDescription, + slug: params.tagSlug, + } + case 'wordpress_delete_tag': + return { + ...baseParams, + tagId: Number(params.tagId), + } case 'wordpress_get_current_user': return baseParams case 'wordpress_list_users': @@ -921,7 +1110,7 @@ export const WordPressBlock: BlockConfig = { query: params.query, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, - type: params.searchType || undefined, + subtype: params.searchType || undefined, } default: return baseParams @@ -942,6 +1131,7 @@ export const WordPressBlock: BlockConfig = { slug: { type: 'string', description: 'URL slug' }, categories: { type: 'string', description: 'Category IDs (comma-separated)' }, tags: { type: 'string', description: 'Tag IDs (comma-separated)' }, + listAuthor: { type: 'number', description: 'Filter posts by author ID' }, featuredMedia: { type: 'number', description: 'Featured media ID' }, // Page inputs pageId: { type: 'number', description: 'Page ID' }, @@ -953,19 +1143,26 @@ export const WordPressBlock: BlockConfig = { mediaTitle: { type: 'string', description: 'Media title' }, caption: { type: 'string', description: 'Media caption' }, altText: { type: 'string', description: 'Alt text' }, + mediaDescription: { type: 'string', description: 'Media description' }, mediaId: { type: 'number', description: 'Media ID' }, mediaType: { type: 'string', description: 'Media type filter' }, // Comment inputs commentPostId: { type: 'number', description: 'Post ID for comment' }, commentContent: { type: 'string', description: 'Comment content' }, + commentParent: { type: 'number', description: 'Parent comment ID for replies' }, + commentAuthorName: { type: 'string', description: 'Comment author display name' }, + commentAuthorEmail: { type: 'string', description: 'Comment author email' }, + commentAuthorUrl: { type: 'string', description: 'Comment author URL' }, commentId: { type: 'number', description: 'Comment ID' }, commentStatus: { type: 'string', description: 'Comment status' }, // Category inputs + categoryId: { type: 'number', description: 'Category ID' }, categoryName: { type: 'string', description: 'Category name' }, categoryDescription: { type: 'string', description: 'Category description' }, categoryParent: { type: 'number', description: 'Parent category ID' }, categorySlug: { type: 'string', description: 'Category slug' }, // Tag inputs + tagId: { type: 'number', description: 'Tag ID' }, tagName: { type: 'string', description: 'Tag name' }, tagDescription: { type: 'string', description: 'Tag description' }, tagSlug: { type: 'string', description: 'Tag slug' }, @@ -974,7 +1171,10 @@ export const WordPressBlock: BlockConfig = { roles: { type: 'string', description: 'User roles filter' }, // Search inputs query: { type: 'string', description: 'Search query' }, - searchType: { type: 'string', description: 'Content type filter' }, + searchType: { + type: 'string', + description: 'Content subtype filter (post, page) — maps to the API subtype param', + }, // List inputs perPage: { type: 'number', description: 'Results per page' }, page: { type: 'number', description: 'Page number' }, @@ -983,7 +1183,6 @@ export const WordPressBlock: BlockConfig = { order: { type: 'string', description: 'Order direction' }, listStatus: { type: 'string', description: 'Status filter' }, force: { type: 'boolean', description: 'Force delete' }, - hideEmpty: { type: 'boolean', description: 'Hide empty taxonomies' }, }, outputs: { // Post outputs @@ -1114,5 +1313,19 @@ export const WordPressBlockMeta = { content: '# Moderate WordPress Comments\n\nKeep the comment queue clean and on-policy.\n\n## Steps\n1. List comments, optionally filtering by status such as hold.\n2. For each comment, judge it against the moderation policy: legitimate, spam, or abusive.\n3. Update each comment to the right status: approved, hold, spam, or trash.\n\n## Output\nReturn a summary of how many comments were approved, held, marked spam, or trashed, with the comment IDs grouped by action taken.', }, + { + name: 'organize-taxonomy', + description: + 'Clean up WordPress categories and tags: rename, re-slug, re-parent, or remove unused ones.', + content: + '# Organize WordPress Taxonomy\n\nKeep categories and tags tidy and consistent.\n\n## Steps\n1. List existing categories and tags to see the current taxonomy, including each item post count.\n2. Decide the target structure: rename to a consistent style, fix slugs, set parents for hierarchy, or remove duplicates and empties.\n3. For renames or re-parenting, update the category or tag by ID with the new name, slug, or parent.\n4. To remove one, get it first to confirm it is the right term and low-usage, then delete it (deletion is permanent for terms).\n\n## Output\nReport what changed for each term: renamed, re-slugged, re-parented, or deleted, with the term IDs. Note any term skipped because it still had many posts.', + }, + { + name: 'audit-site-content', + description: + 'Inventory WordPress content by searching and listing posts and pages to find gaps or issues.', + content: + '# Audit WordPress Content\n\nBuild an inventory of what is on the site.\n\n## Steps\n1. Use search across content, or list posts and pages with filters such as status and date order.\n2. Page through results using the total and totalPages counts so nothing is missed.\n3. Group findings by status, category, or age to spot drafts left unpublished, stale posts, or thin content.\n\n## Output\nReturn a structured inventory: counts by status and type, plus a list of flagged items (IDs, titles, and URLs) that need attention.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 801cd3c2abf..fd1d8de138a 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -92,6 +92,7 @@ import { GmailBlock, GmailBlockMeta, GmailV2Block, GmailV2BlockMeta } from '@/bl import { GongBlock, GongBlockMeta } from '@/blocks/blocks/gong' import { GoogleSearchBlock, GoogleSearchBlockMeta } from '@/blocks/blocks/google' import { GoogleAdsBlock, GoogleAdsBlockMeta } from '@/blocks/blocks/google_ads' +import { GoogleAppsheetBlock, GoogleAppsheetBlockMeta } from '@/blocks/blocks/google_appsheet' import { GoogleBigQueryBlock, GoogleBigQueryBlockMeta } from '@/blocks/blocks/google_bigquery' import { GoogleBooksBlock, GoogleBooksBlockMeta } from '@/blocks/blocks/google_books' import { @@ -429,6 +430,7 @@ export const BLOCK_REGISTRY: Record = { gmail_v2: GmailV2Block, gong: GongBlock, google_ads: GoogleAdsBlock, + google_appsheet: GoogleAppsheetBlock, google_bigquery: GoogleBigQueryBlock, google_books: GoogleBooksBlock, google_calendar: GoogleCalendarBlock, @@ -725,6 +727,7 @@ export const BLOCK_META_REGISTRY: Record = { gmail_v2: GmailV2BlockMeta, gong: GongBlockMeta, google_ads: GoogleAdsBlockMeta, + google_appsheet: GoogleAppsheetBlockMeta, google_bigquery: GoogleBigQueryBlockMeta, google_books: GoogleBooksBlockMeta, google_calendar: GoogleCalendarBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 58d2124d7b3..00db61dd498 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1742,6 +1742,21 @@ export function AmplitudeIcon(props: SVGProps) { ) } +export function GoogleAppsheetIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function GoogleBooksIcon(props: SVGProps) { return ( @@ -7985,18 +8000,16 @@ export function LeadMagicIcon(props: SVGProps) { ) } -/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */ +/** Dropcontact brand icon: the teal swirl mark from the official wordmark. */ export function DropcontactIcon(props: SVGProps) { return ( - - + - ) } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 2823d3d1383..1ff65759ed1 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -5,6 +5,21 @@ import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-hand import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' +const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({ + mockExecutorExecute: vi.fn(), + mockCreateSnapshot: vi.fn(), +})) + +vi.mock('@/executor', () => ({ + Executor: class { + execute = mockExecutorExecute + }, +})) + +vi.mock('@/lib/logs/execution/snapshot/service', () => ({ + snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot }, +})) + vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: vi.fn().mockResolvedValue('test-token'), })) @@ -161,6 +176,119 @@ describe('WorkflowBlockHandler', () => { }) }) + describe('workspace containment', () => { + const inputs = { workflowId: 'child-workflow-id' } + + it('should fail a cross-workspace child in the draft loader path', async () => { + const ctx = { ...mockContext, workspaceId: 'workspace-parent' } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Foreign Workflow', + workspaceId: 'workspace-other', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + + await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow( + 'Child workflow child-workflow-id belongs to a different workspace and cannot be executed' + ) + expect(mockCreateSnapshot).not.toHaveBeenCalled() + expect(mockExecutorExecute).not.toHaveBeenCalled() + }) + + it('should fail a cross-workspace child in the deployed loader path', async () => { + const ctx = { + ...mockContext, + workspaceId: 'workspace-parent', + isDeployedContext: true, + } + + mockFetch.mockImplementation(async (url: unknown) => { + if (String(url).includes('/deployed')) { + return { + ok: true, + json: () => + Promise.resolve({ + data: { + deployedState: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + } + } + return { + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Foreign Workflow', + workspaceId: 'workspace-other', + variables: {}, + }, + }), + } + }) + + await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow( + 'Child workflow child-workflow-id belongs to a different workspace and cannot be executed' + ) + expect(mockCreateSnapshot).not.toHaveBeenCalled() + expect(mockExecutorExecute).not.toHaveBeenCalled() + }) + + it('should execute a same-workspace child as before', async () => { + const ctx = { ...mockContext, workspaceId: 'workspace-parent' } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + const result = await handler.execute(ctx, mockBlock, inputs) + + expect(result).toMatchObject({ + success: true, + childWorkflowId: 'child-workflow-id', + childWorkflowName: 'Child Workflow', + childWorkflowSnapshotId: 'snapshot-1', + result: { data: 'ok' }, + }) + expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id') + }) + + it('should fail closed when the executing context has no workspace', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( + 'Cannot execute child workflow child-workflow-id: executing context has no workspace' + ) + expect(mockExecutorExecute).not.toHaveBeenCalled() + }) + }) + describe('loadChildWorkflow', () => { it('should return null for 404 responses', async () => { const workflowId = 'non-existent-workflow' diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 2a8d3d73c31..eea6b4a3004 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -109,6 +109,8 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error(`Child workflow ${workflowId} not found`) } + this.assertChildWorkflowInWorkspace(workflowId, childWorkflow.workspaceId, ctx.workspaceId) + childWorkflowName = childWorkflow.name || 'Unknown Workflow' logger.info( @@ -324,6 +326,34 @@ export class WorkflowBlockHandler implements BlockHandler { return { chain, rootError: rootError.trim() || 'Unknown error' } } + /** + * Ensures the child workflow belongs to the same workspace as the executing + * context before any child execution starts. Blocks silent cross-workspace + * execution (e.g. a manual workflow id still pointing at the source + * workspace after a fork), which would otherwise run the foreign workflow + * with the parent workspace's environment and billing. Fails closed when the + * executing context carries no workspace id: every server execution path + * populates it via execution-core, so a missing value indicates a context + * that must not silently bypass the check. The error message intentionally + * omits the foreign workspace id. + */ + private assertChildWorkflowInWorkspace( + childWorkflowId: string, + childWorkspaceId: string | null | undefined, + parentWorkspaceId: string | undefined + ): void { + if (!parentWorkspaceId) { + throw new Error( + `Cannot execute child workflow ${childWorkflowId}: executing context has no workspace` + ) + } + if (childWorkspaceId !== parentWorkspaceId) { + throw new Error( + `Child workflow ${childWorkflowId} belongs to a different workspace and cannot be executed` + ) + } + } + private async loadChildWorkflow(workflowId: string, userId?: string) { const headers = await buildAuthHeaders(userId) const url = buildAPIUrl(`/api/workflows/${workflowId}`) @@ -378,6 +408,7 @@ export class WorkflowBlockHandler implements BlockHandler { return { name: workflowData.name, + workspaceId: (workflowData.workspaceId ?? null) as string | null, serializedState: serializedWorkflow, variables: workflowVariables, workflowState: workflowStateWithVariables, @@ -461,6 +492,7 @@ export class WorkflowBlockHandler implements BlockHandler { return { name: childName, + workspaceId: (wfData?.workspaceId ?? null) as string | null, serializedState: serializedWorkflow, variables: workflowVariables, workflowState: workflowStateWithVariables, diff --git a/apps/sim/lib/api/contracts/tools/microsoft.ts b/apps/sim/lib/api/contracts/tools/microsoft.ts index be68501cdba..30fe918d9cf 100644 --- a/apps/sim/lib/api/contracts/tools/microsoft.ts +++ b/apps/sim/lib/api/contracts/tools/microsoft.ts @@ -86,6 +86,13 @@ export const sharepointUploadBodySchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) +export const sharepointDownloadFileBodySchema = z.object({ + accessToken: accessTokenSchema, + driveId: z.string().min(1, 'Drive ID is required'), + itemId: z.string().min(1, 'Item ID is required'), + fileName: z.string().optional().nullable(), +}) + export const dataverseUploadFileBodySchema = z.object({ accessToken: accessTokenSchema, environmentUrl: z.string().min(1, 'Environment URL is required'), @@ -190,6 +197,13 @@ export const sharepointUploadContract = defineRouteContract({ response: { mode: 'json', schema: toolJsonResponseSchema }, }) +export const sharepointDownloadFileContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sharepoint/download-file', + body: sharepointDownloadFileBodySchema, + response: { mode: 'json', schema: toolJsonResponseSchema }, +}) + export const dataverseUploadFileContract = defineRouteContract({ method: 'POST', path: '/api/tools/microsoft-dataverse/upload-file', @@ -210,4 +224,5 @@ export type TeamsDeleteChatMessageBody = ContractBody export type OneDriveDownloadBody = ContractBody export type SharepointUploadBody = ContractBody +export type SharepointDownloadFileBody = ContractBody export type DataverseUploadFileBody = z.output diff --git a/apps/sim/lib/api/contracts/tools/onepassword.ts b/apps/sim/lib/api/contracts/tools/onepassword.ts index b67c7fe12e1..349b13296f9 100644 --- a/apps/sim/lib/api/contracts/tools/onepassword.ts +++ b/apps/sim/lib/api/contracts/tools/onepassword.ts @@ -43,6 +43,10 @@ export const onePasswordResolveSecretBodySchema = onePasswordCredentialsBodySche secretReference: z.string().min(1, 'Secret reference is required'), }) +export const onePasswordGetItemFileBodySchema = onePasswordGetItemBodySchema.extend({ + fileId: z.string().min(1, 'File ID is required'), +}) + export const onePasswordListVaultsContract = defineRouteContract({ method: 'POST', path: '/api/tools/onepassword/list-vaults', @@ -148,3 +152,22 @@ export const onePasswordResolveSecretContract = defineRouteContract({ schema: onePasswordResolveSecretResponseSchema, }, }) + +const onePasswordGetItemFileResponseSchema = z.object({ + file: z.object({ + name: z.string(), + mimeType: z.string(), + data: z.string(), + size: z.number(), + }), +}) + +export const onePasswordGetItemFileContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/onepassword/get-item-file', + body: onePasswordGetItemFileBodySchema, + response: { + mode: 'json', + schema: onePasswordGetItemFileResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 828d91db17b..72bb635cf71 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' const subBlockValuesSchema = z.record(z.string(), z.record(z.string(), z.unknown())) @@ -343,6 +344,13 @@ export const executeWorkflowBodySchema = z.object({ startBlockId: z.string().optional(), stopAfterBlockId: z.string().optional(), runFromBlock: executeWorkflowRunFromBlockSchema.optional(), + /** + * Workspace of the parent execution when this call is a workflow-in-workflow + * invocation (e.g. the agent `workflow_executor` tool). When present, the + * route rejects execution of a workflow that lives in a different workspace. + * Direct API callers omit it and are unaffected. + */ + parentWorkspaceId: workspaceIdSchema.optional(), }) export type ExecuteWorkflowBody = z.input diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index cf7e2430dfa..ec1280b9d15 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -331,24 +331,33 @@ const forkClearedRefBaseSchema = z.object({ }) /** - * A reference in a synced source workflow that WILL be blanked in the target by this sync, with the + * A reference in a synced source workflow that this sync would blank in the target, with the * labels to phrase it as "{blockLabel} will lose {fieldLabel} in workflow {workflowName}". A * discriminated union on `cause` so clients narrow exhaustively (only `dependent` carries the parent * fields): - * - `reference`: an unmapped remappable resource (`kind`) - drops off the list once the user maps - * OR copies it (matched to a mapping entry by `${kind}:${sourceId}`). - * - `workflow`: a `workflow-selector`/`workflow_input` ref to a workflow not in the target - - * always cleared (cannot be fixed in the modal). - * - `dependent`: a create-target dependent selector a remapped parent clears. Carries the parent - * (`parentKind`/`parentSourceId`); when the child follows its parent (a document under a knowledge - * base) the client drops it once that parent is mapped/copied, else it stays (credential label / - * table column). + * - `reference`: an unmapped remappable resource (`kind`). BLOCKS the sync until the user maps it + * OR selects it for copy (matched to a mapping entry by `${kind}:${sourceId}`); the entry drops + * off the blocker list once resolved. + * - `workflow`: a `workflow-selector`/`workflow_input` ref to a workflow not carried into the + * target. BLOCKS the sync; resolved outside the modal (deploy the referenced workflow in the + * source, or remove/fix the reference). + * - `dependent`: a create-target dependent selector a remapped parent clears. NOT a blocker (the + * reconfigure flow owns dependents). Carries the parent (`parentKind`/`parentSourceId`); when the + * child follows its parent (a document under a knowledge base) the client drops it once that + * parent is mapped/copied, else it stays (credential label / table column). */ export const forkClearedRefSchema = z.discriminatedUnion('cause', [ forkClearedRefBaseSchema.extend({ cause: z.literal('reference'), /** The unmapped remappable resource (never `workflow`). */ kind: forkRemapKindSchema, + /** + * True when the referenced resource no longer exists (deleted/archived) in the SOURCE + * workspace, so it cannot be offered for copy - the resolution is mapping the dead source id + * to a live target resource, or fixing the source workflow. Collected as `false` and + * annotated post-collection by the source-liveness check (`annotateForkClearedRefSourceLiveness`). + */ + sourceDeleted: z.boolean(), }), forkClearedRefBaseSchema.extend({ cause: z.literal('workflow'), @@ -365,6 +374,42 @@ export const forkClearedRefSchema = z.discriminatedUnion('cause', [ ]) export type ForkClearedRef = z.output +/** + * Why a would-clear reference blocks the sync, so clients can phrase the resolution: + * - `unmapped-copyable`: a live copyable-kind resource (table / KB / file / custom tool / skill) + * with no target mapping - resolve by mapping it or selecting it for copy. + * - `unmapped-mcp-server`: a live external MCP server with no target mapping - resolve by mapping + * (MCP servers are never copied; create one in the target first if none exists). + * - `source-deleted`: the referenced resource was deleted in the source - resolve by mapping the + * dead id to an existing live target resource, or by fixing/archiving the source workflow. + * - `workflow-missing`: a cross-workflow reference to a workflow not carried into the target - + * resolve by deploying the referenced workflow in the source, or removing the reference. + */ +export const forkSyncBlockerReasonSchema = z.enum([ + 'unmapped-copyable', + 'unmapped-mcp-server', + 'source-deleted', + 'workflow-missing', +]) +export type ForkSyncBlockerReason = z.output + +/** + * One reference that blocked a promote at the server gate (the authoritative in-tx re-check of + * the would-clear set). Mirrors the cleared-ref labels so the client can phrase each blocker; + * `kind` is `workflow` for cross-workflow references. `sourceLabel` may fall back to `sourceId` + * (the gate skips display-label loading); the modal's refreshed diff carries the labeled list. + */ +export const forkSyncBlockerSchema = z.object({ + workflowName: z.string(), + blockLabel: z.string(), + fieldLabel: z.string(), + kind: z.union([forkRemapKindSchema, z.literal('workflow')]), + sourceId: z.string(), + sourceLabel: z.string(), + reason: forkSyncBlockerReasonSchema, +}) +export type ForkSyncBlocker = z.output + export const getForkDiffQuerySchema = z.object({ otherWorkspaceId: workspaceIdSchema, direction: forkDirectionSchema, @@ -395,11 +440,13 @@ export const getForkDiffContract = defineRouteContract({ /** Every workflow each mapped resource is used in, for the always-on reconfigure listing. */ resourceUsages: z.array(forkResourceUsageSchema), /** - * Referenced resources with no target mapping that the sync can copy into the target - * (fork-style), so the user can copy instead of mapping each one by hand. Default-selected - * in the modal; documents under a selected knowledge base are copied automatically. - * `parentId`/`parentLabel` carry the folder grouping for file entries (id + name); they - * are null for non-file kinds and for files at the workspace root. + * Copyable resources with no target mapping that the sync can copy into the target + * (fork-style). `referenced: true` entries are referenced by the synced workflows and + * default-selected in the modal (deselecting one clears its references); `referenced: false` + * entries exist in the source but are used by no synced workflow and default-unselected + * (skipping one breaks nothing). Documents under a selected knowledge base are copied + * automatically. `parentId`/`parentLabel` carry the folder grouping for file entries + * (id + name); they are null for non-file kinds and for files at the workspace root. */ copyableUnmapped: z.array( z.object({ @@ -408,6 +455,8 @@ export const getForkDiffContract = defineRouteContract({ label: z.string(), parentId: z.string().nullable(), parentLabel: z.string().nullable(), + /** Whether any synced workflow references this resource (drives the copy default). */ + referenced: z.boolean(), }) ), /** @@ -453,9 +502,10 @@ export const forkDependentValueEntrySchema = z.object({ export type ForkDependentValueEntry = z.input /** - * Source resource ids (by kind) the user chose to copy into the target before the sync gate, - * for referenced-but-unmapped resources. Each kind's documents under a copied knowledge base - * are discovered + copied automatically (the user selects only the parent resources). + * Source resource ids (by kind) the user chose to copy into the target before the sync gate - + * unmapped resources, whether referenced by the synced workflows or not. Each kind's documents + * under a copied knowledge base are discovered + copied automatically (the user selects only + * the parent resources). */ export const promoteCopyResourcesSchema = z.object({ knowledgeBases: forkResourceIdList, @@ -495,6 +545,12 @@ export const promoteForkContract = defineRouteContract({ redeployed: z.number().int(), deployFailed: z.number().int(), unmappedRequired: z.array(forkUnmappedReferenceSchema), + /** + * References the sync would have cleared, so it was blocked without writing (the + * authoritative in-tx gate; non-empty only when `promoteRunId` is empty). Normally the + * client blocks first - this fires only when the state changed between preview and Sync. + */ + blockers: z.array(forkSyncBlockerSchema), /** Workflows whose required dependent fields the target must re-pick post-sync. */ needsConfiguration: z.array(forkNeedsConfigurationSchema), /** Workflows whose optional dependent fields a swap cleared (surfaced, not gated). */ diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 37aeaa2735c..e1d6ec21740 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -2,12 +2,18 @@ * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) +/** + * Overrides the global `@sim/db` mock: the logs-context tests below need + * controllable row data, which the stable `dbChainMockFns.limit` provides. + */ +vi.mock('@sim/db', () => dbChainMock) import { processContextsServer } from './process-contents' @@ -67,3 +73,186 @@ describe('processContextsServer - skill contexts', () => { expect(result).toEqual([]) }) }) + +describe('processContextsServer - logs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + executionData: { + traceSpans: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + duration: 500, + input: { prompt: 'do the thing' }, + output: { error: '429 No active subscription' }, + }, + ], + }, + costTotal: '0.05', + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toHaveLength(1) + expect(result[0].type).toBe('logs') + expect(result[0].tag).toBe('@My Flow') + + const summary = JSON.parse(result[0].content) + expect(summary).toMatchObject({ + executionId: 'exec-1', + workflowId: 'wf-1', + workflowName: 'My Flow', + level: 'error', + trigger: 'manual', + totalDurationMs: 1000, + cost: { total: 0.05 }, + overview: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + durationMs: 500, + }, + ], + }) + const serialized = JSON.stringify(summary) + expect(serialized).not.toContain('do the thing') + expect(serialized).not.toContain('429 No active subscription') + expect(summary.note).toContain('query_logs') + expect(summary.note).toContain('exec-1') + }) + + it('drops the overview (keeping the rest of the summary) when it exceeds the size cap', async () => { + const traceSpans = Array.from({ length: 2000 }, (_, i) => ({ + id: `span-${i}`, + blockId: `block-${i}`, + name: `Block ${i}`, + type: 'agent', + status: 'success', + duration: 10, + })) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + executionData: { traceSpans }, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + const summary = JSON.parse(result[0].content) + expect(summary.overview).toBeUndefined() + expect(summary.executionId).toBe('exec-1') + expect(summary.note).toContain('query_logs') + }) + + it('drops a log context when the workflow is outside the current workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-other', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-other' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) + + it('drops a log context the user is not authorized to read', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index ef33580211c..a49ea07ccc0 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' +import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, @@ -18,6 +19,8 @@ import { encodeVfsSegment, } from '@/lib/copilot/vfs/path-utils' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { toOverview } from '@/lib/logs/log-views' +import type { TraceSpan } from '@/lib/logs/types' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -565,6 +568,31 @@ async function processWorkflowBlockFromDb( } } +/** + * Cap on the serialized summary (including the block overview tree) sent for + * a tagged run. `toOverview` already excludes every block's input/output, so + * this is a safety net against pathological span counts, not the primary + * defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down + * since this lands in the prompt unconditionally rather than behind an + * explicit tool call. + */ +const MAX_LOG_SUMMARY_BYTES = 64 * 1024 + +/** + * Resolve a tagged run to a compact summary instead of its full execution + * trace. A run's trace can carry every block's input/output plus nested + * tool-call spans, which is unbounded and would repeatedly blow the context + * window if inlined directly. The summary includes the block-level overview + * tree (name/type/status/timing/cost, no input or output — the same + * projection `query_logs`'s `overview` view returns) so the model can see + * which block failed without a round trip, and points it at `query_logs` for + * that block's actual input/output/error, or to grep the trace. + * + * `materializeExecutionData` only unwraps a top-level object-storage pointer, + * for runs whose whole trace was offloaded as one blob — a no-op for the + * common inline case. Individual span input/output stay as large-value refs; + * `toOverview` never resolves those. + */ async function processExecutionLogFromDb( executionId: string, userId: string | undefined, @@ -610,12 +638,14 @@ async function processExecutionLogFromDb( } } - // Heavy execution data may live in object storage; resolve the pointer. const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') const executionData = (await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - )) as any + )) as { traceSpans?: TraceSpan[] } | undefined + const overview = executionData?.traceSpans?.length + ? toOverview(executionData.traceSpans) + : undefined const summary = { id: log.id, @@ -627,13 +657,13 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - executionData: executionData - ? { - traceSpans: executionData.traceSpans || undefined, - errorDetails: executionData.errorDetails || undefined, - } - : undefined, cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, + overview, + note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`, + } + + if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) { + summary.overview = undefined } const content = JSON.stringify(summary) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index a81853c5514..7f81039622c 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -4,6 +4,7 @@ import https from 'https' import type { LookupFunction } from 'net' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import * as ipaddr from 'ipaddr.js' import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' @@ -333,6 +334,8 @@ export interface SecureFetchOptions { maxRedirects?: number maxResponseBytes?: number signal?: AbortSignal + /** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */ + stripAuthOnRedirect?: boolean } export class SecureFetchHeaders { @@ -500,10 +503,16 @@ export async function secureFetchWithPinnedIP( settledReject(new Error(`Redirect blocked: ${validation.error}`)) return } + const redirectOptions = options.stripAuthOnRedirect + ? { + ...options, + headers: omit(options.headers ?? {}, ['Authorization', 'authorization']), + } + : options return secureFetchWithPinnedIP( redirectUrl, validation.resolvedIP!, - options, + redirectOptions, redirectCount + 1 ) }) diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index fbb302ad9f1..e9df1348307 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -77,6 +77,7 @@ import { GmailIcon, GongIcon, GoogleAdsIcon, + GoogleAppsheetIcon, GoogleBigQueryIcon, GoogleBooksIcon, GoogleCalendarIcon, @@ -310,6 +311,7 @@ export const blockTypeToIconMap: Record = { gmail_v2: GmailIcon, gong: GongIcon, google_ads: GoogleAdsIcon, + google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, google_books: GoogleBooksIcon, google_calendar_v2: GoogleCalendarIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index e362db3a581..449e5c883d8 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,12 +1,12 @@ { - "updatedAt": "2026-07-01", + "updatedAt": "2026-07-02", "integrations": [ { "type": "onepassword", "slug": "1password", "name": "1Password", "description": "Manage secrets and items in 1Password vaults", - "longDescription": "Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.", + "longDescription": "Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.", "bgColor": "#FFFFFF", "iconName": "OnePasswordIcon", "docsUrl": "https://docs.sim.ai/integrations/onepassword", @@ -27,6 +27,10 @@ "name": "Get Item", "description": "Get full details of an item including all fields and secrets" }, + { + "name": "Get Item File", + "description": "Download the content of a file attached to an item" + }, { "name": "Create Item", "description": "Create a new item in a vault" @@ -48,7 +52,7 @@ "description": "Resolve a secret reference (op://vault/item/field) to its value. Service Account mode only." } ], - "operationCount": 9, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -345,28 +349,40 @@ "longDescription": "Integrate Ahrefs SEO tools into your workflow. Analyze domain ratings, backlinks, organic keywords, top pages, and more. Requires an Ahrefs Enterprise plan with API access.", "bgColor": "#FFFFFF", "iconName": "AhrefsIcon", - "docsUrl": "https://docs.ahrefs.com/docs/api/reference/introduction", + "docsUrl": "https://docs.sim.ai/integrations/ahrefs", "operations": [ { "name": "Domain Rating", "description": "Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website's backlink profile on a scale from 0 to 100." }, + { + "name": "Metrics Overview", + "description": "Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost." + }, { "name": "Backlinks", "description": "Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating." }, { "name": "Backlinks Stats", - "description": "Get backlink statistics for a target domain or URL. Returns totals for different backlink types including dofollow, nofollow, text, image, and redirect links." + "description": "Get backlink and referring domain totals for a target domain or URL, both currently live and across all time." }, { "name": "Referring Domains", "description": "Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates." }, + { + "name": "Broken Backlinks", + "description": "Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities." + }, { "name": "Organic Keywords", "description": "Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic." }, + { + "name": "Organic Competitors", + "description": "Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap." + }, { "name": "Top Pages", "description": "Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value." @@ -374,13 +390,9 @@ { "name": "Keyword Overview", "description": "Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential." - }, - { - "name": "Broken Backlinks", - "description": "Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities." } ], - "operationCount": 8, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -541,9 +553,13 @@ { "name": "Delete By Filter", "description": "Delete all records matching a filter from an Algolia index" + }, + { + "name": "Get Task Status", + "description": "Check whether an Algolia indexing task has finished publishing" } ], - "operationCount": 15, + "operationCount": 16, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -669,8 +685,8 @@ "slug": "amplitude", "name": "Amplitude", "description": "Track events and query analytics from Amplitude", - "longDescription": "Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data.", - "bgColor": "#1B1F3B", + "longDescription": "Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data.", + "bgColor": "#13294B", "iconName": "AmplitudeIcon", "docsUrl": "https://docs.sim.ai/integrations/amplitude", "operations": [ @@ -696,7 +712,7 @@ }, { "name": "User Profile", - "description": "Get a user profile including properties, cohort memberships, and computed properties." + "description": "Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects." }, { "name": "Event Segmentation", @@ -717,9 +733,17 @@ { "name": "Get Revenue", "description": "Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts." + }, + { + "name": "Funnels", + "description": "Analyze conversion rates and drop-off between a sequence of events." + }, + { + "name": "Retention", + "description": "Measure how many users return to perform an action after a starting action." } ], - "operationCount": 11, + "operationCount": 13, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -2647,7 +2671,7 @@ "slug": "clerk", "name": "Clerk", "description": "Manage users, organizations, and sessions in Clerk", - "longDescription": "Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions.", + "longDescription": "Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens.", "bgColor": "#131316", "iconName": "ClerkIcon", "docsUrl": "https://docs.sim.ai/integrations/clerk", @@ -2672,6 +2696,26 @@ "name": "Delete User", "description": "Delete a user from your Clerk application" }, + { + "name": "Ban User", + "description": "Ban a user, preventing them from signing in" + }, + { + "name": "Unban User", + "description": "Remove a ban from a user, allowing them to sign in again" + }, + { + "name": "Lock User", + "description": "Lock a user account, blocking sign-in attempts" + }, + { + "name": "Unlock User", + "description": "Unlock a previously locked user account" + }, + { + "name": "Get User OAuth Token", + "description": "Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO" + }, { "name": "List Organizations", "description": "List all organizations in your Clerk application with optional filtering" @@ -2684,6 +2728,38 @@ "name": "Create Organization", "description": "Create a new organization in your Clerk application" }, + { + "name": "Update Organization", + "description": "Update an existing organization in your Clerk application" + }, + { + "name": "Delete Organization", + "description": "Delete an organization from your Clerk application" + }, + { + "name": "List Organization Memberships", + "description": "List members of a Clerk organization with optional filtering and pagination" + }, + { + "name": "Add Organization Member", + "description": "Add a user as a member of a Clerk organization with a given role" + }, + { + "name": "Update Organization Membership", + "description": "Change a member's role within a Clerk organization" + }, + { + "name": "Remove Organization Member", + "description": "Remove a member from a Clerk organization" + }, + { + "name": "Create Organization Invitation", + "description": "Invite a user by email to join a Clerk organization with a given role" + }, + { + "name": "List Organization Invitations", + "description": "List pending and past invitations for a Clerk organization" + }, { "name": "List Sessions", "description": "List sessions for a user or client in your Clerk application" @@ -2695,9 +2771,49 @@ { "name": "Revoke Session", "description": "Revoke a session to immediately invalidate it" + }, + { + "name": "List Allowlist Identifiers", + "description": "List email/phone/web3-wallet identifiers on your Clerk instance allowlist" + }, + { + "name": "Create Allowlist Identifier", + "description": "Add an email, phone number, or web3 wallet to your Clerk instance allowlist" + }, + { + "name": "Delete Allowlist Identifier", + "description": "Remove an identifier from your Clerk instance allowlist" + }, + { + "name": "List Blocklist Identifiers", + "description": "List email/phone/web3-wallet identifiers on your Clerk instance blocklist" + }, + { + "name": "Create Blocklist Identifier", + "description": "Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups" + }, + { + "name": "Delete Blocklist Identifier", + "description": "Remove an identifier from your Clerk instance blocklist" + }, + { + "name": "List JWT Templates", + "description": "List custom JWT templates configured on your Clerk instance" + }, + { + "name": "Get JWT Template", + "description": "Retrieve a single custom JWT template by ID from Clerk" + }, + { + "name": "Create Actor Token", + "description": "Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling" + }, + { + "name": "Revoke Actor Token", + "description": "Revoke an actor token before it is used or expires" } ], - "operationCount": 11, + "operationCount": 34, "triggers": [ { "id": "clerk_user_created", @@ -2719,23 +2835,58 @@ "name": "Clerk Session Created", "description": "Trigger workflow when a Clerk session is created" }, + { + "id": "clerk_session_ended", + "name": "Clerk Session Ended", + "description": "Trigger workflow when a Clerk session ends" + }, + { + "id": "clerk_session_removed", + "name": "Clerk Session Removed", + "description": "Trigger workflow when a Clerk session is removed" + }, + { + "id": "clerk_session_revoked", + "name": "Clerk Session Revoked", + "description": "Trigger workflow when a Clerk session is revoked" + }, { "id": "clerk_organization_created", "name": "Clerk Organization Created", "description": "Trigger workflow when a Clerk organization is created" }, + { + "id": "clerk_organization_updated", + "name": "Clerk Organization Updated", + "description": "Trigger workflow when a Clerk organization is updated" + }, + { + "id": "clerk_organization_deleted", + "name": "Clerk Organization Deleted", + "description": "Trigger workflow when a Clerk organization is deleted" + }, { "id": "clerk_organization_membership_created", "name": "Clerk Organization Membership Created", "description": "Trigger workflow when a Clerk organization membership is created" }, + { + "id": "clerk_organization_membership_updated", + "name": "Clerk Organization Membership Updated", + "description": "Trigger workflow when a Clerk organization membership is updated" + }, + { + "id": "clerk_organization_membership_deleted", + "name": "Clerk Organization Membership Deleted", + "description": "Trigger workflow when a Clerk organization membership is deleted" + }, { "id": "clerk_webhook", "name": "Clerk Webhook", "description": "Trigger workflow on any Clerk webhook event" } ], - "triggerCount": 7, + "triggerCount": 14, "authType": "none", "category": "tools", "integrationType": "security", @@ -4418,7 +4569,7 @@ "name": "Dropcontact", "description": "Enrich B2B contacts with verified email, phone, and company data", "longDescription": "Use Dropcontact to verify and enrich B2B contacts. Submit a contact with their name, company, website, or LinkedIn URL and receive a verified professional email, phone number, company firmographics, and LinkedIn profile. Enrichment is async: Dropcontact processes the request, then Sim polls until the result is ready. Credits are only charged when a verified email is returned.", - "bgColor": "#0066FF", + "bgColor": "#0ABA9F", "iconName": "DropcontactIcon", "docsUrl": "https://docs.sim.ai/tools/dropcontact", "operations": [ @@ -5082,6 +5233,10 @@ "name": "List Meetings", "description": "List recent meetings recorded by the user or shared to their team." }, + { + "name": "List Meeting Types", + "description": "List meeting types configured in your Fathom organization." + }, { "name": "Get Summary", "description": "Get the call summary for a specific meeting recording." @@ -5099,7 +5254,7 @@ "description": "List teams in your Fathom organization." } ], - "operationCount": 5, + "operationCount": 6, "triggers": [ { "id": "fathom_new_meeting", @@ -6097,6 +6252,14 @@ "name": "List Flows", "description": "List Gong Engage flows (sales engagement sequences)." }, + { + "name": "Assign Flow Prospects", + "description": "Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow." + }, + { + "name": "Get Prospect Flows", + "description": "Get the Gong Engage flows currently assigned to the given CRM prospects." + }, { "name": "Get Coaching", "description": "Retrieve coaching metrics for a manager from Gong." @@ -6108,9 +6271,17 @@ { "name": "Lookup Phone", "description": "Find all references to a phone number in Gong (calls, email messages, meetings, CRM data, and associated contacts)." + }, + { + "name": "Purge Email Address", + "description": "Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible." + }, + { + "name": "Purge Phone Number", + "description": "Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible." } ], - "operationCount": 21, + "operationCount": 25, "triggers": [ { "id": "gong_webhook", @@ -6173,6 +6344,41 @@ "integrationType": "analytics", "tags": ["marketing", "google-workspace", "data-analytics"] }, + { + "type": "google_appsheet", + "slug": "google-appsheet", + "name": "Google AppSheet", + "description": "Read, add, edit, and delete rows in a Google AppSheet table", + "longDescription": "Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key.", + "bgColor": "#FFFFFF", + "iconName": "GoogleAppsheetIcon", + "docsUrl": "https://docs.sim.ai/integrations/google_appsheet", + "operations": [ + { + "name": "Find Rows", + "description": "Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results." + }, + { + "name": "Add Rows", + "description": "Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID())." + }, + { + "name": "Edit Rows", + "description": "Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change." + }, + { + "name": "Delete Rows", + "description": "Delete rows from an AppSheet table. Each row only needs to include the key column name and value." + } + ], + "operationCount": 4, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "databases", + "tags": ["spreadsheet", "automation", "google-workspace"] + }, { "type": "google_bigquery", "slug": "google-bigquery", @@ -7312,7 +7518,7 @@ "name": "Grafana", "description": "Interact with Grafana dashboards, alerts, and annotations", "longDescription": "Integrate Grafana into workflows. Manage dashboards, alerts, annotations, data sources, folders, and monitor health status.", - "bgColor": "#F46800", + "bgColor": "#FFFFFF", "iconName": "GrafanaIcon", "docsUrl": "https://docs.sim.ai/integrations/grafana", "operations": [ @@ -7696,7 +7902,7 @@ "slug": "hex", "name": "Hex", "description": "Run and manage Hex projects", - "longDescription": "Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.", + "longDescription": "Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token.", "bgColor": "#14151A", "iconName": "HexIcon", "docsUrl": "https://docs.sim.ai/integrations/hex", @@ -7757,6 +7963,10 @@ "name": "Create Collection", "description": "Create a new collection in the Hex workspace to organize projects." }, + { + "name": "Update Collection", + "description": "Update the name or description of an existing Hex collection." + }, { "name": "List Data Connections", "description": "List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, BigQuery)." @@ -7764,9 +7974,25 @@ { "name": "Get Data Connection", "description": "Retrieve details for a specific data connection including type, description, and configuration flags." + }, + { + "name": "Create Group", + "description": "Create a new group in the Hex workspace, optionally with initial members." + }, + { + "name": "Update Group", + "description": "Rename a Hex group or add/remove members from it." + }, + { + "name": "Delete Group", + "description": "Delete a group from the Hex workspace." + }, + { + "name": "Deactivate User", + "description": "Deactivate a user in the Hex workspace." } ], - "operationCount": 16, + "operationCount": 21, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -9273,9 +9499,21 @@ { "name": "Create Runs Batch", "description": "Forward multiple runs to LangSmith in a single batch." + }, + { + "name": "Update Run", + "description": "Patch an existing LangSmith run with outputs, status, or timing once it completes." + }, + { + "name": "Get Run", + "description": "Retrieve a single LangSmith run by ID." + }, + { + "name": "Create Feedback", + "description": "Attach a score, correction, or comment to a LangSmith run." } ], - "operationCount": 2, + "operationCount": 5, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -10197,7 +10435,7 @@ }, { "name": "List Transactional Emails", - "description": "Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables." + "description": "Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables." }, { "name": "Create Contact Property", @@ -10206,9 +10444,21 @@ { "name": "List Contact Properties", "description": "Retrieve a list of contact properties from your Loops account. Returns each property with its key, label, and data type. Can filter to show all properties or only custom ones." + }, + { + "name": "Check Contact Suppression", + "description": "Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId." + }, + { + "name": "Remove Contact Suppression", + "description": "Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota." + }, + { + "name": "Get Transactional Email", + "description": "Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs." } ], - "operationCount": 10, + "operationCount": 13, "triggers": [ { "id": "loops_email_delivered", @@ -15275,6 +15525,18 @@ "name": "Read Page", "description": "Read a specific page from a SharePoint site" }, + { + "name": "Update Page", + "description": "Update the title and/or content of a SharePoint page" + }, + { + "name": "Publish Page", + "description": "Publish the latest version of a SharePoint page, making it available to all users" + }, + { + "name": "Delete Page", + "description": "Delete a page from a SharePoint site" + }, { "name": "List Sites", "description": "List details of all SharePoint sites" @@ -15295,12 +15557,32 @@ "name": "Add List Item", "description": "Add a new item to a SharePoint list" }, + { + "name": "Get List Item", + "description": "Get a single item (with field values) from a SharePoint list" + }, + { + "name": "Delete List Item", + "description": "Delete an item from a SharePoint list" + }, { "name": "Upload File", "description": "Upload files to a SharePoint document library" + }, + { + "name": "Download File", + "description": "Download a file from a SharePoint document library" + }, + { + "name": "Get Drive Item", + "description": "Get metadata for a file or folder in a SharePoint document library" + }, + { + "name": "Delete File", + "description": "Delete a file (or folder) from a SharePoint document library" } ], - "operationCount": 8, + "operationCount": 16, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -15442,9 +15724,13 @@ { "name": "Visit Duration (Desktop)", "description": "Get average desktop visit duration over time (in seconds)" + }, + { + "name": "Page Views", + "description": "Get total page views over time (desktop and mobile combined)" } ], - "operationCount": 5, + "operationCount": 6, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -17165,7 +17451,7 @@ }, { "name": "Introspect Schema", - "description": "Introspect Supabase database schema to get table structures, columns, and relationships" + "description": "Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)" }, { "name": "Storage: Upload File", @@ -17199,10 +17485,22 @@ "name": "Storage: Create Signed URL", "description": "Create a temporary signed URL for a file in a Supabase storage bucket" }, + { + "name": "Storage: Create Signed Upload URL", + "description": "Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket" + }, { "name": "Storage: Create Bucket", "description": "Create a new storage bucket in Supabase" }, + { + "name": "Storage: Update Bucket", + "description": "Update the configuration of an existing Supabase storage bucket" + }, + { + "name": "Storage: Empty Bucket", + "description": "Delete all objects inside a Supabase storage bucket without deleting the bucket itself" + }, { "name": "Storage: List Buckets", "description": "List all storage buckets in Supabase" @@ -17212,7 +17510,7 @@ "description": "Delete a storage bucket in Supabase" } ], - "operationCount": 23, + "operationCount": 26, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -17262,6 +17560,10 @@ "name": "Update Device Key", "description": "Enable or disable key expiry on a device" }, + { + "name": "Expire Device Key", + "description": "Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet" + }, { "name": "List DNS Nameservers", "description": "Get the DNS nameservers configured for the tailnet" @@ -17290,6 +17592,14 @@ "name": "List Users", "description": "List all users in the tailnet" }, + { + "name": "Suspend User", + "description": "Suspend a user's access to the tailnet" + }, + { + "name": "Delete User", + "description": "Delete a user from the tailnet" + }, { "name": "Create Auth Key", "description": "Create a new auth key for the tailnet to pre-authorize devices" @@ -17309,9 +17619,13 @@ { "name": "Get ACL", "description": "Get the current ACL policy for the tailnet" + }, + { + "name": "Set ACL", + "description": "Replace the ACL policy file for the tailnet" } ], - "operationCount": 20, + "operationCount": 24, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -17808,8 +18122,8 @@ "type": "trello", "slug": "trello", "name": "Trello", - "description": "Manage Trello lists, cards, and activity", - "longDescription": "Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.", + "description": "Manage Trello lists, cards, checklists, and activity", + "longDescription": "Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments.", "bgColor": "#0052CC", "iconName": "TrelloIcon", "docsUrl": "https://docs.sim.ai/integrations/trello", @@ -17822,6 +18136,10 @@ "name": "List Cards", "description": "List cards from a Trello board or list" }, + { + "name": "Search", + "description": "Search Trello cards and boards by keyword" + }, { "name": "Create Card", "description": "Create a new card in a Trello list" @@ -17834,6 +18152,10 @@ "name": "Update Card", "description": "Update an existing card on Trello" }, + { + "name": "Delete Card", + "description": "Permanently delete a Trello card" + }, { "name": "Get Actions", "description": "Get activity/actions from a board or card" @@ -17846,14 +18168,34 @@ "name": "Add Checklist", "description": "Add a checklist to a Trello card" }, + { + "name": "Add Checklist Item", + "description": "Add an item to a Trello checklist" + }, + { + "name": "Update Checklist Item", + "description": "Check off, uncheck, or rename a Trello checklist item" + }, { "name": "Add Label", "description": "Attach an existing label to a Trello card" }, + { + "name": "Remove Label", + "description": "Detach a label from a Trello card" + }, { "name": "Add Member", "description": "Assign a member to a Trello card" }, + { + "name": "Remove Member", + "description": "Unassign a member from a Trello card" + }, + { + "name": "List Members", + "description": "List members of a Trello board" + }, { "name": "Create Board", "description": "Create a new Trello board" @@ -17865,9 +18207,13 @@ { "name": "Create List", "description": "Create a new list on a Trello board" + }, + { + "name": "Update List", + "description": "Rename, move, archive, or reopen a Trello list" } ], - "operationCount": 13, + "operationCount": 21, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -19040,7 +19386,7 @@ "slug": "wordpress", "name": "WordPress", "description": "Manage WordPress content", - "longDescription": "Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication.", + "longDescription": "Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth.", "bgColor": "#21759B", "iconName": "WordpressIcon", "docsUrl": "https://docs.sim.ai/integrations/wordpress", @@ -19121,6 +19467,18 @@ "name": "Create Category", "description": "Create a new category in WordPress.com" }, + { + "name": "Update Category", + "description": "Update an existing category in WordPress.com" + }, + { + "name": "Delete Category", + "description": "Delete a category from WordPress.com" + }, + { + "name": "Get Category", + "description": "Get a single category from WordPress.com by ID" + }, { "name": "List Categories", "description": "List categories from WordPress.com" @@ -19129,6 +19487,18 @@ "name": "Create Tag", "description": "Create a new tag in WordPress.com" }, + { + "name": "Update Tag", + "description": "Update an existing tag in WordPress.com" + }, + { + "name": "Delete Tag", + "description": "Delete a tag from WordPress.com" + }, + { + "name": "Get Tag", + "description": "Get a single tag from WordPress.com by ID" + }, { "name": "List Tags", "description": "List tags from WordPress.com" @@ -19150,7 +19520,7 @@ "description": "Search across all content types in WordPress.com (posts, pages, media)" } ], - "operationCount": 26, + "operationCount": 32, "triggers": [], "triggerCount": 0, "authType": "oauth", diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts index 67e7fd8c997..ef943e7d408 100644 --- a/apps/sim/lib/webhooks/providers/google-forms.ts +++ b/apps/sim/lib/webhooks/providers/google-forms.ts @@ -29,7 +29,12 @@ export const googleFormsHandler: WebhookProviderHandler = { const responseId = (b?.responseId || b?.id || '') as string const createTime = (b?.createTime || b?.timestamp || new Date().toISOString()) as string const lastSubmittedTime = (b?.lastSubmittedTime || createTime) as string - const formId = (b?.formId || providerConfig.formId || '') as string + // triggerFormId is the current subBlock id; formId is the pre-#3141 id still + // present in provider_config for webhooks deployed before that rename. + const formId = (b?.formId || + providerConfig.triggerFormId || + providerConfig.formId || + '') as string const includeRaw = providerConfig.includeRawPayload !== false return { input: { @@ -46,7 +51,10 @@ export const googleFormsHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext) { const expectedToken = providerConfig.token as string | undefined if (!expectedToken) { - return null + logger.warn(`[${requestId}] Google Forms webhook secret not configured`) + return new NextResponse('Unauthorized - Missing Google Forms webhook secret', { + status: 401, + }) } const secretHeaderName = providerConfig.secretHeaderName as string | undefined @@ -57,4 +65,17 @@ export const googleFormsHandler: WebhookProviderHandler = { return null }, + + extractIdempotencyId(body: unknown): string | null { + const b = body as Record + // Mirrors formatInput's responseId resolution. formId is deliberately not part + // of this key: the final key is already scoped by webhookId (one webhook per + // deployed form), and extractIdempotencyId has no access to providerConfig, so + // a body-only formId fallback would risk a bogus 'unknown' segment. + const responseId = (b?.responseId || b?.id) as string | undefined + if (typeof responseId !== 'string' || !responseId) { + return null + } + return `google_forms:${responseId}` + }, } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 15de7f9f066..1c2d7cfed18 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -152,6 +152,125 @@ describe('updateResumeOutputInAggregationBuffers', () => { }) }) +describe('PauseResumeManager.getPauseContextDetail', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('does not duplicate a pause point large response payload between pausePoint and execution.pausePoints', async () => { + const largeDisplayValue = 'x'.repeat(50_000) + + const row = { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausedAt: null, + updatedAt: null, + expiresAt: null, + metadata: {}, + executionSnapshot: { triggerIds: [] }, + pausePoints: { + 'ctx-1': { + contextId: 'ctx-1', + blockId: 'hitl-1', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { + data: { + operation: 'human', + inputFormat: [{ id: 'field_0', name: 'approved', type: 'boolean', required: false }], + submission: null, + responseStructure: [ + { name: 'ai_analysis', type: 'string', value: largeDisplayValue }, + ], + }, + status: 200, + headers: {}, + }, + }, + 'ctx-2': { + contextId: 'ctx-2', + blockId: 'hitl-2', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { + data: { operation: 'human', inputFormat: [], submission: null }, + status: 200, + headers: {}, + }, + }, + }, + } + + dbChainMockFns.limit.mockResolvedValueOnce([row]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const detail = await PauseResumeManager.getPauseContextDetail({ + workflowId: 'workflow-1', + executionId: 'execution-1', + contextId: 'ctx-1', + }) + + expect(detail).not.toBeNull() + // The requested pause point keeps its full response payload. + expect(detail!.pausePoint.response.data.responseStructure[0].value).toBe(largeDisplayValue) + expect(detail!.pausePoint.contextId).toBe('ctx-1') + + // `execution.pausePoints` must not re-embed the (potentially large) + // response payload — it's already available via `pausePoint` above. + for (const point of detail!.execution.pausePoints) { + expect(point.response?.data).toBeUndefined() + } + // Non-payload fields are still present on the execution's pause points. + expect(detail!.execution.pausePoints.map((p) => p.contextId).sort()).toEqual(['ctx-1', 'ctx-2']) + expect(detail!.execution.pausePoints.find((p) => p.contextId === 'ctx-1')?.resumeStatus).toBe( + 'paused' + ) + }) + + it('returns null when the pause context no longer exists', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausedAt: null, + updatedAt: null, + expiresAt: null, + metadata: {}, + executionSnapshot: { triggerIds: [] }, + pausePoints: { + 'ctx-1': { + contextId: 'ctx-1', + blockId: 'hitl-1', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { data: { operation: 'human' }, status: 200, headers: {} }, + }, + }, + }, + ]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const detail = await PauseResumeManager.getPauseContextDetail({ + workflowId: 'workflow-1', + executionId: 'execution-1', + contextId: 'missing-ctx', + }) + + expect(detail).toBeNull() + }) +}) + describe('PauseResumeManager.persistPauseResult metadata merge on re-pause', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index cc678b88755..a5a5a40d870 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -2048,8 +2048,18 @@ export class PauseResumeManager { entry.contextId === contextId && (entry.status === 'claimed' || entry.status === 'pending') ) + // The selected pause point's full `response.data` is already returned via + // `pausePoint` below; strip it from `execution.pausePoints` so a large + // HITL display payload isn't duplicated in full within the same response. + const execution: PausedExecutionDetail = { + ...detail, + pausePoints: detail.pausePoints.map((point) => + point.response ? { ...point, response: { ...point.response, data: undefined } } : point + ), + } + return { - execution: detail, + execution, pausePoint, queue: detail.queue, activeResumeEntry, diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index 3deedca5212..d56d51ff055 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -26,6 +26,10 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { knowledge: { knowledgeBaseId: 'knowledgeBaseSelector', }, + algolia: { + listPage: 'page', + listHitsPerPage: 'hitsPerPage', + }, kalshi: { settlementStatus: '_removed_settlementStatus', }, diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts index d6261bc01c5..7cdc22f39b2 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts @@ -121,8 +121,10 @@ describe('remapWorkflowReferencesInSubBlocks', () => { // The `inputMapping` belongs to the ACTIVE canonical mode's workflow only. resolveCanonicalMode // picks the active mode (block.data.canonicalModes override, else the value heuristic); the wipe - // fires iff the ACTIVE mode's workflowId was removed by the remap. clearUnmapped: true throughout. - it('keeps inputMapping: active basic valid + dormant advanced stale (no override)', () => { + // fires iff the ACTIVE mode's workflow was removed by the remap. Only the SELECTOR is ever + // remapped/cleared - the manual member passes through verbatim - so an active-advanced (manual) + // mode never wipes inputMapping. clearUnmapped: true throughout. + it('keeps inputMapping: active basic valid + dormant advanced manual preserved (no override)', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, @@ -130,11 +132,12 @@ describe('remapWorkflowReferencesInSubBlocks', () => { } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.workflowId.value).toBe('wf-dst') - expect(result.manualWorkflowId.value).toBe('') + // Manual member is user-owned: preserved verbatim (never cleared), even while dormant. + expect(result.manualWorkflowId.value).toBe('wf-unknown') expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('wipes inputMapping: active advanced stale (canonicalModes override) + dormant basic valid', () => { + it('keeps inputMapping: active advanced manual preserved (canonicalModes override) + dormant basic remapped', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, @@ -144,12 +147,14 @@ describe('remapWorkflowReferencesInSubBlocks', () => { clearUnmapped: true, canonicalModes: { workflowId: 'advanced' }, }) + // Active advanced manual is preserved, so its inputMapping survives; the dormant basic selector + // still remaps. expect(result.workflowId.value).toBe('wf-dst') - expect(result.manualWorkflowId.value).toBe('') - expect(result.inputMapping.value).toBe('') + expect(result.manualWorkflowId.value).toBe('wf-unknown') + expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('wipes inputMapping: active basic stale (heuristic) + dormant advanced valid', () => { + it('wipes inputMapping: active basic selector cleared (heuristic) + dormant advanced manual preserved', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, @@ -157,22 +162,24 @@ describe('remapWorkflowReferencesInSubBlocks', () => { } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.workflowId.value).toBe('') - expect(result.manualWorkflowId.value).toBe('wf-dst') + // Manual preserved verbatim (not remapped to wf-dst); the active basic selector clearing is what + // wipes inputMapping. + expect(result.manualWorkflowId.value).toBe('wf-src') expect(result.inputMapping.value).toBe('') }) - it('wipes inputMapping: active advanced stale + basic empty (heuristic)', () => { + it('keeps inputMapping: active advanced manual preserved + basic empty (heuristic)', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) - expect(result.manualWorkflowId.value).toBe('') - expect(result.inputMapping.value).toBe('') + expect(result.manualWorkflowId.value).toBe('wf-unknown') + expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('keeps inputMapping: both modes valid', () => { + it('keeps inputMapping: both modes valid (selector remapped, manual preserved)', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' }, @@ -180,27 +187,27 @@ describe('remapWorkflowReferencesInSubBlocks', () => { } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.workflowId.value).toBe('wf-dst') - expect(result.manualWorkflowId.value).toBe('sub-dst') + expect(result.manualWorkflowId.value).toBe('sub-src') expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('remaps the advanced-mode manualWorkflowId override', () => { + it('does not remap the advanced manualWorkflowId (manual is user-owned)', () => { const subBlocks: SubBlockRecord = { manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map) - expect(result.manualWorkflowId.value).toBe('wf-dst') + expect(result.manualWorkflowId.value).toBe('wf-src') }) - it('remaps a comma-separated manualWorkflowIds list', () => { + it('does not remap the manual comma-separated manualWorkflowIds list (manual is user-owned)', () => { const subBlocks: SubBlockRecord = { manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map) - expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst') + expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src') }) - it('drops unmapped ids from a manualWorkflowIds list when clearUnmapped is set', () => { + it('preserves the manual manualWorkflowIds list verbatim even under clearUnmapped', () => { const subBlocks: SubBlockRecord = { manualWorkflowIds: { id: 'manualWorkflowIds', @@ -209,7 +216,47 @@ describe('remapWorkflowReferencesInSubBlocks', () => { }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) - expect(result.manualWorkflowIds.value).toBe('wf-dst') + expect(result.manualWorkflowIds.value).toBe('wf-src,wf-unknown') + }) + + // The advanced manual field is user-owned: ANY free-form value - env ref, literal id, tag, or + // arbitrary text - is preserved verbatim under clearUnmapped (active advanced), and its sibling + // inputMapping is never wiped. One passthrough covers every free-form edge case at once. + it.each([ + ['env ref', '{{MY_WORKFLOW_ID}}'], + ['literal source-workspace id', 'wf-unknown'], + ['block-output tag', ''], + ['arbitrary text', 'not an id at all'], + ])('preserves a manual %s value and its inputMapping (active advanced)', (_label, value) => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.manualWorkflowId.value).toBe(value) + expect(result.inputMapping.value).toBe('{"a":"b"}') + }) + + // The one behavioral change vs. selector handling: a literal source-workspace id typed into the + // MANUAL field that WOULD map to a copied target is left AS-IS (not remapped), because manual is + // user-owned - while the SELECTOR with the same id still remaps to the copied target. + it('leaves a mapped literal id in the manual field as-is while the selector remaps it', () => { + const manualSubBlocks: SubBlockRecord = { + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, + } + expect( + remapWorkflowReferencesInSubBlocks(manualSubBlocks, map, { clearUnmapped: true }) + .manualWorkflowId.value + ).toBe('wf-src') + + const selectorSubBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, + } + expect( + remapWorkflowReferencesInSubBlocks(selectorSubBlocks, map, { clearUnmapped: true }).workflowId + .value + ).toBe('wf-dst') }) it('remaps a multi-select workflowSelector array', () => { @@ -220,11 +267,64 @@ describe('remapWorkflowReferencesInSubBlocks', () => { expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst']) }) - // create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state - // loaded). With BOTH the parent (`wf-src`) and child (`sub-src`) workflows copied, every - // reference variety must remap to the child id (NOT clear), even under fork-create's - // clearUnmapped policy - the explicit "both deployed and copied" guard. - it('remaps every reference variety when both referenced workflows are copied (clearUnmapped)', () => { + it('clears unmapped ids from the structured workflowSelector list under clearUnmapped', () => { + const subBlocks: SubBlockRecord = { + workflowSelector: { + id: 'workflowSelector', + type: 'dropdown', + value: ['wf-src', 'wf-unknown'], + }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowSelector.value).toEqual(['wf-dst']) + }) + + // The sim workspace-event trigger's workflow filter: a multi-select `dropdown` with baseKey + // `workflowIds` whose options are workspace workflow ids - a structured (selector-sourced) + // list, remapped exactly like `workflowSelector`. + it('remaps the workspace-event trigger workflowIds dropdown list', () => { + const subBlocks: SubBlockRecord = { + workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'sub-src'] }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map) + expect(result.workflowIds.value).toEqual(['wf-dst', 'sub-dst']) + }) + + it('drops unmapped ids from the workflowIds dropdown under clearUnmapped', () => { + const subBlocks: SubBlockRecord = { + workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'wf-unknown'] }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowIds.value).toEqual(['wf-dst']) + }) + + // The TYPE gate: the legacy logs block's `workflowIds` is a free-form short-input (manual, + // user-owned), so it must pass through verbatim even though its baseKey matches. + it('leaves a short-input workflowIds (legacy logs block, user-owned) untouched under clearUnmapped', () => { + const subBlocks: SubBlockRecord = { + workflowIds: { id: 'workflowIds', type: 'short-input', value: 'wf-src,wf-unknown' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowIds.value).toBe('wf-src,wf-unknown') + }) + + // The baseKey gate: dropdowns whose baseKey is neither `workflowSelector` nor `workflowIds` + // (event pickers, status filters, ...) hold non-workflow values and are never rewritten. + it('leaves other dropdowns untouched (only workflow-list baseKeys are remapped)', () => { + const subBlocks: SubBlockRecord = { + eventType: { id: 'eventType', type: 'dropdown', value: 'wf-src' }, + level: { id: 'level', type: 'dropdown', value: ['wf-src'] }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.eventType.value).toBe('wf-src') + expect(result.level.value).toEqual(['wf-src']) + }) + + // create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state loaded). + // With BOTH `wf-src` and `sub-src` copied, the SELECTOR varieties remap to the child ids; the + // free-form MANUAL varieties (`manualWorkflowId`, `manualWorkflowIds`) are user-owned and pass + // through verbatim, even under fork-create's clearUnmapped policy. + it('remaps selector varieties and preserves manual varieties when both workflows are copied (clearUnmapped)', () => { const subBlocks: SubBlockRecord = { selector: { id: 'selector', type: 'workflow-selector', value: 'wf-src' }, inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, @@ -243,17 +343,19 @@ describe('remapWorkflowReferencesInSubBlocks', () => { const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.selector.value).toBe('wf-dst') expect(result.inputMapping.value).toBe('{"a":"b"}') - expect(result.manualWorkflowId.value).toBe('sub-dst') - expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst') + // Manual varieties pass through verbatim (not remapped to the child ids). + expect(result.manualWorkflowId.value).toBe('sub-src') + expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src') expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst']) const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }> expect(tools[0].params?.workflowId).toBe('sub-dst') expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' }) }) - // A deployed source workflow whose state failed to load is excluded from the scoped fork map, - // so a copied workflow's reference to it clears (never dangles at a never-created child id). - it('clears references to a deployed-but-uncopied workflow absent from the scoped map', () => { + // A deployed source workflow whose state failed to load is excluded from the scoped fork map, so a + // copied workflow's SELECTOR reference to it clears (never dangles at a never-created child id). The + // free-form manual list is user-owned and preserved verbatim. + it('clears selector references to a deployed-but-uncopied workflow (manual list preserved)', () => { const subBlocks: SubBlockRecord = { selector: { id: 'selector', type: 'workflow-selector', value: 'wf-uncopied' }, inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, @@ -271,7 +373,7 @@ describe('remapWorkflowReferencesInSubBlocks', () => { const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.selector.value).toBe('') expect(result.inputMapping.value).toBe('') - expect(result.manualWorkflowIds.value).toBe('wf-dst') + expect(result.manualWorkflowIds.value).toBe('wf-src,wf-uncopied') expect(result.tools.value as unknown[]).toHaveLength(0) }) }) diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts index d21d12c6e1e..5a42bcd4fce 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts @@ -126,17 +126,24 @@ export function remapVariableIdsInSubBlocks( } /** - * Rewrite cross-workflow references through a workflow id map: single - * `workflow-selector` / `manualWorkflowId` values, multi-workflow lists - * (`workflowSelector` multi-select + comma-separated `manualWorkflowIds`, as used - * by the logs block), and `workflow_input` sub-workflow tools nested in a - * `tool-input` array (an agent calling another workflow as a tool). + * Rewrite cross-workflow references through a workflow id map. Only SELECTOR-sourced (structured) + * references are remapped/cleared: the basic `workflow-selector` value, the multi-select + * `workflowSelector` list (logs block), the workspace-event trigger's multi-select `workflowIds` + * dropdown (its options are workspace workflow ids), and `workflow_input` sub-workflow tools + * nested in a `tool-input` array (an agent picking another workflow as a tool - its + * `params.workflowId` comes from the workflow picker, never free-form input). * - * `clearUnmapped` controls the cross-workspace case: fork/promote pass `true` so a - * reference to a workflow that wasn't copied is cleared/dropped rather than left - * pointing at the source workspace (a silent cross-workspace execution). Same- - * workspace duplication leaves it `false` to preserve references to untouched - * sibling workflows. + * The advanced, free-form MANUAL fields (`manualWorkflowId`, comma-separated `manualWorkflowIds`) + * are user-owned and pass through VERBATIM - mirroring `manualCredential` in the fork remap - so a + * hand-typed value (an env ref `{{VAR}}`, a `` tag, a literal id, or arbitrary text) + * is never rewritten or cleared. The `workflowIds` handling is gated on subblock TYPE `dropdown` + * for the same reason: the legacy logs block's `workflowIds` is a free-form `short-input` + * (user-owned, verbatim), and only the workspace-event trigger uses a `workflowIds` dropdown. + * + * `clearUnmapped` controls the cross-workspace case for those selector references: fork/promote pass + * `true` so a selector pointing at a workflow that wasn't copied is cleared/dropped rather than left + * pointing at the source workspace (a silent cross-workspace execution). Same-workspace duplication + * leaves it `false` to preserve references to untouched sibling workflows. */ export function remapWorkflowReferencesInSubBlocks( subBlocks: SubBlockRecord, @@ -152,7 +159,8 @@ export function remapWorkflowReferencesInSubBlocks( } // The `workflowId` canonical pair: basic `workflow-selector` + advanced `manualWorkflowId`. Capture // each key (by type/baseKey, regardless of value) and its ORIGINAL value so the inputMapping wipe - // below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`. + // below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`. Only the basic + // selector is ever remapped; the advanced manual member is captured for mode resolution only. let basicId: string | undefined let basicValue = '' let advancedId: string | undefined @@ -168,15 +176,23 @@ export function remapWorkflowReferencesInSubBlocks( advancedId = key advancedValue = typeof subBlock.value === 'string' ? subBlock.value : '' } + // Remap only the SELECTOR member; the manual `manualWorkflowId` passes through verbatim. if ( - (subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') && + subBlock.type === 'workflow-selector' && typeof subBlock.value === 'string' && subBlock.value ) { updated[key] = { ...subBlock, value: remapScalar(subBlock.value) } continue } - if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') { + // Remap only the STRUCTURED multi-workflow lists: the logs block's `workflowSelector` and + // the workspace-event trigger's `workflowIds` dropdown. The latter is gated on TYPE + // `dropdown` so the legacy logs block's `workflowIds` short-input (manual, user-owned) + // passes through verbatim, as does the manual comma-separated `manualWorkflowIds`. + if ( + baseKey === 'workflowSelector' || + (subBlock.type === 'dropdown' && baseKey === 'workflowIds') + ) { const remapped = remapWorkflowIdList(subBlock.value, workflowIdMap, clearUnmapped) if (remapped !== subBlock.value) { updated[key] = { ...subBlock, value: remapped } diff --git a/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts index 5f557f68f7a..936a777a82d 100644 --- a/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts +++ b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts @@ -75,6 +75,14 @@ function clearFailedSubBlockReferences( * when a reference-clear phase threw - placeholders were then NOT dropped - and `cleared` is 0 in * that case, so the report never claims references it did not actually clear. On success `cleared` * is the count of failed resources whose references were cleared. + * + * Storage accounting: this cleanup never decrements storage usage because it never removes + * anything that was counted. Copied file blobs are the only counted copies (incremented in + * `executeForkFileBlobCopies` only after the blob lands), and a failed file's blob never + * landed - its metadata row is intentionally left re-uploadable, and nothing was charged. The + * dropped table/KB/document placeholders are DB rows the upload path never counts, and any KB + * blobs copied before their KB failed are left in storage (rows only are dropped here) but + * uncounted - mirroring the KB upload path, which never counts KB blobs. */ export async function clearFailedForkResourceReferences(params: { childWorkspaceId: string diff --git a/apps/sim/lib/workspaces/fork/copy/copy-files.test.ts b/apps/sim/lib/workspaces/fork/copy/copy-files.test.ts new file mode 100644 index 00000000000..e0b5c3c0e58 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/copy-files.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { storageServiceMock, storageServiceMockFns } from '@sim/testing' +import { omit } from '@sim/utils/object' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIncrementStorageUsage } = vi.hoisted(() => ({ + mockIncrementStorageUsage: vi.fn(), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +vi.mock('@/lib/billing/storage', () => ({ + incrementStorageUsage: mockIncrementStorageUsage, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + generateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => `workspace/${workspaceId}/generated-${fileName}` + ), +})) + +import type { DbOrTx } from '@/lib/db/types' +import { + type BlobCopyTask, + executeForkFileBlobCopies, + planForkFileCopies, +} from '@/lib/workspaces/fork/copy/copy-files' + +function makeTask(overrides: Partial = {}): BlobCopyTask { + return { + sourceKey: 'workspace/src-ws/source-a.txt', + targetKey: 'workspace/child-ws/target-a.txt', + context: 'workspace', + fileName: 'a.txt', + contentType: 'text/plain', + size: 100, + userId: 'user-1', + workspaceId: 'child-ws', + ...overrides, + } +} + +describe('executeForkFileBlobCopies storage accounting', () => { + beforeEach(() => { + vi.clearAllMocks() + storageServiceMockFns.mockHeadObject.mockResolvedValue(null) + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('blob-bytes')) + storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'workspace/child-ws/target' }) + mockIncrementStorageUsage.mockResolvedValue(undefined) + }) + + it('charges the initiating user exactly once per landed blob, by the metadata row size', async () => { + const tasks = [ + makeTask({ targetKey: 'workspace/child-ws/t1', size: 100 }), + makeTask({ targetKey: 'workspace/child-ws/t2', size: 200, fileName: 'b.txt' }), + ] + + const result = await executeForkFileBlobCopies(tasks, 'test') + + expect(result).toEqual({ copied: 2, failed: 0, failedTargetKeys: [] }) + expect(mockIncrementStorageUsage).toHaveBeenCalledTimes(2) + expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(1, 'user-1', 100, 'child-ws') + expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(2, 'user-1', 200, 'child-ws') + }) + + it('skips an already-existing target blob without re-copying or re-charging (replayed run)', async () => { + storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 100 }) + + const result = await executeForkFileBlobCopies([makeTask()], 'test') + + expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] }) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) + + it('never charges a failed copy (the blob did not land)', async () => { + storageServiceMockFns.mockDownloadFile.mockRejectedValue(new Error('source gone')) + + const result = await executeForkFileBlobCopies([makeTask()], 'test') + + expect(result).toEqual({ + copied: 0, + failed: 1, + failedTargetKeys: ['workspace/child-ws/target-a.txt'], + }) + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) + + it('treats a tracking failure as best-effort - the copy still counts as landed', async () => { + mockIncrementStorageUsage.mockRejectedValue(new Error('billing hiccup')) + + const result = await executeForkFileBlobCopies([makeTask()], 'test') + + expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] }) + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) + }) + + it('skips the charge for a legacy payload enqueued before size existed', async () => { + // Simulates a Trigger.dev payload serialized by a pre-`size` deploy (rolling upgrade). + const legacyTask = omit(makeTask(), ['size']) as BlobCopyTask + + const result = await executeForkFileBlobCopies([legacyTask], 'test') + + expect(result.copied).toBe(1) + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) +}) + +describe('planForkFileCopies', () => { + it('carries the source metadata size onto each blob task and the child row', async () => { + const sourceMeta = { + id: 'wf_src1', + key: 'workspace/src-ws/1-abc-a.txt', + userId: 'uploader-1', + workspaceId: 'src-ws', + folderId: 'folder-1', + context: 'workspace', + chatId: null, + originalName: 'a.txt', + displayName: null, + contentType: 'text/plain', + size: 4321, + deletedAt: null, + uploadedAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } + const inserted: Array> = [] + const tx = { + select: vi.fn(() => ({ from: () => ({ where: () => Promise.resolve([sourceMeta]) }) })), + insert: vi.fn(() => ({ + values: (row: Record) => { + inserted.push(row) + return Promise.resolve() + }, + })), + } as unknown as DbOrTx + + const result = await planForkFileCopies({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + fileIds: ['wf_src1'], + now: new Date('2026-02-01'), + }) + + expect(result.blobTasks).toHaveLength(1) + expect(result.blobTasks[0]).toMatchObject({ + sourceKey: 'workspace/src-ws/1-abc-a.txt', + targetKey: 'workspace/child-ws/generated-a.txt', + size: 4321, + userId: 'user-1', + workspaceId: 'child-ws', + }) + expect(inserted[0]).toMatchObject({ size: 4321, workspaceId: 'child-ws' }) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/copy-files.ts b/apps/sim/lib/workspaces/fork/copy/copy-files.ts index e7e8f5080eb..828aa16513a 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-files.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-files.ts @@ -3,9 +3,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { incrementStorageUsage } from '@/lib/billing/storage' import type { DbOrTx } from '@/lib/db/types' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service' import type { StorageContext } from '@/lib/uploads/shared/types' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { @@ -30,6 +31,13 @@ export interface BlobCopyTask { context: StorageContext fileName: string contentType: string + /** + * Byte size from the source metadata row - the child `workspace_files` row was inserted + * with this same size, so the storage-usage increment after a successful blob copy + * charges exactly the bytes the row advertises (matching the upload path, where the + * incremented bytes always equal the row's `size`). + */ + size: number userId: string workspaceId: string } @@ -127,6 +135,7 @@ export async function planForkFileCopies(params: { context: meta.context as StorageContext, fileName: meta.originalName, contentType: meta.contentType, + size: meta.size, userId, workspaceId: childWorkspaceId, }) @@ -145,6 +154,17 @@ export async function planForkFileCopies(params: { * blob's child storage key is returned in `failedTargetKeys` so the caller can clear the * `file-upload` references pointing at the now-missing object (the metadata row is left in * place, so the user can still re-upload the blob). + * + * Storage accounting: each blob that actually lands increments the initiating user's + * storage usage by the metadata row's size - the copied bytes are charged exactly as if + * the file had been uploaded to the target workspace. The increment cannot double-count: + * the content-copy job is at-most-once by config (`maxAttempts: 1`), each task increments + * only after its own successful upload, and the target-existence skip below means a + * manually replayed run neither re-copies nor re-charges a blob a prior attempt landed. + * Like the upload path, a tracking failure is logged and never fails the copy - and is + * never retried, so a landed blob whose increment failed stays uncounted (a manual replay + * skips it without charging). Accepted trade-off, matching the platform's upload paths: + * storage may undercount, but a user is never charged twice or for bytes that didn't land. */ export async function executeForkFileBlobCopies( blobTasks: BlobCopyTask[], @@ -155,6 +175,18 @@ export async function executeForkFileBlobCopies( const failedTargetKeys: string[] = [] for (const task of blobTasks) { try { + // Replay guard: target keys are freshly generated per fork/sync, so an existing + // object can only mean an earlier attempt already landed this exact copy. Skip + // without incrementing - a replay must never double-charge, so if the prior + // attempt's best-effort increment failed those bytes stay uncounted (the same + // accepted undercount as a tracking failure on the upload path). `headObject` + // returns null on local storage, where the copy is simply repeated (same bytes + // to the same key). + const existing = await headObject(task.targetKey, task.context) + if (existing) { + copied += 1 + continue + } const buffer = await downloadFile({ key: task.sourceKey, context: task.context, @@ -187,6 +219,17 @@ export async function executeForkFileBlobCopies( }, }) copied += 1 + // The typeof guard covers payloads enqueued before `size` existed (rolling deploy). + if (typeof task.size === 'number' && task.size > 0) { + try { + await incrementStorageUsage(task.userId, task.size, task.workspaceId) + } catch (storageError) { + logger.error(`[${requestId}] Failed to update storage tracking for copied file blob`, { + targetKey: task.targetKey, + error: getErrorMessage(storageError), + }) + } + } } catch (error) { failedTargetKeys.push(task.targetKey) logger.warn(`[${requestId}] Failed to copy file blob during fork`, { diff --git a/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts b/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts index a1ff7fb971d..e33be42700f 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts @@ -10,8 +10,15 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +const { mockIncrementStorageUsage } = vi.hoisted(() => ({ + mockIncrementStorageUsage: vi.fn(), +})) + vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +vi.mock('@/lib/billing/storage', () => ({ + incrementStorageUsage: mockIncrementStorageUsage, +})) import type { DbOrTx } from '@/lib/db/types' import { @@ -115,6 +122,24 @@ describe('copyForkResourceContent', () => { }) }) + it('#1 never touches storage accounting for copied KB document blobs (mirrors the KB upload path)', async () => { + // The normal KB upload path never increments `storage_used_bytes` (and embeddings are + // uncounted DB rows), so a fork-copied KB blob must not be charged either - copied KB + // bytes are only headroom-checked pre-fork, exactly like the multipart-initiate check. + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + }), + requestId: 'test', + }) + + expect(result.copied).toBe(1) + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) + it('#4 re-reads a copied skill body post-commit and rewrites it via db.update (never from payload)', async () => { // The body is no longer carried in the plan - the content phase keyset-re-reads the child row. dbChainMockFns.limit.mockResolvedValueOnce([ @@ -355,6 +380,101 @@ describe('copyForkResourceContainers skill copy', () => { }) }) +describe('copyForkResourceContainers knowledge-base tag definitions', () => { + /** Sequential tx mock: each select resolves the next queued row set; inserts are captured per call. */ + function makeKbTx(selects: Array>>) { + let call = 0 + const inserts: Array>> = [] + const tx = { + select: () => ({ + from: () => ({ where: () => Promise.resolve(selects[call++] ?? []) }), + }), + insert: () => ({ + values: (rows: Array>) => { + inserts.push(rows) + return Promise.resolve() + }, + }), + } + return { tx: tx as unknown as DbOrTx, inserts } + } + + const kbSelection = { + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: ['kb-1'], + } + + const sourceBase = { id: 'kb-1', name: 'Docs KB', workspaceId: 'src-ws', deletedAt: null } + + it('copies the source KB tag definitions to the child KB with fresh ids (other columns verbatim)', async () => { + const { tx, inserts } = makeKbTx([ + [sourceBase], + [ + { + id: 'tag-1', + knowledgeBaseId: 'kb-1', + tagSlot: 'tag1', + displayName: 'Category', + fieldType: 'text', + }, + { + id: 'tag-2', + knowledgeBaseId: 'kb-1', + tagSlot: 'boolean1', + displayName: 'Reviewed', + fieldType: 'boolean', + }, + ], + ]) + + const result = await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: kbSelection, + workflowIdMap: new Map(), + }) + + const childKbId = result.idMap.get('knowledge_base')?.get('kb-1') + expect(childKbId).toBeTruthy() + // insert #0 is the KB row; insert #1 is the tag-definition batch. + expect(inserts).toHaveLength(2) + const tagRows = inserts[1] + expect(tagRows).toHaveLength(2) + for (const row of tagRows) { + expect(row.knowledgeBaseId).toBe(childKbId) + expect(row.id).not.toBe('tag-1') + expect(row.id).not.toBe('tag-2') + } + expect(tagRows.map((row) => [row.tagSlot, row.displayName, row.fieldType])).toEqual([ + ['tag1', 'Category', 'text'], + ['boolean1', 'Reviewed', 'boolean'], + ]) + }) + + it('no-ops the tag-definition copy for a KB with zero definitions', async () => { + const { tx, inserts } = makeKbTx([[sourceBase], []]) + + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: kbSelection, + workflowIdMap: new Map(), + }) + + // Only the KB row itself is inserted - no empty tag-definition insert. + expect(inserts).toHaveLength(1) + }) +}) + describe('planForkMappedKbDocumentCopies', () => { const sourceRow = (id: string, knowledgeBaseId: string) => ({ id, diff --git a/apps/sim/lib/workspaces/fork/copy/copy-resources.ts b/apps/sim/lib/workspaces/fork/copy/copy-resources.ts index 5f78426b2b8..a6b3779c4ce 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-resources.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-resources.ts @@ -4,6 +4,7 @@ import { document, embedding, knowledgeBase, + knowledgeBaseTagDefinitions, skill, userTableDefinitions, userTableRows, @@ -24,6 +25,7 @@ import type { ForkMappingUpsert, ForkResourceType, } from '@/lib/workspaces/fork/mapping/mapping-store' +import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import { type ForkContentRefMaps, rewriteForkContentRefs, @@ -83,6 +85,13 @@ export interface CopyResourcesParams { * plan resolver); omitted by fork-create, which preserves env names verbatim (no rewrite). */ resolveEnvName?: (key: string) => string | null | undefined + /** + * Resolve a source block id to its target block id for copied tables' workflow-group + * `outputs[].blockId`. Promote passes the SAME persisted-pair resolver its workflow writes + * use (on push the parent keeps its ORIGINAL block ids, never the derive); fork-create + * omits it, defaulting to the deterministic derive (a fresh child has no pairs). + */ + resolveBlockId?: ForkBlockIdResolver } export interface ForkContentPlanEntry { @@ -199,8 +208,9 @@ type SkillSkeletonInsert = Omit & { conten /** * Copy the selected resources' **container rows** into the child workspace inside * the fork transaction: custom tools, skills, and MCP server configs (each a - * single row), plus table definitions and knowledge-base rows (without their bulk - * rows / documents / embeddings). This keeps the fork transaction bounded to + * single row), plus table definitions and knowledge-base rows with their tag + * definitions (bounded per KB) but without their bulk rows / documents / + * embeddings. This keeps the fork transaction bounded to * O(selected resources) single-row writes. The heavy content (table rows, KB * documents + embeddings) is returned as a {@link ForkContentPlan} for * {@link copyForkResourceContent} to copy best-effort after commit. Secrets are @@ -359,7 +369,8 @@ export async function copyForkResourceContainers( const childTableId = generateId() const remappedSchema = remapForkTableWorkflowGroups( definition.schema as TableSchema, - workflowIdMap + workflowIdMap, + params.resolveBlockId ) inserts.push({ ...definition, @@ -414,6 +425,34 @@ export async function copyForkResourceContainers( } if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts) + // Copy each source KB's tag definitions to its child so tagged documents keep a working tag + // schema: the copied documents carry tag VALUES in their slot columns, and both tag-filter + // search and documentTags writes resolve display names through these definition rows (a copy + // without them 400s / throws on every defined tag). Fresh ids, child KB id, all other columns + // verbatim - nothing persists a tag-definition id (workflow state, documents, and fork + // mappings all reference tags by display name / slot), so no id map is recorded. + if (kbEntryBySourceId.size > 0) { + const tagDefinitions = await tx + .select() + .from(knowledgeBaseTagDefinitions) + .where( + inArray(knowledgeBaseTagDefinitions.knowledgeBaseId, Array.from(kbEntryBySourceId.keys())) + ) + const tagDefinitionInserts: (typeof knowledgeBaseTagDefinitions.$inferInsert)[] = [] + for (const definition of tagDefinitions) { + const childKbId = kbEntryBySourceId.get(definition.knowledgeBaseId)?.childId + if (!childKbId) continue + tagDefinitionInserts.push({ + ...definition, + id: generateId(), + knowledgeBaseId: childKbId, + }) + } + if (tagDefinitionInserts.length > 0) { + await tx.insert(knowledgeBaseTagDefinitions).values(tagDefinitionInserts) + } + } + // Pre-create placeholder document rows for the documents the copied workflows // reference, at child ids generated inside the transaction, so each // `document-selector` reference can be remapped to a valid copied document rather diff --git a/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts b/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts index e141d65e7b4..74b7ade28f9 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts @@ -1,8 +1,22 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import { buildWorkflowNameRegistry } from '@/lib/workspaces/fork/copy/copy-workflows' +import { describe, expect, it, vi } from 'vitest' +import type { DbOrTx } from '@/lib/db/types' + +const { mockSaveWorkflowToNormalizedTables } = vi.hoisted(() => ({ + mockSaveWorkflowToNormalizedTables: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, +})) + +import { + buildWorkflowNameRegistry, + copyWorkflowStateIntoTarget, + resolveForkFolderMapping, +} from '@/lib/workspaces/fork/copy/copy-workflows' describe('buildWorkflowNameRegistry', () => { it('reports a name as taken by another workflow in the same folder', () => { @@ -60,3 +74,222 @@ describe('buildWorkflowNameRegistry', () => { expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(false) }) }) + +interface FolderRow { + id: string + name: string + userId: string + workspaceId: string + parentId: string | null + color: string | null + isExpanded: boolean + locked: boolean + sortOrder: number + createdAt: Date + updatedAt: Date + archivedAt: Date | null +} + +function folderRow(id: string, name: string, parentId: string | null = null): FolderRow { + return { + id, + name, + userId: 'source-user', + workspaceId: 'ws-source', + parentId, + color: '#6B7280', + isExpanded: true, + locked: false, + sortOrder: 0, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + archivedAt: null, + } +} + +/** + * Transaction stub for {@link resolveForkFolderMapping}: the first awaited select resolves + * the source folders, the second the target folders, and inserted rows are captured. + */ +function buildFolderTx(sourceFolders: FolderRow[], targetFolders: FolderRow[] = []) { + const insertedRows: FolderRow[] = [] + const selects = [sourceFolders, targetFolders] + let selectIndex = 0 + const tx = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(selects[selectIndex++] ?? []), + }), + }), + insert: () => ({ + values: (rows: FolderRow[]) => { + insertedRows.push(...rows) + return Promise.resolve() + }, + }), + } as unknown as DbOrTx + return { tx, insertedRows } +} + +function resolveMapping(params: { + tx: DbOrTx + contentFolderIds: ReadonlyArray +}): Promise> { + return resolveForkFolderMapping({ + tx: params.tx, + sourceWorkspaceId: 'ws-source', + targetWorkspaceId: 'ws-target', + userId: 'target-user', + now: new Date('2026-07-01'), + contentFolderIds: params.contentFolderIds, + }) +} + +describe('resolveForkFolderMapping', () => { + it('keeps the full ancestor chain of a nested folder holding a copied workflow', async () => { + const { tx, insertedRows } = buildFolderTx([ + folderRow('A', 'Alpha'), + folderRow('B', 'Beta', 'A'), + folderRow('C', 'Gamma', 'B'), + ]) + + const map = await resolveMapping({ tx, contentFolderIds: ['C'] }) + + expect(map.size).toBe(3) + expect(insertedRows).toHaveLength(3) + const byName = new Map(insertedRows.map((row) => [row.name, row])) + expect(byName.get('Alpha')?.parentId).toBeNull() + expect(byName.get('Beta')?.parentId).toBe(map.get('A')) + expect(byName.get('Gamma')?.parentId).toBe(map.get('B')) + for (const row of insertedRows) { + expect(row.workspaceId).toBe('ws-target') + expect(row.userId).toBe('target-user') + expect(row.locked).toBe(false) + expect(['A', 'B', 'C']).not.toContain(row.id) + } + }) + + it('prunes an empty sibling subtree while keeping the occupied folder', async () => { + const { tx, insertedRows } = buildFolderTx([ + folderRow('A', 'Occupied'), + folderRow('D', 'Empty parent'), + folderRow('E', 'Empty child', 'D'), + ]) + + const map = await resolveMapping({ tx, contentFolderIds: ['A'] }) + + expect(insertedRows).toHaveLength(1) + expect(insertedRows[0].name).toBe('Occupied') + expect(map.has('A')).toBe(true) + expect(map.has('D')).toBe(false) + expect(map.has('E')).toBe(false) + }) + + it('prunes a root-level empty folder when the copied workflows live at root', async () => { + const { tx, insertedRows } = buildFolderTx([folderRow('F', 'Never used')]) + + const map = await resolveMapping({ tx, contentFolderIds: [null, null] }) + + expect(insertedRows).toHaveLength(0) + expect(map.size).toBe(0) + }) + + it('creates no folders when nothing is copied into any folder', async () => { + const { tx, insertedRows } = buildFolderTx([ + folderRow('A', 'Alpha'), + folderRow('B', 'Beta', 'A'), + ]) + + const map = await resolveMapping({ tx, contentFolderIds: [] }) + + expect(insertedRows).toHaveLength(0) + expect(map.size).toBe(0) + }) + + it('reuses an existing target folder for a kept folder instead of duplicating it', async () => { + const existing = { ...folderRow('T1', 'Shared'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx([folderRow('G', 'Shared')], [existing]) + + const map = await resolveMapping({ tx, contentFolderIds: ['G'] }) + + expect(insertedRows).toHaveLength(0) + expect(map.get('G')).toBe('T1') + }) + + it('maps a pruned folder onto a matching existing target folder without creating it', async () => { + const existing = { ...folderRow('T1', 'Prior sync'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx([folderRow('P', 'Prior sync')], [existing]) + + const map = await resolveMapping({ tx, contentFolderIds: [] }) + + expect(insertedRows).toHaveLength(0) + expect(map.get('P')).toBe('T1') + }) + + it('never root-aliases a pruned nested folder onto a same-named root target folder', async () => { + // Source X is nested under unmatched P; the target's root-level "X" is unrelated. + const existing = { ...folderRow('T-root-x', 'X'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx( + [folderRow('P', 'Parent'), folderRow('X', 'X', 'P')], + [existing] + ) + + const map = await resolveMapping({ tx, contentFolderIds: [] }) + + expect(insertedRows).toHaveLength(0) + expect(map.size).toBe(0) + }) + + it('creates a kept child under a reused existing parent folder', async () => { + const existingParent = { ...folderRow('T-parent', 'Parent'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx( + [folderRow('P', 'Parent'), folderRow('C', 'Child', 'P')], + [existingParent] + ) + + const map = await resolveMapping({ tx, contentFolderIds: ['C'] }) + + expect(map.get('P')).toBe('T-parent') + expect(insertedRows).toHaveLength(1) + expect(insertedRows[0].name).toBe('Child') + expect(insertedRows[0].parentId).toBe('T-parent') + }) +}) + +describe('copyWorkflowStateIntoTarget folder fallback', () => { + it('places a copied workflow at the target root when its source folder has no mapping', async () => { + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + const insertedWorkflows: Array> = [] + const tx = { + insert: () => ({ + values: (row: Record) => { + insertedWorkflows.push(row) + return Promise.resolve() + }, + }), + } as unknown as DbOrTx + + const result = await copyWorkflowStateIntoTarget({ + tx, + targetWorkflowId: 'wf-child', + targetWorkspaceId: 'ws-target', + userId: 'target-user', + mode: 'create', + now: new Date('2026-07-01'), + sourceState: { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }, + sourceMeta: { + name: 'Orphaned placement', + description: null, + folderId: 'folder-with-no-mapping', + sortOrder: 0, + }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + nameRegistry: buildWorkflowNameRegistry([]), + }) + + expect(insertedWorkflows).toHaveLength(1) + expect(insertedWorkflows[0].folderId).toBeNull() + expect(result.name).toBe('Orphaned placement') + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts b/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts index ecb386d0d09..f1d7be3ecbb 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts @@ -45,13 +45,25 @@ interface ResolveForkFolderMappingParams { targetWorkspaceId: string userId: string now: Date + /** + * Source folder ids that will directly hold copied content (workflows); null entries + * (root-placed content) are ignored. A source folder is copied into the target only when + * its subtree contains at least one of these, so a fork/sync never creates folders that + * would end up empty. Copied workspace FILES never influence this set: they live in the + * separate `workspace_file_folders` entity and are flattened to root by the copy. + */ + contentFolderIds: ReadonlyArray } /** - * Mirror the source workspace's folder tree into the target workspace, creating - * folders as needed and reusing target folders that already match by name within - * the same (mapped) parent. Returns a map from source folder id to target folder - * id so copied workflows can be placed in the corresponding folder. + * Mirror into the target workspace the part of the source folder tree that will actually + * receive copied content: the folders in `contentFolderIds` plus their ancestor chains (so + * nesting stays intact). Target folders that already match by name within the same (mapped) + * parent are reused instead of duplicated. Folders whose subtree holds no copied content are + * pruned - never created - though a pruned folder still maps onto an existing target folder + * when one matches, so previously-synced content refs keep resolving. Returns a map from + * source folder id to target folder id; a copied workflow whose folder is absent from the + * map is placed at the target's root (see {@link copyWorkflowStateIntoTarget}). */ export async function resolveForkFolderMapping({ tx, @@ -59,6 +71,7 @@ export async function resolveForkFolderMapping({ targetWorkspaceId, userId, now, + contentFolderIds, }: ResolveForkFolderMappingParams): Promise> { const map = new Map() @@ -71,6 +84,20 @@ export async function resolveForkFolderMapping({ if (sourceFolders.length === 0) return map + const byId = new Map(sourceFolders.map((folder) => [folder.id, folder])) + + // Kept = folders that directly hold copied content plus every ancestor; everything else + // would be empty in the target and is pruned. A dangling (archived) parent ends the walk, + // matching the re-root fallback below. + const kept = new Set() + for (const folderId of contentFolderIds) { + let current = folderId ? byId.get(folderId) : undefined + while (current && !kept.has(current.id)) { + kept.add(current.id) + current = current.parentId ? byId.get(current.parentId) : undefined + } + } + const targetFolders = await tx .select() .from(workflowFolder) @@ -83,7 +110,6 @@ export async function resolveForkFolderMapping({ targetByKey.set(`${folder.parentId ?? ''}::${folder.name}`, folder.id) } - const byId = new Map(sourceFolders.map((folder) => [folder.id, folder])) const ordered: typeof sourceFolders = [] const seen = new Set() const visit = (folder: (typeof sourceFolders)[number]) => { @@ -97,13 +123,20 @@ export async function resolveForkFolderMapping({ const newFolders: (typeof sourceFolders)[number][] = [] for (const folder of ordered) { + const isKept = kept.has(folder.id) const mappedParentId = folder.parentId ? (map.get(folder.parentId) ?? null) : null const key = `${mappedParentId ?? ''}::${folder.name}` const existing = targetByKey.get(key) if (existing) { - map.set(folder.id, existing) + // A pruned folder may still MAP onto an existing target folder, but only when its + // parent chain actually resolved: an unmapped pruned parent aliases the key to root + // level, which could match an unrelated same-named root folder. + if (isKept || !folder.parentId || map.has(folder.parentId)) { + map.set(folder.id, existing) + } continue } + if (!isKept) continue const newFolderId = generateId() map.set(folder.id, newFolderId) targetByKey.set(key, newFolderId) diff --git a/apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts b/apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts new file mode 100644 index 00000000000..04f0ef050f5 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckStorageQuota } = vi.hoisted(() => ({ + mockCheckStorageQuota: vi.fn(), +})) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuota: mockCheckStorageQuota, +})) + +/** + * Minimal stand-in for the domain error so this unit test never loads the authz module's + * billing/feature-flag import chain. Shape-compatible with the real `ForkError`. + */ +vi.mock('@/lib/workspaces/fork/lineage/authz', () => ({ + ForkError: class ForkError extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.name = 'ForkError' + this.statusCode = statusCode + } + }, +})) + +import type { DbOrTx } from '@/lib/db/types' +import { + assertForkStorageHeadroom, + sumForkCopyBytes, +} from '@/lib/workspaces/fork/copy/storage-quota' +import { ForkError } from '@/lib/workspaces/fork/lineage/authz' + +/** + * Fake executor resolving one aggregate row per query, in call order. Supports both sum + * shapes: `select().from().where()` (files) and `select().from().innerJoin().where()` (KB + * documents joined to their live KB row). + */ +function makeExecutor(totals: Array) { + let call = 0 + const next = () => Promise.resolve([{ total: totals[call++] ?? 0 }]) + const select = vi.fn(() => ({ + from: () => ({ + where: next, + innerJoin: () => ({ where: next }), + }), + })) + return { executor: { select } as unknown as DbOrTx, select } +} + +describe('sumForkCopyBytes', () => { + it('adds the workspace-file and KB-document byte sums', async () => { + const { executor, select } = makeExecutor([300, 700]) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { + fileIds: ['wf-1'], + knowledgeBaseIds: ['kb-1'], + }) + + expect(bytes).toBe(1000) + expect(select).toHaveBeenCalledTimes(2) + }) + + it('coerces driver string aggregates (bigint sums) to numbers', async () => { + const { executor } = makeExecutor(['1024']) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { fileKeys: ['workspace/src/k1'] }) + + expect(bytes).toBe(1024) + }) + + it('runs no query for an empty selection', async () => { + const { executor, select } = makeExecutor([]) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { + fileIds: [], + fileKeys: [], + knowledgeBaseIds: [], + }) + + expect(bytes).toBe(0) + expect(select).not.toHaveBeenCalled() + }) + + it('skips the file query when only KBs are selected (and vice versa)', async () => { + const { executor, select } = makeExecutor([555]) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { knowledgeBaseIds: ['kb-1'] }) + + expect(bytes).toBe(555) + expect(select).toHaveBeenCalledTimes(1) + }) +}) + +describe('assertForkStorageHeadroom', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('never consults the quota helper for zero bytes', async () => { + await assertForkStorageHeadroom({ userId: 'user-1', bytes: 0 }) + expect(mockCheckStorageQuota).not.toHaveBeenCalled() + }) + + it('resolves when the scope has headroom', async () => { + mockCheckStorageQuota.mockResolvedValue({ allowed: true, currentUsage: 10, limit: 100 }) + + await expect( + assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 }) + ).resolves.toBeUndefined() + expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 50) + }) + + it("throws a 413 ForkError carrying the upload path's quota message when over quota", async () => { + mockCheckStorageQuota.mockResolvedValue({ + allowed: false, + currentUsage: 99, + limit: 100, + error: 'Storage limit exceeded. Used: 10.50GB, Limit: 10GB', + }) + + const rejection = expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 })).rejects + await rejection.toBeInstanceOf(ForkError) + await rejection.toMatchObject({ + statusCode: 413, + message: + 'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB', + }) + }) + + it('falls back to a generic storage message when the quota helper omits one', async () => { + mockCheckStorageQuota.mockResolvedValue({ allowed: false, currentUsage: 0, limit: 0 }) + + await expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 1 })).rejects.toThrow( + 'Not enough storage to copy the selected resources. Storage limit exceeded' + ) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/storage-quota.ts b/apps/sim/lib/workspaces/fork/copy/storage-quota.ts new file mode 100644 index 00000000000..91a250f1b5d --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/storage-quota.ts @@ -0,0 +1,126 @@ +import { document, knowledgeBase, workspaceFiles } from '@sim/db/schema' +import { and, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' +import { checkStorageQuota } from '@/lib/billing/storage' +import type { DbOrTx } from '@/lib/db/types' +import { ForkError } from '@/lib/workspaces/fork/lineage/authz' + +/** Resource ids whose blob bytes a fork/sync copy would duplicate into the target. */ +export interface ForkCopyBytesSelection { + /** Workspace files selected by `workspace_files.id` (the fork modal's picker shape). */ + fileIds?: string[] + /** Workspace files selected by storage key (the sync copy selection shape). */ + fileKeys?: string[] + /** Knowledge bases whose live documents' stored blobs would be re-keyed into the target. */ + knowledgeBaseIds?: string[] +} + +/** + * Byte total of the workspace-file blobs a copy selection would duplicate. Applies the + * same row filters as `planForkFileCopies` (source workspace, durable `workspace` + * context, non-deleted, id/key selectors OR'd), so the sum covers exactly the rows the + * copy would plan. + */ +async function sumWorkspaceFileBytes( + executor: DbOrTx, + sourceWorkspaceId: string, + fileIds: string[], + fileKeys: string[] +): Promise { + if (fileIds.length === 0 && fileKeys.length === 0) return 0 + const selectors = [ + fileIds.length > 0 ? inArray(workspaceFiles.id, fileIds) : undefined, + fileKeys.length > 0 ? inArray(workspaceFiles.key, fileKeys) : undefined, + ].filter((clause): clause is NonNullable => clause !== undefined) + const rows = await executor + .select({ total: sql`coalesce(sum(${workspaceFiles.size}), 0)` }) + .from(workspaceFiles) + .where( + and( + selectors.length === 1 ? selectors[0] : or(...selectors), + eq(workspaceFiles.workspaceId, sourceWorkspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + // `sum()` comes back as a string (bigint) from the driver; coerce explicitly. + return Number(rows[0]?.total ?? 0) +} + +/** + * Byte total of the KB document blobs the selected knowledge bases would re-key into the + * target. Scoped to live KBs in the source workspace (mirroring the container copy) and + * to LIVE documents with an internal blob: external/`data:` documents have a null + * `storageKey` (no blob is duplicated), and embeddings are DB rows the upload path never + * counts, so neither contributes bytes here. + */ +async function sumKbDocumentBytes( + executor: DbOrTx, + sourceWorkspaceId: string, + knowledgeBaseIds: string[] +): Promise { + if (knowledgeBaseIds.length === 0) return 0 + const rows = await executor + .select({ total: sql`coalesce(sum(${document.fileSize}), 0)` }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + inArray(knowledgeBase.id, knowledgeBaseIds), + eq(knowledgeBase.workspaceId, sourceWorkspaceId), + isNull(knowledgeBase.deletedAt), + isNull(document.deletedAt), + isNull(document.archivedAt), + isNotNull(document.storageKey) + ) + ) + return Number(rows[0]?.total ?? 0) +} + +/** + * Byte total a fork/sync copy selection would duplicate into the target: selected + * workspace-file blobs plus the selected knowledge bases' stored document blobs. Sizes + * come from the metadata rows (`workspace_files.size`, `document.file_size`) - no blob + * reads. Both sums scope to the source workspace with the same filters the copy itself + * applies, so an id that is not actually copyable can only over-count (block), never + * under-count. + */ +export async function sumForkCopyBytes( + executor: DbOrTx, + sourceWorkspaceId: string, + selection: ForkCopyBytesSelection +): Promise { + const fileBytes = await sumWorkspaceFileBytes( + executor, + sourceWorkspaceId, + selection.fileIds ?? [], + selection.fileKeys ?? [] + ) + const kbBytes = await sumKbDocumentBytes( + executor, + sourceWorkspaceId, + selection.knowledgeBaseIds ?? [] + ) + return fileBytes + kbBytes +} + +/** + * Assert the initiating user's storage scope has headroom for `bytes` of copied blobs, + * using the exact quota helper the upload path uses (`checkStorageQuota`, which resolves + * the org-pooled vs personal scope from the user's subscription and always allows when + * billing is disabled). Over quota throws a {@link ForkError} (413, matching the upload + * routes' storage-limit status) carrying the upload path's quota error message, so the + * fork/sync modals surface the same user-facing text an over-quota upload would. + */ +export async function assertForkStorageHeadroom(params: { + userId: string + bytes: number +}): Promise { + const { userId, bytes } = params + if (bytes <= 0) return + const quota = await checkStorageQuota(userId, bytes) + if (quota.allowed) return + throw new ForkError( + `Not enough storage to copy the selected resources. ${quota.error ?? 'Storage limit exceeded'}`, + 413 + ) +} diff --git a/apps/sim/lib/workspaces/fork/create-fork.test.ts b/apps/sim/lib/workspaces/fork/create-fork.test.ts new file mode 100644 index 00000000000..c2f1ee680f8 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/create-fork.test.ts @@ -0,0 +1,187 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockSumForkCopyBytes, + mockAssertForkStorageHeadroom, + mockLoadSourceDeployedStates, + mockPlanForkFileCopies, + mockCopyForkResourceContainers, + mockStartBackgroundWork, + mockFinishBackgroundWork, + mockScheduleForkContentCopy, +} = vi.hoisted(() => ({ + mockSumForkCopyBytes: vi.fn(), + mockAssertForkStorageHeadroom: vi.fn(), + mockLoadSourceDeployedStates: vi.fn(), + mockPlanForkFileCopies: vi.fn(), + mockCopyForkResourceContainers: vi.fn(), + mockStartBackgroundWork: vi.fn(), + mockFinishBackgroundWork: vi.fn(), + mockScheduleForkContentCopy: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@/lib/workflows/defaults', () => ({ + buildDefaultWorkflowArtifacts: vi.fn(() => ({ workflowState: {} })), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/background-work/store', () => ({ + startBackgroundWork: mockStartBackgroundWork, + finishBackgroundWork: mockFinishBackgroundWork, +})) +vi.mock('@/lib/workspaces/fork/copy/content-copy-runner', () => ({ + hasForkContentToCopy: vi.fn(() => false), + scheduleForkContentCopy: mockScheduleForkContentCopy, + serializeContentRefMaps: vi.fn(() => ({})), +})) +vi.mock('@/lib/workspaces/fork/copy/copy-files', () => ({ + planForkFileCopies: mockPlanForkFileCopies, +})) +vi.mock('@/lib/workspaces/fork/copy/copy-resources', () => ({ + copyForkResourceContainers: mockCopyForkResourceContainers, +})) +vi.mock('@/lib/workspaces/fork/copy/storage-quota', () => ({ + sumForkCopyBytes: mockSumForkCopyBytes, + assertForkStorageHeadroom: mockAssertForkStorageHeadroom, +})) +vi.mock('@/lib/workspaces/fork/copy/copy-workflows', () => ({ + copyWorkflowStateIntoTarget: vi.fn(), + loadWorkflowNameRegistry: vi.fn(async () => new Map()), + resolveForkFolderMapping: vi.fn(async () => new Map()), +})) +vi.mock('@/lib/workspaces/fork/copy/deploy-bridge', () => ({ + loadSourceDeployedStates: mockLoadSourceDeployedStates, +})) +vi.mock('@/lib/workspaces/fork/lineage/lineage', () => ({ + setForkLockTimeout: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/block-map-store', () => ({ + reconcileForkBlockPairs: vi.fn(), + toForkBlockPairs: vi.fn(() => []), +})) +vi.mock('@/lib/workspaces/fork/mapping/mapping-store', () => ({ + seedEdgeMappings: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/remap/fork-bootstrap', () => ({ + createForkBootstrapTransform: vi.fn(() => (subBlocks: unknown) => subBlocks), +})) +vi.mock('@/lib/workspaces/fork/remap/reference-scan', () => ({ + collectReferencedDocumentIds: vi.fn(() => new Set()), +})) +vi.mock('@/lib/workspaces/policy', () => ({ + WORKSPACE_MODE: { + PERSONAL: 'personal', + ORGANIZATION: 'organization', + GRANDFATHERED_SHARED: 'grandfathered_shared', + }, +})) + +import { createFork } from '@/lib/workspaces/fork/create-fork' + +const SOURCE = { id: 'src-ws', name: 'Parent' } as never +const POLICY = { + organizationId: null, + workspaceMode: 'personal', + billedAccountUserId: null, +} as never + +function forkParams(selection?: { + files?: string[] + knowledgeBases?: string[] +}): Parameters[0] { + return { + source: SOURCE, + policy: POLICY, + userId: 'user-1', + name: 'My Fork', + selection: { + files: selection?.files ?? [], + tables: [], + knowledgeBases: selection?.knowledgeBases ?? [], + customTools: [], + skills: [], + workflowMcpServers: [], + }, + requestId: 'test', + } +} + +describe('createFork storage headroom gate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockSumForkCopyBytes.mockResolvedValue(0) + mockAssertForkStorageHeadroom.mockResolvedValue(undefined) + mockLoadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [], + sourceStates: new Map(), + }) + mockPlanForkFileCopies.mockResolvedValue({ + keyMap: new Map(), + idMap: new Map(), + blobTasks: [], + }) + mockCopyForkResourceContainers.mockResolvedValue({ + idMap: new Map(), + mappingEntries: [], + contentPlan: { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + }, + names: { + tables: [], + knowledgeBases: [], + customTools: [], + skills: [], + workflowMcpServers: [], + }, + }) + mockStartBackgroundWork.mockResolvedValue('status-1') + mockFinishBackgroundWork.mockResolvedValue(undefined) + }) + + it('fails an over-quota fork BEFORE any read or write, with the storage error', async () => { + mockSumForkCopyBytes.mockResolvedValue(999_999) + mockAssertForkStorageHeadroom.mockRejectedValue( + new Error( + 'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB' + ) + ) + + await expect( + createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] })) + ).rejects.toThrow('Not enough storage to copy the selected resources') + + expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 }) + // Nothing was read, created, or recorded: the fork failed before all of it. + expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockStartBackgroundWork).not.toHaveBeenCalled() + }) + + it('proceeds under quota, summing exactly the selected files + knowledge bases', async () => { + mockSumForkCopyBytes.mockResolvedValue(500) + + const result = await createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] })) + + expect(result.workspace.name).toBe('My Fork') + expect(result.workflowsCopied).toBe(0) + expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', { + fileIds: ['wf-1'], + knowledgeBaseIds: ['kb-1'], + }) + expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 500 }) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/create-fork.ts b/apps/sim/lib/workspaces/fork/create-fork.ts index 0e4fce8e5de..37230f4ddd9 100644 --- a/apps/sim/lib/workspaces/fork/create-fork.ts +++ b/apps/sim/lib/workspaces/fork/create-fork.ts @@ -29,6 +29,10 @@ import { resolveForkFolderMapping, } from '@/lib/workspaces/fork/copy/copy-workflows' import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge' +import { + assertForkStorageHeadroom, + sumForkCopyBytes, +} from '@/lib/workspaces/fork/copy/storage-quota' import { buildForkWorkflowIdMap } from '@/lib/workspaces/fork/copy/workflow-id-map' import { setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage' import { @@ -111,6 +115,16 @@ export async function createFork(params: CreateForkParams): Promise child folder id map: remaps folder references in the copied workflows below and // feeds the post-commit content-ref rewrite (`sim:folder/` mentions in skill/file bodies). + // Scoped to the folders that will actually receive a copied workflow (plus ancestors): a + // fork copies only DEPLOYED workflows, so folders holding none would be created empty in + // the child and are pruned instead. Copied files don't extend this set - they use the + // separate workspace-file-folder entity and land at the child's root. const folderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId: source.id, targetWorkspaceId: childWorkspaceId, userId, now, + contentFolderIds: deployedWorkflows + .filter((wf) => workflowIdMap.has(wf.id)) + .map((wf) => wf.folderId), }) const resourceResult = await copyForkResourceContainers({ diff --git a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts index 7a9f4ad3115..a2f05cea3f4 100644 --- a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts +++ b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts @@ -205,7 +205,12 @@ export async function getForkMappingView( sourceLabel: p.sourceLabel, targetId, suggested, - required: p.reference.required, + // Every entry here is a reference a synced workflow actually carries, and a sync is + // blocked while ANY reference would clear - so every entry is required. Copyable kinds + // (table / KB / file / custom tool / skill) also satisfy the gate by being selected for + // copy; map-only kinds (credential / env-var / MCP server) and source-deleted resources + // (no copy candidate) must be mapped. + required: true, candidates, // The full (unfiltered) target list for this kind hit the cap, so the picker is // showing a partial list - the UI tells the user to refine. diff --git a/apps/sim/lib/workspaces/fork/mapping/resources.test.ts b/apps/sim/lib/workspaces/fork/mapping/resources.test.ts index b67ce351f22..a29094c217e 100644 --- a/apps/sim/lib/workspaces/fork/mapping/resources.test.ts +++ b/apps/sim/lib/workspaces/fork/mapping/resources.test.ts @@ -5,6 +5,7 @@ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import { + listForkCopyableSourceResources, listForkResourceCandidates, loadForkCopyableResourceLabels, } from '@/lib/workspaces/fork/mapping/resources' @@ -49,6 +50,75 @@ describe('listForkResourceCandidates', () => { }) }) +describe('listForkCopyableSourceResources', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('lists every sync-copyable kind, files keyed by storage key with folder grouping', async () => { + // The grouped queries resolve in Promise.all array order, each ending in `.limit()`: + // files (with folder), tables, knowledge bases, custom tools, skills. + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'file-row-1', + key: 'workspace/SRC/a.png', + label: 'a.png', + folderId: 'fld-1', + folderName: 'Images', + }, + { + id: 'file-row-2', + key: 'workspace/SRC/root.txt', + label: 'root.txt', + folderId: null, + folderName: null, + }, + ]) + .mockResolvedValueOnce([{ id: 'tbl-1', label: 'Table One' }]) + .mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }]) + .mockResolvedValueOnce([{ id: 'ct-1', label: 'Tool One' }]) + .mockResolvedValueOnce([{ id: 'sk-1', label: 'Skill One' }]) + + const result = await listForkCopyableSourceResources(executor, 'ws-src') + + expect(result).toEqual([ + // Files are addressed by STORAGE KEY (matching `file-upload` references + the promote copy + // selection), never by `workspace_files.id`, and carry their folder grouping. + { + kind: 'file', + sourceId: 'workspace/SRC/a.png', + label: 'a.png', + parentId: 'fld-1', + parentLabel: 'Images', + }, + { + kind: 'file', + sourceId: 'workspace/SRC/root.txt', + label: 'root.txt', + parentId: null, + parentLabel: null, + }, + { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, + { + kind: 'knowledge-base', + sourceId: 'kb-1', + label: 'KB One', + parentId: null, + parentLabel: null, + }, + { + kind: 'custom-tool', + sourceId: 'ct-1', + label: 'Tool One', + parentId: null, + parentLabel: null, + }, + { kind: 'skill', sourceId: 'sk-1', label: 'Skill One', parentId: null, parentLabel: null }, + ]) + }) +}) + describe('loadForkCopyableResourceLabels', () => { beforeEach(() => { resetDbChainMock() diff --git a/apps/sim/lib/workspaces/fork/mapping/resources.ts b/apps/sim/lib/workspaces/fork/mapping/resources.ts index 23b0bf1cd38..1a9702e2da9 100644 --- a/apps/sim/lib/workspaces/fork/mapping/resources.ts +++ b/apps/sim/lib/workspaces/fork/mapping/resources.ts @@ -17,7 +17,7 @@ import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm' import type { ForkCopyableKind } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import type { ForkResourceType } from '@/lib/workspaces/fork/mapping/mapping-store' -import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' +import type { ForkMcpServerMeta, ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' export interface ForkResourceCandidate { id: string @@ -329,6 +329,32 @@ export async function filterExistingForkTargets( return result } +/** + * Identity metadata (`name`/`url`) for the given MCP server ids in a workspace, looked up by + * exact id (no candidate cap, same deleted filter as the candidates). Promote uses it for the + * MAPPED TARGET servers so remapped tool-input entries rewrite their embedded server metadata + * from the target row (see {@link ForkMcpServerMeta}) - one bounded `inArray` read per sync, + * never per-entry. An id absent from the map no longer exists; its entries are left as-is. + */ +export async function getMcpServerMetaByIds( + executor: DbOrTx, + workspaceId: string, + ids: string[] +): Promise> { + if (ids.length === 0) return new Map() + const rows = await executor + .select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt), + inArray(mcpServers.id, ids) + ) + ) + return new Map(rows.map((row) => [row.id, { name: row.name, url: row.url ?? null }])) +} + /** * Provider id for each given credential id in a workspace, looked up by exact id (no * candidate cap). Presence in the returned map means the credential exists in the @@ -451,6 +477,66 @@ export interface ForkCopyableLabel { parentLabel: string | null } +/** + * One copyable resource in the sync SOURCE workspace, keyed the way the promote copy addresses + * it: files by STORAGE KEY (matching `file-upload` references + `planForkFileCopies`), every + * other kind by row id. `parentId`/`parentLabel` carry a file's folder grouping (null for + * non-file kinds and root files). + */ +export interface ForkCopyableSourceResource { + kind: ForkCopyableKind + sourceId: string + label: string + parentId: string | null + parentLabel: string | null +} + +/** + * Every copyable-kind resource in the sync source workspace (same archived/deleted filters and + * per-kind {@link CANDIDATE_LIMIT} cap as the copy picker), as sync-copy candidate entries. The + * promote plan filters these down to the UNREFERENCED-and-unmapped set it offers for copy + * alongside the referenced candidates. Covers exactly the sync-copyable kinds + * (`forkCopyableKindSchema`): workflow-publishing MCP servers are fork-copy-only (their copies + * are not recorded in the fork resource map, so a sync copy could never be idempotent) and + * external MCP servers / credentials / env vars are never copied. + */ +export async function listForkCopyableSourceResources( + executor: DbOrTx, + sourceWorkspaceId: string +): Promise { + const [files, tables, kbs, tools, skills] = await Promise.all([ + fileCandidatesWithFolderQuery(executor, sourceWorkspaceId), + tableCandidatesQuery(executor, sourceWorkspaceId), + knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId), + customToolCandidatesQuery(executor, sourceWorkspaceId), + skillCandidatesQuery(executor, sourceWorkspaceId), + ]) + const flat = ( + kind: ForkCopyableKind, + rows: Array<{ id: string; label: string }> + ): ForkCopyableSourceResource[] => + rows.map((row) => ({ + kind, + sourceId: row.id, + label: row.label, + parentId: null, + parentLabel: null, + })) + return [ + ...files.map((row) => ({ + kind: 'file' as const, + sourceId: row.key, + label: row.label, + parentId: row.folderId, + parentLabel: row.folderName, + })), + ...flat('table', tables), + ...flat('knowledge-base', kbs), + ...flat('custom-tool', tools), + ...flat('skill', skills), + ] +} + /** * Labels (by exact id) for the copyable resource kinds referenced-but-unmapped at promote time, * scoped to the source workspace and the same archived/deleted filters as the copy picker. A diff --git a/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts b/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts index 69f3adb8d01..278f421b111 100644 --- a/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts +++ b/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' // The reference indexer resolves a tool's params via the tool registry; stub it so loading the @@ -19,7 +19,30 @@ vi.mock('@/tools/params', () => ({ formatParameterLabel: (label: string) => label, })) -import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs' +// The liveness annotation + gate label loading go through the mapping resource helpers; mocked so +// each case controls which source ids read as still-alive and which labels resolve. +const { mockFilterExisting, mockLoadCopyableLabels } = vi.hoisted(() => ({ + mockFilterExisting: vi.fn(), + mockLoadCopyableLabels: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({ + filterExistingForkTargets: mockFilterExisting, + loadForkCopyableResourceLabels: mockLoadCopyableLabels, + getWorkspaceEnvKeys: vi.fn(), + listForkCopyableSourceResources: vi.fn(), + listForkResourceCandidates: vi.fn(), + getCredentialProvidersByIds: vi.fn(), + classifyCredentialResourceType: vi.fn(), + CANDIDATE_LIMIT: 1000, +})) + +import type { DbOrTx } from '@/lib/db/types' +import { + annotateForkClearedRefSourceLiveness, + collectForkClearedRefCandidates, + collectForkSyncBlockers, +} from '@/lib/workspaces/fork/promote/cleared-refs' +import { buildPromoteWorkflowIdMap } from '@/lib/workspaces/fork/promote/promote-plan' import { buildForkBlockIdResolver, deriveForkBlockId, @@ -114,6 +137,8 @@ describe('collectForkClearedRefCandidates', () => { sourceId: 'kb-src', sourceLabel: 'Docs KB', cause: 'reference', + // Collected as false; source liveness is annotated afterwards (DB check). + sourceDeleted: false, }, ]) }) @@ -271,7 +296,7 @@ describe('collectForkClearedRefCandidates', () => { expect(result).toEqual([]) }) - it('collapses the workflowId pair to the active member: a dormant basic selector is not a false cleared-ref', () => { + it('collapses the workflowId pair to the active member: manual active emits nothing, basic selector still clears', () => { vi.mocked(getBlock).mockReturnValue( blockWith([ { @@ -290,7 +315,16 @@ describe('collectForkClearedRefCandidates', () => { }, ]) ) - // Advanced mode active; the dormant basic selector holds a stale, uncopied id. + const item = { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace' as const, + sourceMeta: { name: 'Caller' }, + } + + // Advanced (manual) mode active; the dormant basic selector holds a stale, uncopied id. The + // manual member is user-owned (never cleared) and the dormant basic selector is collapsed away, + // so NO workflow cleared-ref rows even when nothing is carried into the target. const advancedState = { blocks: { 'block-1': { @@ -309,34 +343,274 @@ describe('collectForkClearedRefCandidates', () => { parallels: {}, variables: {}, } as unknown as WorkflowState + const manualActive = collectForkClearedRefCandidates( + params({ + items: [item], + sourceStates: new Map([['wf-src', advancedState]]), + sourceWorkflowNames: new Map([['wf-active', 'Active Workflow']]), + }) + ) + expect(manualActive.filter((ref) => ref.cause === 'workflow')).toEqual([]) + + // Active BASIC selector path unbroken: an uncopied selector value still produces a workflow row. + const basicState = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'workflow', + name: 'Caller', + data: { canonicalModes: { workflowId: 'basic' } }, + subBlocks: { + workflowId: { type: 'workflow-selector', value: 'wf-basic' }, + manualWorkflowId: { type: 'short-input', value: 'wf-active' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const basicActive = collectForkClearedRefCandidates( + params({ + items: [item], + sourceStates: new Map([['wf-src', basicState]]), + sourceWorkflowNames: new Map([['wf-basic', 'Basic Workflow']]), + }) + ) + const workflowRows = basicActive.filter((ref) => ref.cause === 'workflow') + expect(workflowRows).toHaveLength(1) + expect(workflowRows[0].sourceId).toBe('wf-basic') + }) + + it('collapses the workflowIds pair to the active member: a stale dormant workflowSelector array emits nothing, active basic still clears', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'workflowSelector', + title: 'Workflows', + type: 'dropdown', + canonicalParamId: 'workflowIds', + mode: 'basic', + }, + { + id: 'manualWorkflowIds', + title: 'Workflow IDs', + type: 'short-input', + canonicalParamId: 'workflowIds', + mode: 'advanced', + }, + ]) + ) const item = { sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt', mode: 'replace' as const, - sourceMeta: { name: 'Caller' }, + sourceMeta: { name: 'Logs' }, } + const stateWithModes = (canonicalModes: Record): WorkflowState => + ({ + blocks: { + 'block-1': { + id: 'block-1', + type: 'logs', + name: 'Logs', + data: { canonicalModes }, + subBlocks: { + // Switching to advanced does NOT clear the dormant basic selector, so a stale + // non-empty array persists here. + workflowSelector: { type: 'dropdown', value: ['wf-stale-1', 'wf-stale-2'] }, + manualWorkflowIds: { type: 'short-input', value: 'wf-manual' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) as unknown as WorkflowState - // Active advanced workflow carried into the target: the dormant basic must NOT produce a row. - const carried = collectForkClearedRefCandidates( + // Advanced mode active: the dormant selector's stale, unmapped ids must NOT surface as + // workflow cleared-refs (they would be unresolvable sync blockers - the modal can't map them). + const advancedActive = collectForkClearedRefCandidates( params({ items: [item], - sourceStates: new Map([['wf-src', advancedState]]), - workflowIdMap: new Map([['wf-active', 'wf-active-child']]), + sourceStates: new Map([['wf-src', stateWithModes({ workflowIds: 'advanced' })]]), + workflowIdMap: new Map(), }) ) - expect(carried.filter((ref) => ref.cause === 'workflow')).toEqual([]) + expect(advancedActive.filter((ref) => ref.cause === 'workflow')).toEqual([]) - // The ACTIVE member still produces a row when it is not carried (active path unbroken). - const cleared = collectForkClearedRefCandidates( + // Active BASIC selector path unbroken: the same unmapped ids still emit one row each. + const basicActive = collectForkClearedRefCandidates( params({ items: [item], - sourceStates: new Map([['wf-src', advancedState]]), - sourceWorkflowNames: new Map([['wf-active', 'Active Workflow']]), + sourceStates: new Map([['wf-src', stateWithModes({ workflowIds: 'basic' })]]), + workflowIdMap: new Map(), }) ) - const workflowRows = cleared.filter((ref) => ref.cause === 'workflow') - expect(workflowRows).toHaveLength(1) - expect(workflowRows[0].sourceId).toBe('wf-active') + const workflowRows = basicActive.filter((ref) => ref.cause === 'workflow') + expect(workflowRows.map((ref) => ref.sourceId)).toEqual(['wf-stale-1', 'wf-stale-2']) + }) + + // The sim workspace-event trigger's workflow filter: a multi-select `dropdown` with baseKey + // `workflowIds` (options are workspace workflow ids). Uncarried ids are dropped by the remap, + // so they must surface as workflow-cause cleared refs / sync blockers. + it('emits workflow refs for the workspace-event trigger workflowIds dropdown (uncarried ids)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflows', type: 'dropdown', multiSelect: true }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Alerts' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('sim_workspace_event', 'Workspace Events', { + workflowIds: { type: 'dropdown', value: ['wf-watched', 'wf-carried'] }, + }), + ], + ]), + workflowIdMap: new Map([['wf-carried', 'wf-carried-tgt']]), + sourceWorkflowNames: new Map([['wf-watched', 'Watched Workflow']]), + }) + ) + expect(result).toEqual([ + { + targetWorkflowId: 'wf-tgt', + workflowName: 'Alerts', + blockId: targetBlockId, + blockLabel: 'Workspace Events', + fieldLabel: 'Workflows', + kind: 'workflow', + sourceId: 'wf-watched', + sourceLabel: 'Watched Workflow', + cause: 'workflow', + }, + ]) + }) + + it('emits nothing for the trigger workflowIds when every watched workflow is carried', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflows', type: 'dropdown', multiSelect: true }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Alerts' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('sim_workspace_event', 'Workspace Events', { + workflowIds: { type: 'dropdown', value: ['wf-a', 'wf-b'] }, + }), + ], + ]), + workflowIdMap: new Map([ + ['wf-a', 'wf-a-tgt'], + ['wf-b', 'wf-b-tgt'], + ]), + }) + ) + expect(result).toEqual([]) + }) + + // The TYPE gate: the legacy logs block's `workflowIds` is a free-form short-input (manual, + // user-owned, never remapped/cleared), so it must not emit workflow cleared-refs. + it('does not treat the legacy logs short-input workflowIds as a workflow reference', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflow IDs', type: 'short-input' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Logs' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('logs', 'Logs', { + workflowIds: { type: 'short-input', value: 'wf-a,wf-b' }, + }), + ], + ]), + workflowIdMap: new Map(), + }) + ) + expect(result.filter((ref) => ref.cause === 'workflow')).toEqual([]) + }) + + it('does not emit manual manualWorkflowIds values as workflow cleared-refs', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'workflowSelector', + title: 'Workflows', + type: 'dropdown', + canonicalParamId: 'workflowIds', + mode: 'basic', + }, + { + id: 'manualWorkflowIds', + title: 'Workflow IDs', + type: 'short-input', + canonicalParamId: 'workflowIds', + mode: 'advanced', + }, + ]) + ) + // Active advanced manual list holds uncopied ids: user-owned, so never a workflow cleared-ref. + const state = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'logs', + name: 'Logs', + data: { canonicalModes: { workflowIds: 'advanced' } }, + subBlocks: { + workflowSelector: { type: 'dropdown', value: [] }, + manualWorkflowIds: { type: 'short-input', value: 'wf-a,wf-b' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Logs' }, + }, + ], + sourceStates: new Map([['wf-src', state]]), + workflowIdMap: new Map(), + }) + ) + expect(result.filter((ref) => ref.cause === 'workflow')).toEqual([]) }) it('emits a configured create-target dependent a remapped parent will clear (cause dependent)', () => { @@ -526,3 +800,542 @@ describe('collectForkClearedRefCandidates', () => { expect(result).toEqual([]) }) }) + +const replaceItem = { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace' as const, + sourceMeta: { name: 'Caller' }, +} + +/** Fake executor whose select chains resolve queued row sets in call order. */ +function makeExecutor(rowSets: unknown[][] = []) { + let call = 0 + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(rowSets[call++] ?? [])), + })), + })) + return { executor: { select } as unknown as DbOrTx, select } +} + +describe('annotateForkClearedRefSourceLiveness', () => { + beforeEach(() => { + mockFilterExisting.mockReset() + mockFilterExisting.mockResolvedValue({}) + }) + + const referenceRef = (kind: 'table' | 'knowledge-base', sourceId: string) => ({ + targetWorkflowId: 'wf-tgt', + workflowName: 'Caller', + blockId: 'b1', + blockLabel: 'Block', + fieldLabel: 'Field', + kind, + sourceId, + sourceLabel: sourceId, + cause: 'reference' as const, + sourceDeleted: false, + }) + + it('flags deleted sources and leaves live ones (checked against the SOURCE workspace)', async () => { + mockFilterExisting.mockResolvedValue({ table: new Set(['tbl-live']) }) + const { executor } = makeExecutor() + const result = await annotateForkClearedRefSourceLiveness(executor, 'src-ws', [ + referenceRef('table', 'tbl-live'), + referenceRef('table', 'tbl-gone'), + ]) + expect(mockFilterExisting).toHaveBeenCalledWith(executor, 'src-ws', { + table: new Set(['tbl-live', 'tbl-gone']), + }) + expect(result.map((ref) => (ref.cause === 'reference' ? ref.sourceDeleted : null))).toEqual([ + false, + true, + ]) + }) + + it('no-ops with zero queries when there are no reference-cause entries', async () => { + const { executor } = makeExecutor() + const workflowRef = { + targetWorkflowId: 'wf-tgt', + workflowName: 'Caller', + blockId: 'b1', + blockLabel: 'Block', + fieldLabel: 'Workflow', + kind: 'workflow' as const, + sourceId: 'wf-other', + sourceLabel: 'Other', + cause: 'workflow' as const, + } + const result = await annotateForkClearedRefSourceLiveness(executor, 'src-ws', [workflowRef]) + expect(result).toEqual([workflowRef]) + expect(mockFilterExisting).not.toHaveBeenCalled() + }) +}) + +describe('collectForkSyncBlockers', () => { + beforeEach(() => { + mockFilterExisting.mockReset() + mockLoadCopyableLabels.mockReset() + mockFilterExisting.mockResolvedValue({}) + mockLoadCopyableLabels.mockResolvedValue(new Map()) + }) + + const baseParams = (overrides: Partial[0]>) => ({ + executor: makeExecutor().executor, + sourceWorkspaceId: 'src-ws', + items: [replaceItem], + sourceStates: new Map(), + resolver: (() => null) as ForkReferenceResolver, + workflowIdMap: new Map(), + resolveBlockId, + ...overrides, + }) + + it('blocks an unmapped referenced copyable (unmapped-copyable) with its loaded label', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + mockFilterExisting.mockResolvedValue({ table: new Set(['tbl-src']) }) + mockLoadCopyableLabels.mockResolvedValue( + new Map([['table:tbl-src', { label: 'Orders', parentId: null, parentLabel: null }]]) + ) + const blockers = await collectForkSyncBlockers( + baseParams({ + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([ + { + workflowName: 'Caller', + blockLabel: 'Table Block', + fieldLabel: 'Table', + kind: 'table', + sourceId: 'tbl-src', + sourceLabel: 'Orders', + reason: 'unmapped-copyable', + }, + ]) + }) + + it('passes with ZERO queries when the resolver maps/copy-resolves every reference', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + // The promote gate overlays the copy selection onto the plan resolver; a mapped OR + // copy-selected reference resolves non-null and never reaches the blocker list. + resolver: (kind, id) => (kind === 'table' && id === 'tbl-src' ? 'tbl-copy' : null), + }) + ) + expect(blockers).toEqual([]) + expect(mockFilterExisting).not.toHaveBeenCalled() + expect(select).not.toHaveBeenCalled() + }) + + it('blocks an unmapped external MCP server (unmapped-mcp-server), named via the source read', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'server', title: 'Server', type: 'mcp-server-selector' }]) + ) + mockFilterExisting.mockResolvedValue({ 'mcp-server': new Set(['srv-1']) }) + const { executor } = makeExecutor([[{ id: 'srv-1', name: 'Internal Tools' }]]) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('mcp', 'MCP Block', { + server: { type: 'mcp-server-selector', value: 'srv-1' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'mcp-server', + sourceId: 'srv-1', + sourceLabel: 'Internal Tools', + reason: 'unmapped-mcp-server', + }), + ]) + }) + + it('blocks a source-deleted reference (source-deleted) - no exemption, resolvable by mapping', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }]) + ) + // The liveness check reports the source row gone; the copy loader (live rows only) misses, + // so the label falls back to the id. + mockFilterExisting.mockResolvedValue({ 'knowledge-base': new Set() }) + const blockers = await collectForkSyncBlockers( + baseParams({ + sourceStates: new Map([ + [ + 'wf-src', + stateWith('knowledge', 'KB Block', { + kb: { type: 'knowledge-base-selector', value: 'kb-gone' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'knowledge-base', + sourceId: 'kb-gone', + sourceLabel: 'kb-gone', + reason: 'source-deleted', + }), + ]) + // Mapping the dead id to a live target resolves it (the resolver never checks source + // liveness - a mapping row whose source row is gone still resolves). + const resolved = await collectForkSyncBlockers( + baseParams({ + sourceStates: new Map([ + [ + 'wf-src', + stateWith('knowledge', 'KB Block', { + kb: { type: 'knowledge-base-selector', value: 'kb-gone' }, + }), + ], + ]), + resolver: (kind, id) => (kind === 'knowledge-base' && id === 'kb-gone' ? 'kb-tgt' : null), + }) + ) + expect(resolved).toEqual([]) + }) + + it('blocks a workflow reference that would clear (workflow-missing), named via the source read', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const { executor } = makeExecutor([[{ id: 'wf-child', name: 'Child Flow' }]]) + // The child was deleted in the source: not an item, not in the identity map -> the map + // built by buildPromoteWorkflowIdMap misses and the reference would clear. + const workflowIdMap = buildPromoteWorkflowIdMap({ + identityMap: new Map([['wf-child', 'wf-child-tgt']]), + existingSourceIds: new Set(), + targetActiveIds: new Set(['wf-child-tgt']), + items: [{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt' }], + }) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-child' }, + }), + ], + ]), + workflowIdMap, + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'workflow', + sourceId: 'wf-child', + sourceLabel: 'Child Flow', + reason: 'workflow-missing', + }), + ]) + }) + + it('does NOT block a previously-synced, source-undeployed child (its mapping still resolves)', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const { executor, select } = makeExecutor() + // The child exists in the source (merely undeployed, so not an item this push) and its + // mapped target is still active: the identity seed repoints the reference, nothing clears. + const workflowIdMap = buildPromoteWorkflowIdMap({ + identityMap: new Map([['wf-child', 'wf-child-tgt']]), + existingSourceIds: new Set(['wf-child']), + targetActiveIds: new Set(['wf-child-tgt']), + items: [{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt' }], + }) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-child' }, + }), + ], + ]), + workflowIdMap, + }) + ) + expect(blockers).toEqual([]) + expect(select).not.toHaveBeenCalled() + }) + + it('returns identical blockers via the reused-plan path and a fresh scan, incl. an irrelevant copy selection', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + mockFilterExisting.mockResolvedValue({ table: new Set(['tbl-src']) }) + mockLoadCopyableLabels.mockResolvedValue( + new Map([['table:tbl-src', { label: 'Orders', parentId: null, parentLabel: null }]]) + ) + const sourceStates = new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]) + + const freshScan = await collectForkSyncBlockers(baseParams({ sourceStates })) + const reusedPlan = await collectForkSyncBlockers( + baseParams({ + sourceStates, + planUnmapped: [{ kind: 'table', sourceId: 'tbl-src' }], + }) + ) + // An irrelevant copy selection: the overlay resolver resolves a candidate no synced block + // references. The plan lists it as unmapped, the overlay resolves it, and the blockers are + // unchanged either way. + const overlayResolver: ForkReferenceResolver = (kind, id) => + kind === 'custom-tool' && id === 'ct-unreferenced' ? 'ct-copy' : null + const withIrrelevantCopy = await collectForkSyncBlockers( + baseParams({ + sourceStates, + resolver: overlayResolver, + planUnmapped: [ + { kind: 'table', sourceId: 'tbl-src' }, + { kind: 'custom-tool', sourceId: 'ct-unreferenced' }, + ], + }) + ) + + expect(freshScan).toEqual([ + expect.objectContaining({ kind: 'table', sourceId: 'tbl-src', reason: 'unmapped-copyable' }), + ]) + expect(reusedPlan).toEqual(freshScan) + expect(withIrrelevantCopy).toEqual(freshScan) + }) + + it('skips the per-block reference re-scan when the plan reports nothing unmapped', async () => { + // Deliberately inconsistent inputs: the state carries an unmapped table ref a fresh scan + // WOULD flag, but the supplied plan data says nothing is unmapped. The empty result proves + // the reused-plan shortcut skipped the re-scan entirely (in production the plan is computed + // over the same states inside the same tx, so the inputs can never actually diverge). + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([]) + expect(mockFilterExisting).not.toHaveBeenCalled() + expect(select).not.toHaveBeenCalled() + }) + + it('short-circuits with zero scans/queries when the copy overlay resolves every plan-unmapped ref', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + // The plan saw the ref unmapped; the gate resolver (plan resolver + copy-selection + // overlay) resolves it, so no blocking candidate can exist. + resolver: (kind, id) => (kind === 'table' && id === 'tbl-src' ? 'tbl-copy' : null), + planUnmapped: [{ kind: 'table', sourceId: 'tbl-src' }], + }) + ) + expect(blockers).toEqual([]) + expect(mockFilterExisting).not.toHaveBeenCalled() + expect(select).not.toHaveBeenCalled() + }) + + it('still blocks on a would-clear workflow reference through the reused-plan path', async () => { + // Workflow refs are not in the plan's reference scan, so the shortcut walks them separately: + // an uncarried ref must still trigger the full collection and emit workflow-missing. + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const { executor } = makeExecutor([[{ id: 'wf-child', name: 'Child Flow' }]]) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-child' }, + }), + ], + ]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'workflow', + sourceId: 'wf-child', + reason: 'workflow-missing', + }), + ]) + }) + + it('blocks on an uncarried workspace-event trigger workflowIds entry through the reused-plan path', async () => { + // Trigger workflow filters are not in the plan's reference scan, so the shortcut's light + // workflow-ref walk must detect them and trigger the full collection. + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflows', type: 'dropdown', multiSelect: true }]) + ) + const { executor } = makeExecutor([[{ id: 'wf-watched', name: 'Watched Workflow' }]]) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('sim_workspace_event', 'Workspace Events', { + workflowIds: { type: 'dropdown', value: ['wf-watched'] }, + }), + ], + ]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'workflow', + sourceId: 'wf-watched', + sourceLabel: 'Watched Workflow', + reason: 'workflow-missing', + }), + ]) + }) + + it('never blocks on a dormant workflowSelector array (advanced manual mode active)', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'workflowSelector', + title: 'Workflows', + type: 'dropdown', + canonicalParamId: 'workflowIds', + mode: 'basic', + }, + { + id: 'manualWorkflowIds', + title: 'Workflow IDs', + type: 'short-input', + canonicalParamId: 'workflowIds', + mode: 'advanced', + }, + ]) + ) + const { executor, select } = makeExecutor() + const state = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'logs', + name: 'Logs', + data: { canonicalModes: { workflowIds: 'advanced' } }, + subBlocks: { + workflowSelector: { type: 'dropdown', value: ['wf-stale'] }, + manualWorkflowIds: { type: 'short-input', value: 'wf-manual' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([['wf-src', state]]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([]) + expect(select).not.toHaveBeenCalled() + }) + + it('never blocks on dependent-cause entries (create-target dependents stay informational)', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'folder', + title: 'Label', + type: 'folder-selector', + dependsOn: ['credential'], + selectorKey: 'gmail.labels', + }, + ]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + items: [{ ...replaceItem, mode: 'create' as const }], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('gmail', 'Gmail', { + credential: { value: 'cred-src' }, + folder: { value: 'INBOX' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([]) + expect(select).not.toHaveBeenCalled() + expect(mockFilterExisting).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts b/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts index e6922731275..cb31fef852b 100644 --- a/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts +++ b/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts @@ -1,4 +1,11 @@ -import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' +import { mcpServers, workflow } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import type { + ForkClearedRef, + ForkCopyableKind, + ForkSyncBlocker, +} from '@/lib/api/contracts/workspace-fork' +import type { DbOrTx } from '@/lib/db/types' import { coerceObjectArray, isRecord, @@ -12,8 +19,18 @@ import { resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import { collectForkDependentReconfigs } from '@/lib/workspaces/fork/mapping/dependent-reconfigs' +import { + filterExistingForkTargets, + loadForkCopyableResourceLabels, +} from '@/lib/workspaces/fork/mapping/resources' +import { isForkCopyableKind } from '@/lib/workspaces/fork/promote/promote-plan' +import { + selectForkSyncBlockingRefs, + toForkSyncBlockers, +} from '@/lib/workspaces/fork/promote/sync-blockers' import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import { + type ForkReference, type ForkReferenceResolver, type ForkRemapKind, REQUIRED_KINDS, @@ -24,10 +41,12 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' /** * Remappable kinds excluded from the `reference` cleared-ref list. REQUIRED kinds (credential, - * env-var) are BLOCKERS - they gate Sync and are resolved by mapping, never silently cleared - so - * they must not read as "will be cleared" (a credential is also preserved by name once mapped, an - * env-var always). `knowledge-document` follows its parent KB - a document under an unmapped KB is - * implied by the KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. + * env-var) gate Sync through the kind-level required gate with their own messaging, so they must + * not double-report here (a credential is also preserved by name once mapped, an env-var always). + * `knowledge-document` follows its parent KB - a document under an unmapped KB is implied by the + * KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. Every other kind's + * entry IS a sync blocker (cause `reference`/`workflow`): a sync proceeds only when zero + * references would clear. */ const CLEARED_REF_EXCLUDED_KINDS = new Set([...REQUIRED_KINDS, 'knowledge-document']) @@ -59,10 +78,14 @@ function baseSubBlockId(key: string): string { } /** - * Cross-workflow references (`workflow-selector`, advanced `manualWorkflowId(s)`, multi-select - * `workflowSelector`, nested `workflow_input` tools) in a block's subBlocks. Mirrors the detection - * in {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that - * remap would clear. Returns one entry per referenced workflow id with its owning subblock key. + * Cross-workflow references (`workflow-selector`, multi-select `workflowSelector`, the + * workspace-event trigger's multi-select `workflowIds` dropdown, nested `workflow_input` tools) + * in a block's subBlocks. Mirrors the detection in + * {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that + * remap would clear - the free-form manual fields (`manualWorkflowId`, `manualWorkflowIds`) are + * user-owned and never remapped/cleared, so they are intentionally excluded (the `workflowIds` + * branch is gated on TYPE `dropdown` because the legacy logs block's `workflowIds` is a manual + * `short-input`). Returns one entry per referenced workflow id with its owning subblock key. */ function collectForkWorkflowReferences( subBlocks: SubBlockRecord, @@ -70,29 +93,42 @@ function collectForkWorkflowReferences( canonicalModes: CanonicalModeOverrides | undefined ): Array<{ workflowId: string; subBlockKey: string }> { const out: Array<{ workflowId: string; subBlockKey: string }> = [] - // Collapse the `workflowId` canonical pair (basic `workflow-selector` + advanced `manualWorkflowId`) - // to its ACTIVE member: only the active mode is serialized, so a dormant stale member is not a ref - // that would be cleared (mirrors remap-internal-ids.ts). Undefined mode -> emit both (legacy/no-pair). - const workflowGroup = config - ? buildCanonicalIndex(config.subBlocks).groupsById.workflowId - : undefined - const workflowMode = - workflowGroup && isCanonicalPair(workflowGroup) - ? resolveCanonicalMode(workflowGroup, buildSubBlockValues(subBlocks), canonicalModes) - : undefined + // Collapse each canonical pair to its ACTIVE member: only the selector members are + // remapped/cleared (the advanced `manualWorkflowId`/`manualWorkflowIds` are user-owned and + // preserved verbatim), so a DORMANT member's stale value is not a ref that would be cleared - + // it must not become an unresolvable sync blocker. Mirrors `isDormantCanonicalMember` in + // remap-references.ts: the lookup is per subblock key, so the scalar `workflowId` pair, the + // deployments block's scalar `workflowSelector` pair, and the logs block's multi-select + // `workflowSelector` (`workflowIds` group) all resolve through their OWN group. A missing + // config or a non-pair member is never skipped (legacy/no-pair states keep emitting). + const canonicalIndex = config ? buildCanonicalIndex(config.subBlocks) : undefined + const values = canonicalIndex ? buildSubBlockValues(subBlocks) : {} + const isDormantCanonicalMember = (key: string): boolean => { + if (!canonicalIndex) return false + const baseKey = baseSubBlockId(key) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[baseKey] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + if (!group || !isCanonicalPair(group)) return false + const activeMode = resolveCanonicalMode(group, values, canonicalModes) + return (activeMode === 'advanced') !== group.advancedIds.includes(baseKey) + } for (const [key, subBlock] of Object.entries(subBlocks)) { if (!subBlock || typeof subBlock !== 'object') continue const baseKey = baseSubBlockId(key) if ( - (subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') && + subBlock.type === 'workflow-selector' && typeof subBlock.value === 'string' && subBlock.value ) { - // Skip the dormant member of the pair (the active mode owns the reference). - const isAdvancedMember = baseKey === 'manualWorkflowId' - if (workflowMode && (workflowMode === 'advanced') !== isAdvancedMember) continue + // Only the SELECTOR is remapped/cleared; the manual member is user-owned and preserved + // verbatim, so skip the dormant selector when advanced/manual mode is active. + if (isDormantCanonicalMember(key)) continue out.push({ workflowId: subBlock.value, subBlockKey: key }) - } else if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') { + } else if ( + baseKey === 'workflowSelector' || + (subBlock.type === 'dropdown' && baseKey === 'workflowIds') + ) { + if (isDormantCanonicalMember(key)) continue const ids = Array.isArray(subBlock.value) ? subBlock.value : typeof subBlock.value === 'string' @@ -158,10 +194,16 @@ export function collectForkClearedRefCandidates( config?.subBlocks.find((cfg) => cfg.id === baseSubBlockId(subBlockKey))?.title ?? subBlockKey - // Cause `reference`: unmapped remappable resource refs (per block/field). + // Cause `reference`: unmapped remappable resource refs (per block/field). `blockType` + + // `canonicalModes` gate detection to the ACTIVE canonical member, matching the plan's + // reference scan - a dormant member's stale value is not a real reference, so it must not + // become a blocker with no mapping entry to resolve it. `sourceDeleted` starts false; the + // caller annotates it via {@link annotateForkClearedRefSourceLiveness} (DB check). const scan = remapForkSubBlocks(subBlocks, resolver, 'promote', { blockId: targetBlockId, blockName: blockLabel, + blockType: block.type, + canonicalModes: block.data?.canonicalModes, }) for (const ref of scan.unmapped) { if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue @@ -175,6 +217,7 @@ export function collectForkClearedRefCandidates( sourceId: ref.sourceId, sourceLabel: labelFor(ref.kind, ref.sourceId), cause: 'reference', + sourceDeleted: false, }) } @@ -231,3 +274,169 @@ export function collectForkClearedRefCandidates( return out } + +/** + * Fill each `reference`-cause entry's `sourceDeleted` flag by checking whether its resource still + * exists (not deleted/archived) in the SOURCE workspace. Reuses {@link filterExistingForkTargets} + * - a per-kind, exact-id (cap-free) liveness check with the canonical archived/deleted filters - + * pointed at the source workspace instead of a target. One batched round per kind present; a + * no-op (zero queries) when no reference-cause entries exist. Files check by storage key, matching + * how `file` references are recorded. + */ +export async function annotateForkClearedRefSourceLiveness( + executor: DbOrTx, + sourceWorkspaceId: string, + clearedRefs: ForkClearedRef[] +): Promise { + const idsByKind: Partial>> = {} + for (const ref of clearedRefs) { + if (ref.cause !== 'reference') continue + ;(idsByKind[ref.kind] ??= new Set()).add(ref.sourceId) + } + if (Object.keys(idsByKind).length === 0) return clearedRefs + const liveByKind = await filterExistingForkTargets(executor, sourceWorkspaceId, idsByKind) + return clearedRefs.map((ref) => + ref.cause === 'reference' + ? { ...ref, sourceDeleted: !(liveByKind[ref.kind]?.has(ref.sourceId) ?? false) } + : ref + ) +} + +/** Upper bound on the blockers a gate failure reports, so the error body stays sane. */ +const FORK_SYNC_BLOCKER_LIMIT = 100 + +/** + * Cheap existence check for blocking gate candidates, reusing the plan's already-computed scan + * output instead of re-running the full per-block reference scan: + * - `reference` cause: the collector detects references with the same per-block scan + * ({@link remapForkSubBlocks}) over the same source states the plan already ran, so a + * candidate exists iff some plan-unmapped reference of a non-excluded kind still resolves to + * null through the gate resolver. The gate resolver only ADDS resolutions on top of the plan + * resolver (promote's copy-selection overlay), so filtering the plan's unmapped set through it + * yields exactly the gate's unmapped set. The plan's cascade-only additions (env-var / + * credential) are excluded kinds and never contribute. + * - `workflow` cause: cross-workflow refs are not part of the plan's scan, so walk the blocks + * with the (much lighter) workflow-reference detection only, against the same workflowIdMap + * predicate the collector applies. + * `dependent`-cause candidates never block (see {@link forkSyncBlockerReasonFor}), so they are + * not checked. + */ +function hasForkSyncBlockerCandidates( + planUnmapped: ReadonlyArray>, + params: Pick< + CollectForkClearedRefsParams, + 'items' | 'sourceStates' | 'resolver' | 'workflowIdMap' + > +): boolean { + const { items, sourceStates, resolver, workflowIdMap } = params + const hasReferenceCandidate = planUnmapped.some( + (reference) => + !CLEARED_REF_EXCLUDED_KINDS.has(reference.kind) && + resolver(reference.kind, reference.sourceId) == null + ) + if (hasReferenceCandidate) return true + for (const item of items) { + const state = sourceStates.get(item.sourceWorkflowId) + if (!state) continue + for (const block of Object.values(state.blocks)) { + // double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally + // SubBlockRecord entries but lack the open index signature SubBlockRecord declares + const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord + const workflowRefs = collectForkWorkflowReferences( + subBlocks, + getBlock(block.type), + block.data?.canonicalModes + ) + if (workflowRefs.some((ref) => !workflowIdMap.has(ref.workflowId))) return true + } + } + return false +} + +/** + * The authoritative would-clear gate input for a promote: collect the cleared-ref candidates for + * the sync (against the caller's resolver, which must already account for the copy selection), + * keep the blocking causes (`reference` / `workflow` - dependents stay with the reconfigure + * flow), annotate source liveness, and return them as wire {@link ForkSyncBlocker}s with + * best-effort labels. The happy path (nothing would clear) costs ZERO queries - the collection is + * pure over the pre-read source states - and, when `planUnmapped` is supplied, ZERO re-scans of + * the blocks the plan already scanned; liveness + label reads (and the full candidate collection, + * for identical per-block/field blocker rows) run only when something blocks. Truncated to + * {@link FORK_SYNC_BLOCKER_LIMIT} entries. + */ +export async function collectForkSyncBlockers( + params: Omit & { + executor: DbOrTx + sourceWorkspaceId: string + /** + * The plan's unmapped references (`unmappedRequired` + `unmappedOptional`), when the caller + * computed the plan over the SAME `items`/`sourceStates` inside the same transaction AND the + * gate `resolver` only augments the plan's resolver (never un-resolves a plan-mapped ref) - + * promote's copy-selection overlay satisfies both. Enables the happy-path shortcut via + * {@link hasForkSyncBlockerCandidates}: the full per-block reference scan the plan already + * ran is skipped when no blocking candidate can exist, and re-run (for byte-identical blocker + * rows) when one does. Omit to always collect from scratch. + */ + planUnmapped?: ReadonlyArray> + } +): Promise { + const { executor, sourceWorkspaceId, planUnmapped, ...collectParams } = params + if (planUnmapped && !hasForkSyncBlockerCandidates(planUnmapped, collectParams)) return [] + const candidates = collectForkClearedRefCandidates({ + ...collectParams, + sourceLabels: new Map(), + sourceWorkflowNames: new Map(), + }) + if (!candidates.some((ref) => ref.cause === 'reference' || ref.cause === 'workflow')) return [] + + const annotated = await annotateForkClearedRefSourceLiveness( + executor, + sourceWorkspaceId, + candidates + ) + const blocking = selectForkSyncBlockingRefs(annotated).slice(0, FORK_SYNC_BLOCKER_LIMIT) + if (blocking.length === 0) return [] + + // Best-effort display labels (failure path only). Copyable kinds go through the shared label + // loader (live rows only - a deleted source keeps its id label); MCP servers are read without + // the deleted filter so a source-deleted server still names itself; workflow names label the + // `workflow`-cause entries. + const copyableIdsByKind: Partial> = {} + const mcpIds: string[] = [] + const workflowIds: string[] = [] + for (const { ref } of blocking) { + if (ref.cause === 'workflow') workflowIds.push(ref.sourceId) + else if (ref.kind === 'mcp-server') mcpIds.push(ref.sourceId) + else if (isForkCopyableKind(ref.kind)) (copyableIdsByKind[ref.kind] ??= []).push(ref.sourceId) + } + const [copyableLabels, mcpRows, workflowRows] = await Promise.all([ + loadForkCopyableResourceLabels(executor, sourceWorkspaceId, copyableIdsByKind), + mcpIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; name: string }>) + : executor + .select({ id: mcpServers.id, name: mcpServers.name }) + .from(mcpServers) + .where( + and(eq(mcpServers.workspaceId, sourceWorkspaceId), inArray(mcpServers.id, mcpIds)) + ), + workflowIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; name: string }>) + : executor + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where( + and(eq(workflow.workspaceId, sourceWorkspaceId), inArray(workflow.id, workflowIds)) + ), + ]) + const mcpNames = new Map(mcpRows.map((row) => [row.id, row.name])) + const workflowNames = new Map(workflowRows.map((row) => [row.id, row.name])) + const labelFor = (ref: ForkClearedRef): string => { + if (ref.cause === 'workflow') return workflowNames.get(ref.sourceId) ?? ref.sourceLabel + if (ref.kind === 'mcp-server') return mcpNames.get(ref.sourceId) ?? ref.sourceLabel + return copyableLabels.get(`${ref.kind}:${ref.sourceId}`)?.label ?? ref.sourceLabel + } + + return toForkSyncBlockers( + blocking.map(({ ref, reason }) => ({ ref: { ...ref, sourceLabel: labelFor(ref) }, reason })) + ) +} diff --git a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts index 91e0e74341c..4737665694f 100644 --- a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts +++ b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts @@ -53,16 +53,55 @@ import { isForkCopyableKind } from '@/lib/workspaces/fork/promote/promote-plan' import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' const candidates: ForkCopyableUnmapped[] = [ - { kind: 'knowledge-base', sourceId: 'kb-1', label: 'KB One', parentId: null, parentLabel: null }, - { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, - { kind: 'custom-tool', sourceId: 'ct-1', label: 'Tool One', parentId: null, parentLabel: null }, - { kind: 'skill', sourceId: 'sk-1', label: 'Skill One', parentId: null, parentLabel: null }, + { + kind: 'knowledge-base', + sourceId: 'kb-1', + label: 'KB One', + parentId: null, + parentLabel: null, + referenced: true, + }, + { + kind: 'table', + sourceId: 'tbl-1', + label: 'Table One', + parentId: null, + parentLabel: null, + referenced: true, + }, + { + kind: 'custom-tool', + sourceId: 'ct-1', + label: 'Tool One', + parentId: null, + parentLabel: null, + referenced: true, + }, + { + kind: 'skill', + sourceId: 'sk-1', + label: 'Skill One', + parentId: null, + parentLabel: null, + referenced: true, + }, { kind: 'file', sourceId: 'workspace/SRC/a.png', label: 'a.png', parentId: 'fld-1', parentLabel: 'Images', + referenced: true, + }, + // An UNREFERENCED candidate (new in the source, used by no synced workflow): selectable for + // copy exactly like a referenced one - the server treats the two identically. + { + kind: 'table', + sourceId: 'tbl-unref', + label: 'Scratch table', + parentId: null, + parentLabel: null, + referenced: false, }, ] @@ -76,7 +115,6 @@ describe('buildPromoteCopySelection', () => { expect(selection.tables).toEqual(['tbl-1']) expect(selection.customTools).toEqual(['ct-1']) expect(selection.skills).toEqual(['sk-1']) - expect(selection.workflowMcpServers).toEqual([]) expect(willResolve.has('knowledge-base:kb-1')).toBe(true) expect(willResolve.has('skill:sk-1')).toBe(true) }) @@ -106,12 +144,31 @@ describe('buildPromoteCopySelection', () => { expect(willResolve.size).toBe(0) }) + it('accepts an UNREFERENCED candidate exactly like a referenced one', () => { + // The client keeps unreferenced candidates default-unselected, but once the user opts in the + // server validates + copies them through the same path. Its willResolve key matches no + // unmapped reference (nothing references it), so the pre-copy gate is unaffected. + const { selection, willResolve } = buildPromoteCopySelection( + { tables: ['tbl-unref'] }, + candidates + ) + expect(selection.tables).toEqual(['tbl-unref']) + expect(willResolve.has('table:tbl-unref')).toBe(true) + }) + it('copy-vs-map: maps win - a mapped resource is absent from the candidates, so a copy request for it is dropped', () => { // Reconciliation precedence at the server boundary: a resource the user mapped resolves to a // target, so the plan never lists it in `copyableUnmapped`. Even if a (stale) client still // requests it for copy, only the genuinely-unmapped candidates survive - the map wins. const onlyTableUnmapped: ForkCopyableUnmapped[] = [ - { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, + { + kind: 'table', + sourceId: 'tbl-1', + label: 'Table One', + parentId: null, + parentLabel: null, + referenced: true, + }, ] const { selection, willResolve } = buildPromoteCopySelection( // kb-1 + the file were mapped (so absent from candidates); only the table remains copyable. @@ -137,7 +194,6 @@ describe('hasPromoteCopySelection', () => { hasPromoteCopySelection({ customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: ['kb-1'], files: [], @@ -147,7 +203,6 @@ describe('hasPromoteCopySelection', () => { hasPromoteCopySelection({ customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: [], @@ -157,7 +212,6 @@ describe('hasPromoteCopySelection', () => { hasPromoteCopySelection({ customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: ['workspace/SRC/file.png'], @@ -229,6 +283,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { const tx = {} as DbOrTx // Only edge.childWorkspaceId is read by the copy path. const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge + // The promote-built persisted-pair resolver; the copy must forward it verbatim so copied + // tables' workflow-group outputs land on the same block ids the workflow writes assign. + const resolveBlockId = (workflowId: string, blockId: string) => `${workflowId}:${blockId}` beforeEach(() => { vi.clearAllMocks() @@ -287,7 +344,6 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { selection: { customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: ['workspace/SRC/a.png'], @@ -295,6 +351,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { workflowIdMap: new Map(), folderIdMap: new Map([['fld-src', 'fld-dst']]), resolver: () => null, + resolveBlockId, referencedDocumentIds: [], }) @@ -323,6 +380,64 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { expect(result.contentRefMaps.fileIds).toEqual({ 'file-src': 'file-dst' }) }) + it('persists container mapping entries for copied resources (idempotency for unreferenced copies)', async () => { + // An UNREFERENCED table selected for copy flows through the same container pipeline; its + // mapping row is what makes the next sync resolve the copy instead of re-offering it. + mockCopyForkResourceContainers.mockResolvedValue({ + idMap: new Map([['table', new Map([['tbl-unref', 'tbl-copy']])]]), + mappingEntries: [ + { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, + ], + contentPlan: { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'target-ws', + userId: 'user-1', + tables: [{ sourceId: 'tbl-unref', childId: 'tbl-copy' }], + knowledgeBases: [], + skills: [], + documents: [], + }, + names: { + tables: ['Scratch table'], + knowledgeBases: [], + customTools: [], + skills: [], + workflowMcpServers: [], + }, + }) + mockPlanForkFileCopies.mockResolvedValue({ + keyMap: new Map(), + idMap: new Map(), + blobTasks: [], + }) + + await copyPromoteUnmappedResources({ + tx, + edge, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'target-ws', + direction: 'pull', + userId: 'user-1', + now: new Date(), + selection: { + customTools: [], + skills: [], + tables: ['tbl-unref'], + knowledgeBases: [], + files: [], + }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + resolver: () => null, + resolveBlockId, + referencedDocumentIds: [], + }) + + expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [ + { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, + ]) + }) + it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => { await copyPromoteUnmappedResources({ tx, @@ -335,7 +450,6 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { selection: { customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: ['kb-1'], files: [], @@ -343,13 +457,22 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { workflowIdMap: new Map(), folderIdMap: new Map(), resolver: () => null, + resolveBlockId, // The doc ids come straight from the promote plan's references; the copy must forward them, // not re-scan every source workflow state inside the locked tx. referencedDocumentIds: ['doc-1', 'doc-2'], }) expect(mockCopyForkResourceContainers).toHaveBeenCalledWith( - expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] }) + expect.objectContaining({ + referencedDocumentIds: ['doc-1', 'doc-2'], + // Workflow-publishing MCP servers are fork-create-only; a sync always passes the + // shared pipeline's slot empty (PromoteCopySelection has no such field). + selection: expect.objectContaining({ workflowMcpServers: [] }), + // The promote-built block-id resolver reaches the table remap unchanged, so copied + // tables' workflow-group outputs use the persisted-pair ids, not the derive. + resolveBlockId, + }) ) expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith( expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] }) diff --git a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts index f0080b1d46c..cf1d33d56c8 100644 --- a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts +++ b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts @@ -21,16 +21,20 @@ import { resourceTypeToForkKind, upsertEdgeMappings, } from '@/lib/workspaces/fork/mapping/mapping-store' +import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import type { ForkReferenceResolver, ForkRemapKind, } from '@/lib/workspaces/fork/remap/remap-references' -/** The source ids selected for copy at promote, validated against the plan's copyable candidates. */ +/** + * The source ids selected for copy at promote, validated against the plan's copyable + * candidates. Exactly the sync-copyable kinds (`forkCopyableKindSchema`): workflow-publishing + * MCP servers are fork-create-only (never a promote copy candidate), so they have no slot here. + */ export interface PromoteCopySelection { customTools: string[] skills: string[] - workflowMcpServers: string[] tables: string[] knowledgeBases: string[] /** Workspace files to copy, identified by storage key (not `workspace_files.id`). */ @@ -54,10 +58,11 @@ export const FORK_COPYABLE_KIND_TO_SELECTION_KEY: Record< } /** - * Intersect the user's requested copy with the plan's actual copyable candidates, so a sync can - * only copy resources that are genuinely referenced-but-unmapped + still exist in the source (a - * crafted request can never copy an arbitrary resource). Returns the validated selection plus the - * set of `${kind}:${sourceId}` references the copy will resolve, for the pre-copy sync gate. + * Intersect the user's requested copy with the plan's actual copyable candidates (referenced or + * not, always unmapped + still existing in the source), so a crafted request can never copy an + * arbitrary resource. Returns the validated selection plus the set of `${kind}:${sourceId}` + * references the copy will resolve, for the pre-copy sync gate - an unreferenced candidate's key + * simply matches no reference there, which is harmless. */ export function buildPromoteCopySelection( requested: PromoteCopyResources | undefined, @@ -72,7 +77,6 @@ export function buildPromoteCopySelection( const selection: PromoteCopySelection = { customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: [], @@ -140,8 +144,8 @@ export interface PromoteCopyResult { } /** - * Copy the referenced-but-unmapped resources a sync brings into the target (reusing the fork copy - * pipeline), then persist the source<->target id map in the direction the edge expects: a pull + * Copy the selected unmapped resources (referenced or not) a sync brings into the target (reusing + * the fork copy pipeline), then persist the source<->target id map in the direction the edge expects: a pull * fills the existing `(parent, child=null)` row (fill-null), a push replaces any prior * `(parent, child)` row keyed on the source child resource (delete-then-insert). This covers: * - the user-selected copyable containers (KB / table / custom-tool / skill) and workspace files, @@ -168,6 +172,12 @@ export async function copyPromoteUnmappedResources(params: { folderIdMap: Map /** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */ resolver: ForkReferenceResolver + /** + * The SAME block-id resolver the sync's workflow writes use (persisted pairs preferred over + * derive), so copied tables' workflow-group `outputs[].blockId` point at the blocks the sync + * actually writes - on push the parent keeps its ORIGINAL block ids, never the derive. + */ + resolveBlockId: ForkBlockIdResolver /** * Knowledge-document ids the synced workflows reference, already scanned once in the promote * plan and threaded in so the copy doesn't re-scan every source state inside the locked tx. @@ -188,6 +198,7 @@ export async function copyPromoteUnmappedResources(params: { workflowIdMap, folderIdMap, resolver, + resolveBlockId, referencedDocumentIds, } = params @@ -200,7 +211,9 @@ export async function copyPromoteUnmappedResources(params: { selection: { customTools: selection.customTools, skills: selection.skills, - workflowMcpServers: selection.workflowMcpServers, + // Workflow-publishing MCP servers are fork-create-only (never a sync-copy candidate); + // the shared copy pipeline still takes the slot, so pass it empty. + workflowMcpServers: [], tables: selection.tables, knowledgeBases: selection.knowledgeBases, }, @@ -209,6 +222,7 @@ export async function copyPromoteUnmappedResources(params: { // A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs // rewritten through the same plan resolver that remaps subblock-value env refs. resolveEnvName: (key) => resolver('env-var', key), + resolveBlockId, }) // Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob diff --git a/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts b/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts index bf3d07b1791..f01e4d44a35 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts @@ -2,11 +2,15 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { ForkCopyableLabel } from '@/lib/workspaces/fork/mapping/resources' +import type { + ForkCopyableLabel, + ForkCopyableSourceResource, +} from '@/lib/workspaces/fork/mapping/resources' import { assembleForkCopyableUnmapped, buildPromoteWorkflowIdMap, collectForkCopyableIdsByKind, + collectForkUnreferencedCopyables, } from '@/lib/workspaces/fork/promote/promote-plan' import type { ForkReference } from '@/lib/workspaces/fork/remap/remap-references' @@ -135,8 +139,16 @@ describe('assembleForkCopyableUnmapped', () => { label: 'Docs KB', parentId: null, parentLabel: null, + referenced: true, + }, + { + kind: 'file', + sourceId: 'fk-1', + label: 'a.png', + parentId: 'fld-1', + parentLabel: 'Folder', + referenced: true, }, - { kind: 'file', sourceId: 'fk-1', label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' }, ]) }) @@ -152,3 +164,93 @@ describe('assembleForkCopyableUnmapped', () => { expect(result).toEqual([]) }) }) + +describe('collectForkUnreferencedCopyables', () => { + const source = ( + kind: ForkCopyableSourceResource['kind'], + sourceId: string, + label = sourceId + ): ForkCopyableSourceResource => ({ kind, sourceId, label, parentId: null, parentLabel: null }) + + const referencedCandidate = (kind: ForkCopyableSourceResource['kind'], sourceId: string) => ({ + kind, + sourceId, + label: sourceId, + parentId: null, + parentLabel: null, + referenced: true, + }) + + it('emits an unmapped source resource no synced workflow references, flagged referenced: false', () => { + const result = collectForkUnreferencedCopyables( + [source('table', 'tbl-new', 'Scratch table')], + [], + () => null + ) + expect(result).toEqual([ + { + kind: 'table', + sourceId: 'tbl-new', + label: 'Scratch table', + parentId: null, + parentLabel: null, + referenced: false, + }, + ]) + }) + + it('dedupes against the referenced candidate set (a referenced resource is never double-listed)', () => { + const result = collectForkUnreferencedCopyables( + [source('knowledge-base', 'kb-1'), source('knowledge-base', 'kb-new')], + [referencedCandidate('knowledge-base', 'kb-1')], + () => null + ) + expect(result.map((candidate) => candidate.sourceId)).toEqual(['kb-new']) + }) + + it('excludes a resource with a persisted mapping (idempotency: a prior copy is never re-offered)', () => { + // A resource copied by a prior sync resolves through its workspace_fork_resource_map row. + const result = collectForkUnreferencedCopyables( + [source('skill', 'sk-copied'), source('skill', 'sk-new')], + [], + (kind, sourceId) => (kind === 'skill' && sourceId === 'sk-copied' ? 'sk-target' : null) + ) + expect(result.map((candidate) => candidate.sourceId)).toEqual(['sk-new']) + }) + + it('does not confuse the same id across kinds when deduping or resolving', () => { + const result = collectForkUnreferencedCopyables( + [source('table', 'shared-id'), source('skill', 'shared-id')], + [referencedCandidate('table', 'shared-id')], + () => null + ) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ kind: 'skill', sourceId: 'shared-id', referenced: false }) + }) + + it('carries a file candidate keyed by storage key with its folder grouping', () => { + const result = collectForkUnreferencedCopyables( + [ + { + kind: 'file', + sourceId: 'workspace/SRC/new.png', + label: 'new.png', + parentId: 'fld-1', + parentLabel: 'Images', + }, + ], + [], + () => null + ) + expect(result).toEqual([ + { + kind: 'file', + sourceId: 'workspace/SRC/new.png', + label: 'new.png', + parentId: 'fld-1', + parentLabel: 'Images', + referenced: false, + }, + ]) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/promote-plan.ts b/apps/sim/lib/workspaces/fork/promote/promote-plan.ts index e4d271b736a..32289c204fd 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote-plan.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote-plan.ts @@ -13,8 +13,10 @@ import { } from '@/lib/workspaces/fork/mapping/mapping-store' import { type ForkCopyableLabel, + type ForkCopyableSourceResource, filterExistingForkTargets, getWorkspaceEnvKeys, + listForkCopyableSourceResources, loadForkCopyableResourceLabels, } from '@/lib/workspaces/fork/mapping/resources' import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan' @@ -61,10 +63,13 @@ export interface ForkPromotePlan { /** Review-only descriptions of inline secrets that cannot be id-mapped. */ inlineSecretSources: string[] /** - * Referenced-but-unmapped resources of copyable kinds that still exist in the source, so a - * sync can copy them into the target instead of requiring a manual mapping (U15). Documents - * are auto-copied with their parent KB and are not listed here. `parentId`/`parentLabel` carry - * a file's folder grouping (null for non-file kinds and root files), for the nested picker. + * Unmapped resources of copyable kinds that still exist in the source, so a sync can copy + * them into the target instead of requiring a manual mapping (U15). `referenced: true` + * entries are referenced by the synced workflows (default-selected in the modal - skipping + * one clears its references); `referenced: false` entries are used by no synced workflow + * (default-unselected - skipping one breaks nothing). Documents are auto-copied with their + * parent KB and are not listed here. `parentId`/`parentLabel` carry a file's folder grouping + * (null for non-file kinds and root files), for the nested picker. */ copyableUnmapped: Array<{ kind: ForkCopyableKind @@ -72,6 +77,7 @@ export interface ForkPromotePlan { label: string parentId: string | null parentLabel: string | null + referenced: boolean }> willUpdate: number willCreate: number @@ -136,10 +142,10 @@ export function collectForkCopyableIdsByKind( } /** - * Assemble {@link ForkPromotePlan.copyableUnmapped} from the unmapped references and the loaded - * source labels: each copyable reference whose label resolved becomes a copy candidate; one whose - * label is missing (the resource no longer exists in the source) is dropped. Pure - split from the - * DB label load so it is unit-testable. + * Assemble the REFERENCED slice of {@link ForkPromotePlan.copyableUnmapped} from the unmapped + * references and the loaded source labels: each copyable reference whose label resolved becomes a + * copy candidate; one whose label is missing (the resource no longer exists in the source) is + * dropped. Pure - split from the DB label load so it is unit-testable. */ export function assembleForkCopyableUnmapped( unmappedReferences: ForkReference[], @@ -156,12 +162,36 @@ export function assembleForkCopyableUnmapped( label: entry.label, parentId: entry.parentId, parentLabel: entry.parentLabel, + referenced: true, }, ] : [] }) } +/** + * Assemble the UNREFERENCED slice of {@link ForkPromotePlan.copyableUnmapped}: every copyable + * resource in the source workspace that no synced workflow references (not in the referenced + * candidate set) and that has no target mapping for this edge (the resolver returns null). A + * previously-copied resource resolves through its persisted `workspace_fork_resource_map` row, + * so a re-sync never re-offers it (idempotency). Pure - split from the DB source listing so it + * is unit-testable. + */ +export function collectForkUnreferencedCopyables( + sourceResources: ForkCopyableSourceResource[], + referencedCopyables: ForkPromotePlan['copyableUnmapped'], + resolver: ForkReferenceResolver +): ForkPromotePlan['copyableUnmapped'] { + const referencedKeys = new Set( + referencedCopyables.map((candidate) => `${candidate.kind}:${candidate.sourceId}`) + ) + return sourceResources.flatMap((resource) => { + if (referencedKeys.has(`${resource.kind}:${resource.sourceId}`)) return [] + if (resolver(resource.kind, resource.sourceId) != null) return [] + return [{ ...resource, referenced: false }] + }) +} + /** * Compute everything a promote needs without mutating. Only the source's * **deployed** workflows participate; each plan item carries the source's active @@ -330,7 +360,15 @@ export async function computeForkPromotePlan(params: { sourceWorkspaceId, collectForkCopyableIdsByKind(allUnmapped) ) - const copyableUnmapped = assembleForkCopyableUnmapped(allUnmapped, copyableLabels) + const referencedCopyables = assembleForkCopyableUnmapped(allUnmapped, copyableLabels) + // Also offer the source's UNREFERENCED copyable resources with no target mapping (e.g. newly + // created since the fork), default-unselected in the modal. Mapped ones (including everything + // a prior sync copied) resolve non-null and drop out, so a re-sync never re-offers a copy. + const sourceCopyables = await listForkCopyableSourceResources(executor, sourceWorkspaceId) + const copyableUnmapped = [ + ...referencedCopyables, + ...collectForkUnreferencedCopyables(sourceCopyables, referencedCopyables, resolver), + ] const willUpdate = items.filter((i) => i.mode === 'replace').length const willCreate = items.filter((i) => i.mode === 'create').length diff --git a/apps/sim/lib/workspaces/fork/promote/promote.test.ts b/apps/sim/lib/workspaces/fork/promote/promote.test.ts new file mode 100644 index 00000000000..e9b2a4f7d5b --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/promote.test.ts @@ -0,0 +1,401 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ForkSyncBlocker } from '@/lib/api/contracts/workspace-fork' + +const { + mockComputePlan, + mockBuildCopySelection, + mockHasCopySelection, + mockCopyUnmapped, + mockCollectBlockers, + mockLoadBlockMap, + mockBuildBlockIdResolver, + mockResolveFolderMapping, + mockUpsertPromoteRun, + mockLoadSourceDeployedStates, + mockGetUsersWithPermissions, + mockGetMcpServerMeta, + mockCreateTransform, + mockSumForkCopyBytes, + mockAssertForkStorageHeadroom, +} = vi.hoisted(() => ({ + mockComputePlan: vi.fn(), + mockBuildCopySelection: vi.fn(), + mockHasCopySelection: vi.fn(), + mockCopyUnmapped: vi.fn(), + mockCollectBlockers: vi.fn(), + mockLoadBlockMap: vi.fn(), + mockBuildBlockIdResolver: vi.fn(), + mockResolveFolderMapping: vi.fn(), + mockUpsertPromoteRun: vi.fn(), + mockLoadSourceDeployedStates: vi.fn(), + mockGetUsersWithPermissions: vi.fn(), + mockGetMcpServerMeta: vi.fn(), + mockCreateTransform: vi.fn(), + mockSumForkCopyBytes: vi.fn(), + mockAssertForkStorageHeadroom: vi.fn(), +})) + +vi.mock('@/lib/workflows/deployment-outbox', () => ({ + enqueueWorkflowUndeploySideEffects: vi.fn(), + processWorkflowDeploymentOutboxEvent: vi.fn(), +})) +vi.mock('@/lib/workflows/orchestration/deploy', () => ({ + performFullDeploy: vi.fn(async () => ({ success: true })), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + undeployWorkflow: vi.fn(async () => ({ success: true })), +})) +vi.mock('@/lib/workspaces/fork/background-work/store', () => ({ + startBackgroundWork: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/copy/content-copy-runner', () => ({ + hasForkContentToCopy: vi.fn(() => false), + scheduleForkContentCopy: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/copy/copy-workflows', () => ({ + copyWorkflowStateIntoTarget: vi.fn(), + loadTargetDraftSubBlocks: vi.fn(async () => new Map()), + loadWorkflowNameRegistry: vi.fn(async () => new Map()), + resolveForkFolderMapping: mockResolveFolderMapping, +})) +vi.mock('@/lib/workspaces/fork/copy/storage-quota', () => ({ + sumForkCopyBytes: mockSumForkCopyBytes, + assertForkStorageHeadroom: mockAssertForkStorageHeadroom, +})) +vi.mock('@/lib/workspaces/fork/copy/deploy-bridge', () => ({ + getActiveDeploymentVersionNumbers: vi.fn(async () => new Map()), + loadSourceDeployedStates: mockLoadSourceDeployedStates, +})) +vi.mock('@/lib/workspaces/fork/lineage/lineage', () => ({ + acquireForkEdgeLock: vi.fn(), + acquireForkTargetLock: vi.fn(), + setForkLockTimeout: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/block-map-store', () => ({ + loadForkBlockMap: mockLoadBlockMap, + reconcileForkBlockPairs: vi.fn(), + toForkBlockPairs: vi.fn(() => []), +})) +vi.mock('@/lib/workspaces/fork/mapping/dependent-value-store', () => ({ + loadForkDependentValues: vi.fn(async () => []), + reconcileForkDependentValues: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/mapping-store', () => ({ + deleteWorkflowIdentityByIds: vi.fn(), + upsertEdgeMappings: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/promote/cleared-refs', () => ({ + collectForkSyncBlockers: mockCollectBlockers, +})) +vi.mock('@/lib/workspaces/fork/promote/copy-unmapped', () => ({ + augmentForkResolver: vi.fn((base) => base), + buildPromoteCopySelection: mockBuildCopySelection, + copyPromoteUnmappedResources: mockCopyUnmapped, + hasPromoteCopySelection: mockHasCopySelection, +})) +vi.mock('@/lib/workspaces/fork/promote/promote-plan', () => ({ + computeForkPromotePlan: mockComputePlan, +})) +vi.mock('@/lib/workspaces/fork/promote/promote-run-store', () => ({ + upsertPromoteRun: mockUpsertPromoteRun, +})) +vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({ + getMcpServerMetaByIds: mockGetMcpServerMeta, +})) +vi.mock('@/lib/workspaces/fork/remap/block-identity', () => ({ + buildForkBlockIdResolver: mockBuildBlockIdResolver, +})) +vi.mock('@/lib/workspaces/fork/remap/remap-references', () => ({ + createForkSubBlockTransform: mockCreateTransform, +})) +vi.mock('@/lib/workspaces/fork/socket', () => ({ + notifyForkWorkflowChanged: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUsersWithPermissions: mockGetUsersWithPermissions, +})) + +import { db } from '@sim/db' +import { promoteFork } from '@/lib/workspaces/fork/promote/promote' +import type { ForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan' + +const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' } + +const EMPTY_SELECTION = { + customTools: [], + skills: [], + tables: [], + knowledgeBases: [], + files: [], +} + +function makePlan(overrides: Partial = {}): ForkPromotePlan { + return { + childWorkspaceId: EDGE.childWorkspaceId, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'tgt-ws', + direction: 'push', + resolver: () => null, + items: [], + workflowIdMap: new Map(), + archivedTargetIds: [], + archivedTargets: [], + references: [], + unmappedRequired: [], + unmappedOptional: [], + mcpReauthServerIds: [], + inlineSecretSources: [], + copyableUnmapped: [], + willUpdate: 0, + willCreate: 0, + willArchive: 0, + ...overrides, + } +} + +const BLOCKER: ForkSyncBlocker = { + workflowName: 'Caller', + blockLabel: 'Table Block', + fieldLabel: 'Table', + kind: 'table', + sourceId: 'tbl-1', + sourceLabel: 'Orders', + reason: 'unmapped-copyable', +} + +function promoteParams() { + return { + edge: EDGE as never, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'tgt-ws', + direction: 'push' as const, + userId: 'user-1', + } +} + +describe('promoteFork gates', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(db.transaction).mockImplementation( + async (cb: (tx: unknown) => unknown) => cb({}) as never + ) + mockGetUsersWithPermissions.mockResolvedValue([]) + mockLoadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [], + sourceStates: new Map(), + }) + mockComputePlan.mockResolvedValue(makePlan()) + mockBuildCopySelection.mockReturnValue({ + selection: EMPTY_SELECTION, + willResolve: new Set(), + }) + mockHasCopySelection.mockReturnValue(false) + mockCollectBlockers.mockResolvedValue([]) + mockLoadBlockMap.mockResolvedValue(new Map()) + mockBuildBlockIdResolver.mockReturnValue((_wf: string, blockId: string) => blockId) + mockResolveFolderMapping.mockResolvedValue(new Map()) + mockUpsertPromoteRun.mockResolvedValue('run-1') + mockGetMcpServerMeta.mockResolvedValue(new Map()) + mockCreateTransform.mockReturnValue((subBlocks: unknown) => subBlocks) + mockSumForkCopyBytes.mockResolvedValue(0) + mockAssertForkStorageHeadroom.mockResolvedValue(undefined) + }) + + it('blocks an over-quota copy selection before any lock, read, or write', async () => { + mockSumForkCopyBytes.mockResolvedValue(999_999) + mockAssertForkStorageHeadroom.mockRejectedValue( + new Error( + 'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB' + ) + ) + + await expect( + promoteFork({ + ...promoteParams(), + copyResources: { files: ['workspace/src-ws/key-1'], knowledgeBases: ['kb-1'] }, + }) + ).rejects.toThrow('Not enough storage to copy the selected resources') + + expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 }) + // Fails fast: no source-state loads, no locked transaction, no writes of any kind. + expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + expect(mockUpsertPromoteRun).not.toHaveBeenCalled() + }) + + it('sums the requested copy selection bytes against the SOURCE workspace (files by key, KBs by id)', async () => { + await promoteFork({ + ...promoteParams(), + copyResources: { + files: ['workspace/src-ws/key-1'], + knowledgeBases: ['kb-1'], + tables: ['tbl-1'], + }, + }) + + expect(mockSumForkCopyBytes).toHaveBeenCalledTimes(1) + expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', { + fileKeys: ['workspace/src-ws/key-1'], + knowledgeBaseIds: ['kb-1'], + }) + }) + + it('blocks on unmapped required credentials/secrets BEFORE the cleared-refs gate runs', async () => { + mockComputePlan.mockResolvedValue( + makePlan({ + unmappedRequired: [ + { kind: 'credential', sourceId: 'c1', subBlockKey: 'credential', required: true }, + ], + }) + ) + + const result = await promoteFork(promoteParams()) + + expect(result.blocked).toBe('unmapped') + expect(result.unmappedRequired).toEqual([ + { kind: 'credential', sourceId: 'c1', required: true, blockName: undefined }, + ]) + expect(result.blockers).toEqual([]) + expect(mockCollectBlockers).not.toHaveBeenCalled() + expect(mockResolveFolderMapping).not.toHaveBeenCalled() + expect(mockUpsertPromoteRun).not.toHaveBeenCalled() + }) + + it('blocks with the structured blocker list when references would clear, writing NOTHING', async () => { + mockCollectBlockers.mockResolvedValue([BLOCKER]) + + const result = await promoteFork(promoteParams()) + + expect(result.blocked).toBe('cleared-refs') + expect(result.blockers).toEqual([BLOCKER]) + expect(result.promoteRunId).toBe('') + expect(result.updated).toBe(0) + expect(result.created).toBe(0) + expect(result.archived).toBe(0) + // Blocked before the first write: no folder creation, no resource copy, no undo point. + expect(mockResolveFolderMapping).not.toHaveBeenCalled() + expect(mockCopyUnmapped).not.toHaveBeenCalled() + expect(mockUpsertPromoteRun).not.toHaveBeenCalled() + }) + + it('evaluates the gate against the plan resolver overlaid with the copy selection', async () => { + const planResolver = vi.fn(() => 'plan-resolved') + mockComputePlan.mockResolvedValue(makePlan({ resolver: planResolver })) + mockBuildCopySelection.mockReturnValue({ + selection: EMPTY_SELECTION, + willResolve: new Set(['table:t1']), + }) + + await promoteFork(promoteParams()) + + expect(mockCollectBlockers).toHaveBeenCalledTimes(1) + const gateParams = mockCollectBlockers.mock.calls[0][0] + // A copy-selected reference resolves through the overlay (never hits the plan resolver); + // everything else falls through to the plan's persisted-mapping resolver. + expect(gateParams.resolver('table', 't1')).toBe('t1') + expect(planResolver).not.toHaveBeenCalled() + expect(gateParams.resolver('table', 't2')).toBe('plan-resolved') + expect(planResolver).toHaveBeenCalledWith('table', 't2') + }) + + it('threads the SAME block-id resolver into the gate and the resource copy as the workflow writes', async () => { + // Copied tables' workflow-group outputs must land on the block ids the sync actually writes + // (persisted pairs preferred over derive), so the copy receives the resolver built from the + // loaded block map - the identical instance the cleared-refs gate uses. + const resolver = (_workflowId: string, blockId: string) => `pair-${blockId}` + mockBuildBlockIdResolver.mockReturnValue(resolver) + mockHasCopySelection.mockReturnValue(true) + mockCopyUnmapped.mockResolvedValue({ + contentPlan: { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'tgt-ws', + userId: 'user-1', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + }, + copyIdMapByKind: new Map(), + contentRefMaps: {}, + blobTasks: [], + }) + + await promoteFork(promoteParams()) + + expect(mockCopyUnmapped).toHaveBeenCalledTimes(1) + expect(mockCopyUnmapped.mock.calls[0][0].resolveBlockId).toBe(resolver) + expect(mockCollectBlockers.mock.calls[0][0].resolveBlockId).toBe(resolver) + }) + + it('proceeds when zero references would clear (empty blocker list)', async () => { + const plan = makePlan() + mockComputePlan.mockResolvedValue(plan) + + const result = await promoteFork(promoteParams()) + + expect(result.blocked).toBeNull() + expect(result.blockers).toEqual([]) + expect(result.promoteRunId).toBe('run-1') + expect(mockCollectBlockers).toHaveBeenCalledWith( + expect.objectContaining({ + sourceWorkspaceId: 'src-ws', + items: plan.items, + workflowIdMap: plan.workflowIdMap, + }) + ) + expect(mockUpsertPromoteRun).toHaveBeenCalledTimes(1) + }) + + it("threads the plan's unmapped references into the gate so it can reuse the plan's scan", async () => { + const unmappedOptional = [ + { kind: 'table' as const, sourceId: 'tbl-1', subBlockKey: 'tbl', required: false }, + ] + mockComputePlan.mockResolvedValue(makePlan({ unmappedOptional })) + + await promoteFork(promoteParams()) + + expect(mockCollectBlockers).toHaveBeenCalledWith( + expect.objectContaining({ planUnmapped: unmappedOptional }) + ) + }) + + it('batch-loads the mapped TARGET MCP server rows and threads them into the subblock transform', async () => { + // Two references resolving to the SAME target and one unmapped: the read must cover the + // distinct mapped target ids only (one bounded query, unmapped ids dropped). + const resolver = (kind: string, id: string) => { + if (kind !== 'mcp-server') return null + if (id === 'srv-a' || id === 'srv-b') return 'srv-tgt' + return null + } + mockComputePlan.mockResolvedValue( + makePlan({ + resolver, + references: [ + { kind: 'mcp-server', sourceId: 'srv-a', subBlockKey: 'tools', required: false }, + { kind: 'mcp-server', sourceId: 'srv-b', subBlockKey: 'server', required: false }, + { kind: 'mcp-server', sourceId: 'srv-unmapped', subBlockKey: 'tools', required: false }, + ], + }) + ) + mockGetMcpServerMeta.mockResolvedValue( + new Map([['srv-tgt', { name: 'Target Server', url: 'https://target.example/mcp' }]]) + ) + + await promoteFork(promoteParams()) + + expect(mockGetMcpServerMeta).toHaveBeenCalledTimes(1) + expect(mockGetMcpServerMeta).toHaveBeenCalledWith(expect.anything(), 'tgt-ws', ['srv-tgt']) + // The transform receives a lookup resolving the TARGET id to its row metadata, so remapped + // tool-input entries rewrite their embedded serverUrl/serverName from the target server. + expect(mockCreateTransform).toHaveBeenCalledTimes(1) + const [, transformOptions] = mockCreateTransform.mock.calls[0] + expect(transformOptions.resolveMcpServerMeta('srv-tgt')).toEqual({ + name: 'Target Server', + url: 'https://target.example/mcp', + }) + expect(transformOptions.resolveMcpServerMeta('srv-unknown')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/promote.ts b/apps/sim/lib/workspaces/fork/promote/promote.ts index ad6e5d792c6..c475e8f7d25 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' -import type { PromoteCopyResources } from '@/lib/api/contracts/workspace-fork' +import type { ForkSyncBlocker, PromoteCopyResources } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import { enqueueWorkflowUndeploySideEffects, @@ -31,6 +31,10 @@ import { getActiveDeploymentVersionNumbers, loadSourceDeployedStates, } from '@/lib/workspaces/fork/copy/deploy-bridge' +import { + assertForkStorageHeadroom, + sumForkCopyBytes, +} from '@/lib/workspaces/fork/copy/storage-quota' import { acquireForkEdgeLock, acquireForkTargetLock, @@ -53,6 +57,8 @@ import { type ForkMappingUpsert, upsertEdgeMappings, } from '@/lib/workspaces/fork/mapping/mapping-store' +import { getMcpServerMetaByIds } from '@/lib/workspaces/fork/mapping/resources' +import { collectForkSyncBlockers } from '@/lib/workspaces/fork/promote/cleared-refs' import { augmentForkResolver, buildPromoteCopySelection, @@ -71,6 +77,7 @@ import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-iden import { createForkSubBlockTransform, type ForkReference, + type ForkReferenceResolver, } from '@/lib/workspaces/fork/remap/remap-references' import { notifyForkWorkflowChanged } from '@/lib/workspaces/fork/socket' import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils' @@ -99,9 +106,10 @@ export interface PromoteForkParams { value: string }> /** - * Referenced-but-unmapped resources (by source id) the caller chose to copy into the target - * before the sync gate. Validated against the plan's copyable candidates, so an arbitrary id is - * ignored. Each copied resource's references then resolve to the new copy instead of blocking. + * Unmapped resources (by source id) the caller chose to copy into the target before the sync + * gate - referenced ones (their references then resolve to the new copy instead of blocking) + * and unreferenced ones (new in the source, brought along untouched). Validated against the + * plan's copyable candidates, so an arbitrary id is ignored. */ copyResources?: PromoteCopyResources requestId?: string @@ -120,7 +128,14 @@ export interface PromoteForkResult { */ deployFailed: number unmappedRequired: Array> - blocked: 'unmapped' | null + /** + * References the sync would have cleared in the target, so it was blocked without writing + * (`blocked: 'cleared-refs'`). The authoritative in-tx re-check of the diff's would-clear + * preview: normally the client blocks first, so a non-empty list means the state changed + * between preview and Sync. + */ + blockers: ForkSyncBlocker[] + blocked: 'unmapped' | 'cleared-refs' | null /** Names of the workflows the sync changed, by action, for the activity report. */ updatedNames: string[] createdNames: string[] @@ -256,10 +271,9 @@ async function propagateCredentialAccess( } } -interface PromoteTxBlocked { - blocked: 'unmapped' - unmappedRequired: PromoteForkResult['unmappedRequired'] -} +type PromoteTxBlocked = + | { blocked: 'unmapped'; unmappedRequired: PromoteForkResult['unmappedRequired'] } + | { blocked: 'cleared-refs'; blockers: ForkSyncBlocker[] } interface PromoteTxApplied { blocked: null @@ -329,7 +343,9 @@ function groupDependentOverrides( * propagated, and every promoted target is deployed. The plan is computed inside * the edge lock so concurrent promotes serialize. A sync always force-replaces the * target's deployed state (the modal confirms the overwrite up front); it blocks - * without mutating only when required references are unmapped. + * without mutating when required references (credentials / secrets) are unmapped OR + * when any reference would clear in a synced target workflow (the zero-cleared-refs + * gate - every reference must be mapped, selected for copy, or carried by the sync). */ export async function promoteFork(params: PromoteForkParams): Promise { const { edge, sourceWorkspaceId, targetWorkspaceId, direction, userId } = params @@ -352,6 +368,19 @@ export async function promoteFork(params: PromoteForkParams): Promise m.userId) // Read the source's deployed workflows + states BEFORE the transaction so these @@ -382,10 +411,10 @@ export async function promoteFork(params: PromoteForkParams): Promise target fully operational). Evaluated + // against the plan resolver overlaid with the validated copy selection (a selected copy + // resolves its references), BEFORE any write. Authoritative versus the diff's unlocked + // preview - state drift between preview and Sync re-blocks here (TOCTOU) - and it makes the + // in-tx remap's clear-unresolved behavior an unreachable defense-in-depth backstop. The + // plan's unmapped references are threaded through so the gate's happy path reuses the plan's + // scan (computed moments earlier over the same states, inside this same locked tx) instead of + // re-running the full per-block reference scan; the scan re-runs only when something blocks. + const gateResolver: ForkReferenceResolver = (kind, sourceId) => + willResolve.has(`${kind}:${sourceId}`) ? sourceId : plan.resolver(kind, sourceId) + const blockers = await collectForkSyncBlockers({ + executor: tx, + sourceWorkspaceId, + items: plan.items, + sourceStates, + resolver: gateResolver, + workflowIdMap: plan.workflowIdMap, + resolveBlockId, + planUnmapped: [...plan.unmappedRequired, ...plan.unmappedOptional], + }) + if (blockers.length > 0) { + return { blocked: 'cleared-refs', blockers } + } + // Resolve the source->target folder map BEFORE the copy so the folders already exist in the // target and the copy can rewrite `sim:folder/` references inside copied skill / markdown // bodies (the post-commit content rewrite reads this map). Idempotent: it reuses target - // folders that already match by name within the same mapped parent. + // folders that already match by name within the same mapped parent. Creation is scoped to + // the folders that will hold a synced workflow (plus ancestors) - a folder whose subtree + // syncs nothing is never created empty in the target, though it still maps onto a matching + // existing target folder so prior syncs' refs keep resolving. const folderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId, targetWorkspaceId, userId, now, + contentFolderIds: plan.items.map((item) => item.sourceMeta.folderId), }) let resolver = plan.resolver @@ -445,6 +512,9 @@ export async function promoteFork(params: PromoteForkParams): Promise reference.kind === 'mcp-server') + .map((reference) => resolver('mcp-server', reference.sourceId)) + .filter((targetId): targetId is string => targetId != null) + ), + ] + const mcpServerMetaById = await getMcpServerMetaByIds( + tx, + targetWorkspaceId, + mappedMcpServerTargetIds + ) + + const transform = createForkSubBlockTransform(resolver, { + resolveMcpServerMeta: (targetServerId) => mcpServerMetaById.get(targetServerId), + }) // Batch every prior-version read (replace + archive targets) into one query before any // write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so @@ -491,13 +583,8 @@ export async function promoteFork(params: PromoteForkParams): Promise +type DependentRef = Extract + +const base = { + targetWorkflowId: 'wf-tgt', + workflowName: 'Workflow', + blockId: 'block-1', + blockLabel: 'Block', + fieldLabel: 'Field', + sourceLabel: 'Source', +} + +const referenceRef = ( + kind: ReferenceRef['kind'], + sourceId: string, + sourceDeleted = false +): ReferenceRef => ({ ...base, cause: 'reference', kind, sourceId, sourceDeleted }) + +const workflowRef = (sourceId: string): ForkClearedRef => ({ + ...base, + cause: 'workflow', + kind: 'workflow', + sourceId, +}) + +const dependentRef = (parentKind: DependentRef['parentKind']): DependentRef => ({ + ...base, + cause: 'dependent', + kind: parentKind, + sourceId: 'parent-src', + parentKind, + parentSourceId: 'parent-src', +}) + +describe('forkSyncBlockerReasonFor', () => { + it('maps a live unmapped copyable-kind reference to unmapped-copyable (map or copy)', () => { + for (const kind of ['table', 'knowledge-base', 'file', 'custom-tool', 'skill'] as const) { + expect(forkSyncBlockerReasonFor(referenceRef(kind, 'src-1'))).toBe('unmapped-copyable') + } + }) + + it('maps a live unmapped MCP server to unmapped-mcp-server (map-only; no copy option)', () => { + expect(forkSyncBlockerReasonFor(referenceRef('mcp-server', 'srv-1'))).toBe( + 'unmapped-mcp-server' + ) + }) + + it('maps a source-deleted reference of ANY kind to source-deleted (no exemption)', () => { + expect(forkSyncBlockerReasonFor(referenceRef('table', 'tbl-gone', true))).toBe('source-deleted') + expect(forkSyncBlockerReasonFor(referenceRef('mcp-server', 'srv-gone', true))).toBe( + 'source-deleted' + ) + expect(forkSyncBlockerReasonFor(referenceRef('file', 'workspace/SRC/gone.png', true))).toBe( + 'source-deleted' + ) + }) + + it('maps a workflow-cause entry to workflow-missing', () => { + expect(forkSyncBlockerReasonFor(workflowRef('wf-other'))).toBe('workflow-missing') + }) + + it('never blocks a dependent-cause entry (the reconfigure flow owns dependents)', () => { + expect(forkSyncBlockerReasonFor(dependentRef('credential'))).toBeNull() + expect(forkSyncBlockerReasonFor(dependentRef('knowledge-base'))).toBeNull() + }) + + it('defensively ignores kinds the collector excludes (credential / env-var / document)', () => { + // These never reach the cleared list (excluded by the collector); if one leaked, the + // kind-level required gate owns credentials/env-vars, so this path must not double-block. + expect(forkSyncBlockerReasonFor(referenceRef('credential', 'c1'))).toBeNull() + expect(forkSyncBlockerReasonFor(referenceRef('env-var', 'KEY'))).toBeNull() + expect(forkSyncBlockerReasonFor(referenceRef('knowledge-document', 'doc-1'))).toBeNull() + }) +}) + +describe('selectForkSyncBlockingRefs / toForkSyncBlockers', () => { + it('keeps reference + workflow causes with their reasons and drops dependents', () => { + const refs: ForkClearedRef[] = [ + referenceRef('table', 'tbl-1'), + referenceRef('mcp-server', 'srv-1'), + referenceRef('skill', 'sk-gone', true), + workflowRef('wf-other'), + dependentRef('credential'), + ] + const blocking = selectForkSyncBlockingRefs(refs) + expect(blocking.map(({ ref, reason }) => [ref.sourceId, reason])).toEqual([ + ['tbl-1', 'unmapped-copyable'], + ['srv-1', 'unmapped-mcp-server'], + ['sk-gone', 'source-deleted'], + ['wf-other', 'workflow-missing'], + ]) + }) + + it('maps blocking entries to the wire blocker shape', () => { + const blocking = selectForkSyncBlockingRefs([referenceRef('table', 'tbl-1')]) + expect(toForkSyncBlockers(blocking)).toEqual([ + { + workflowName: 'Workflow', + blockLabel: 'Block', + fieldLabel: 'Field', + kind: 'table', + sourceId: 'tbl-1', + sourceLabel: 'Source', + reason: 'unmapped-copyable', + }, + ]) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/sync-blockers.ts b/apps/sim/lib/workspaces/fork/promote/sync-blockers.ts new file mode 100644 index 00000000000..b5d2bbed6c2 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/sync-blockers.ts @@ -0,0 +1,65 @@ +import { + type ForkClearedRef, + type ForkSyncBlocker, + type ForkSyncBlockerReason, + forkCopyableKindSchema, +} from '@/lib/api/contracts/workspace-fork' + +/** + * Pure sync-blocker taxonomy, shared by the server gate (promote) and the modal's blocker + * rendering. A sync is allowed only when ZERO references would clear in any synced target + * workflow; every would-clear entry of cause `reference` or `workflow` is a blocker with an + * actionable reason. `dependent`-cause entries are NOT blockers - the dependent/reconfigure + * flow owns them (its own required gating), and a credential-anchored dependent clears on any + * parent remap, so blocking on it would be unresolvable. + */ + +/** Copyable kinds derived from the wire contract, so the reason split can never drift. */ +const COPYABLE_BLOCKER_KINDS: ReadonlySet = new Set(forkCopyableKindSchema.options) + +/** + * The blocker reason for a would-clear entry, or null when the entry does not block + * (`dependent` cause, and - defensively - any kind the cleared-ref collector excludes): + * - `workflow` cause -> `workflow-missing` (deploy the referenced workflow in the source, or + * remove the reference). + * - `reference` + source deleted -> `source-deleted` (map the dead id to a live target + * resource, or fix/archive the source workflow). + * - `reference` + external MCP server -> `unmapped-mcp-server` (map it; MCP servers are never + * copied). + * - `reference` + copyable kind -> `unmapped-copyable` (map it or select it for copy). + */ +export function forkSyncBlockerReasonFor(ref: ForkClearedRef): ForkSyncBlockerReason | null { + if (ref.cause === 'workflow') return 'workflow-missing' + if (ref.cause !== 'reference') return null + if (ref.sourceDeleted) return 'source-deleted' + if (ref.kind === 'mcp-server') return 'unmapped-mcp-server' + if (COPYABLE_BLOCKER_KINDS.has(ref.kind)) return 'unmapped-copyable' + // Credential / env-var / knowledge-document never reach the cleared list (excluded by the + // collector; the first two gate via the kind-level required gate, documents follow their KB). + return null +} + +/** The would-clear entries that BLOCK the sync, paired with their reason. */ +export function selectForkSyncBlockingRefs( + clearedRefs: ForkClearedRef[] +): Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }> { + return clearedRefs.flatMap((ref) => { + const reason = forkSyncBlockerReasonFor(ref) + return reason ? [{ ref, reason }] : [] + }) +} + +/** Map blocking entries to the wire {@link ForkSyncBlocker} shape of the promote gate error. */ +export function toForkSyncBlockers( + blocking: Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }> +): ForkSyncBlocker[] { + return blocking.map(({ ref, reason }) => ({ + workflowName: ref.workflowName, + blockLabel: ref.blockLabel, + fieldLabel: ref.fieldLabel, + kind: ref.kind, + sourceId: ref.sourceId, + sourceLabel: ref.sourceLabel, + reason, + })) +} diff --git a/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts b/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts index 849273501aa..d549381d74a 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts @@ -25,6 +25,7 @@ import { applyDependentOverrides, clearDependentsOnRemap, collectClearedDependents, + createForkSubBlockTransform, parseNestedDependentKey, readTargetDraftDependentValue, remapForkSubBlocks, @@ -413,6 +414,263 @@ describe('createForkBootstrapTransform document-selector remap', () => { }) }) +describe('MCP block server remap follows the tool selection (optimistic verbatim)', () => { + // Shape of the real MCP block: tool depends on server, arguments depend on tool. + const mcpBlock = () => + blockWith([ + { id: 'server', title: 'MCP Server', type: 'mcp-server-selector', required: true }, + { + id: 'tool', + title: 'Tool', + type: 'mcp-tool-selector', + required: true, + dependsOn: ['server'], + }, + { id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] }, + ]) + const mcpSubBlocks = (): SubBlockRecord => ({ + server: { id: 'server', type: 'mcp-server-selector', value: 'mcp-src1' }, + tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-src1-search_docs' }, + arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' }, + }) + const mapServer = (kind: string, id: string) => + kind === 'mcp-server' && id === 'mcp-src1' ? 'mcp-tgt9' : null + + it('sync transform: keeps the tool (embedded server id swapped, name verbatim) and its arguments', () => { + // The same transform serves BOTH create- and replace-mode sync targets, so a freshly + // created target deploys with the tool intact instead of an empty required field. + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const transform = createForkSubBlockTransform(mapServer) + const result = transform(mcpSubBlocks(), 'mcp') + expect(result.server.value).toBe('mcp-tgt9') + expect(result.tool.value).toBe('mcp-tgt9-search_docs') + expect(result.arguments.value).toBe('{"query":"hello"}') + }) + + it('keeps a bare tool name (no embedded server id) verbatim under the remapped server', () => { + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const subBlocks = mcpSubBlocks() + subBlocks.tool = { id: 'tool', type: 'mcp-tool-selector', value: 'search_docs' } + const transform = createForkSubBlockTransform(mapServer) + const result = transform(subBlocks, 'mcp') + expect(result.server.value).toBe('mcp-tgt9') + expect(result.tool.value).toBe('search_docs') + expect(result.arguments.value).toBe('{"query":"hello"}') + }) + + it('sync transform: an UNMAPPED server is cleared and still clears tool + arguments (defense-in-depth)', () => { + // The zero-cleared-refs gate blocks a sync before this state can persist; the remap's + // clear-unresolved backstop must still never leave a tool under a cleared server. + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const transform = createForkSubBlockTransform(() => null) + const result = transform(mcpSubBlocks(), 'mcp') + expect(result.server.value).toBe('') + expect(result.tool.value).toBe('') + expect(result.arguments.value).toBe('') + }) + + it('fork-create: servers are not copied, so the reference clears and dependents clear with it', () => { + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const transform = createForkBootstrapTransform(() => null) + const result = transform(mcpSubBlocks(), 'mcp') + expect(result.server.value).toBe('') + expect(result.tool.value).toBe('') + expect(result.arguments.value).toBe('') + }) + + it('remap layer: the tool follow-rewrite is not registered as a remapped parent key', () => { + // Only `server` may drive dependent clears; the followed tool must not (its own + // dependent - arguments - is preserved with it). + const result = remapForkSubBlocks(mcpSubBlocks(), mapServer, 'promote') + expect(result.subBlocks.tool.value).toBe('mcp-tgt9-search_docs') + expect(result.remappedKeys).toEqual(new Set(['server'])) + }) + + it('clearDependentsOnRemap: exemption applies ONLY to the mcp tool selector, not other kinds', () => { + // A knowledge-base parent remapped to a non-empty target still clears its + // document-selector dependent (regression guard for the mcp-only exemption). + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { + id: 'documentId', + title: 'Doc', + type: 'document-selector', + dependsOn: ['knowledgeBaseId'], + }, + ]) + ) + const result = clearDependentsOnRemap( + { + knowledgeBaseId: { + id: 'knowledgeBaseId', + type: 'knowledge-base-selector', + value: 'kb-dst', + }, + documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' }, + }, + 'knowledge', + new Set(['knowledgeBaseId']) + ) + expect(result.documentId.value).toBe('') + }) + + it('clearDependentsOnRemap: preserve holds when a SECOND remapped key also reaches the tool selector', () => { + // Synthetic config (no registry block wires this today): the tool selector hangs off BOTH a + // remapped mcp-server parent (preserve) and another remapped parent (no preserve). The + // selector-keyed preserve must win over the other key's clear, in either key order, while + // the other key's own non-exempt dependent still clears. + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'server', title: 'MCP Server', type: 'mcp-server-selector' }, + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { + id: 'tool', + title: 'Tool', + type: 'mcp-tool-selector', + dependsOn: ['server', 'knowledgeBaseId'], + }, + { id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] }, + { + id: 'documentId', + title: 'Doc', + type: 'document-selector', + dependsOn: ['knowledgeBaseId'], + }, + ]) + ) + const subBlocks = (): SubBlockRecord => ({ + server: { id: 'server', type: 'mcp-server-selector', value: 'mcp-tgt9' }, + knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-dst' }, + tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-tgt9-search_docs' }, + arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' }, + documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' }, + }) + for (const keys of [ + ['server', 'knowledgeBaseId'], + ['knowledgeBaseId', 'server'], + ]) { + const result = clearDependentsOnRemap(subBlocks(), 'mcp', new Set(keys)) + expect(result.tool.value).toBe('mcp-tgt9-search_docs') + expect(result.arguments.value).toBe('{"query":"hello"}') + expect(result.documentId.value).toBe('') + } + }) + + it('clearDependentsOnRemap: a CLEARED server alongside another remapped key still clears the tool', () => { + // Same two-key config, but the server was cleared (unmapped): no preserve applies anywhere, + // so the tool and its arguments clear as ordinary dependents. + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'server', title: 'MCP Server', type: 'mcp-server-selector' }, + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { + id: 'tool', + title: 'Tool', + type: 'mcp-tool-selector', + dependsOn: ['server', 'knowledgeBaseId'], + }, + { id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] }, + ]) + ) + const result = clearDependentsOnRemap( + { + server: { id: 'server', type: 'mcp-server-selector', value: '' }, + knowledgeBaseId: { + id: 'knowledgeBaseId', + type: 'knowledge-base-selector', + value: 'kb-dst', + }, + tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-src1-search_docs' }, + arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' }, + }, + 'mcp', + new Set(['server', 'knowledgeBaseId']) + ) + expect(result.tool.value).toBe('') + expect(result.arguments.value).toBe('') + }) +}) + +describe('tool-input MCP entry server remap rewrites embedded server metadata', () => { + const toolInputSubBlocks = (params: Record): SubBlockRecord => ({ + tools: { + id: 'tools', + type: 'tool-input', + value: [{ type: 'mcp', title: 'search', toolId: 'mcp-src1-search', params }], + }, + }) + const entryParams = () => ({ + serverId: 'mcp-src1', + serverUrl: 'https://old.example/mcp', + toolName: 'search', + serverName: 'Old Server', + }) + const mapServer = (kind: string, id: string) => + kind === 'mcp-server' && id === 'mcp-src1' ? 'mcp-tgt9' : null + + it('rewrites serverUrl/serverName from the mapped TARGET row; tool name verbatim, toolId rebuilt', () => { + const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote', { + resolveMcpServerMeta: (targetServerId) => + targetServerId === 'mcp-tgt9' + ? { name: 'New Server', url: 'https://new.example/mcp' } + : undefined, + }) + const [tool] = result.subBlocks.tools.value as Array<{ + toolId: string + params: Record + }> + expect(tool.params).toEqual({ + serverId: 'mcp-tgt9', + serverUrl: 'https://new.example/mcp', + toolName: 'search', + serverName: 'New Server', + }) + expect(tool.toolId).toBe('mcp-tgt9-search') + }) + + it('drops the stale serverUrl when the target server has no url', () => { + const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote', { + resolveMcpServerMeta: () => ({ name: 'New Server', url: null }), + }) + const [tool] = result.subBlocks.tools.value as Array<{ params: Record }> + expect(tool.params).toEqual({ + serverId: 'mcp-tgt9', + toolName: 'search', + serverName: 'New Server', + }) + }) + + it('without a meta resolver (scan-only callers) the id remaps and metadata is left as-is', () => { + const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote') + const [tool] = result.subBlocks.tools.value as Array<{ + toolId: string + params: Record + }> + expect(tool.params).toEqual({ + serverId: 'mcp-tgt9', + serverUrl: 'https://old.example/mcp', + toolName: 'search', + serverName: 'Old Server', + }) + expect(tool.toolId).toBe('mcp-tgt9-search') + }) + + it('threads the meta resolver through the sync transform', () => { + // Transform-level check: promote passes the batch-loaded target rows via options. + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + ) + const transform = createForkSubBlockTransform(mapServer, { + resolveMcpServerMeta: () => ({ name: 'New Server', url: 'https://new.example/mcp' }), + }) + const result = transform(toolInputSubBlocks(entryParams()), 'agent') + const [tool] = result.tools.value as Array<{ params: Record }> + expect(tool.params.serverUrl).toBe('https://new.example/mcp') + expect(tool.params.serverName).toBe('New Server') + }) +}) + describe('clearDependentsOnRemap canonical-pair gating', () => { const kbCanonicalBlock = () => blockWith([ diff --git a/apps/sim/lib/workspaces/fork/remap/remap-references.ts b/apps/sim/lib/workspaces/fork/remap/remap-references.ts index dc4e9c269bf..fe645f8b9ad 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import type { SubBlockType } from '@sim/workflow-types/blocks' import type { z } from 'zod' import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork' @@ -33,6 +34,7 @@ import { } from '@/lib/workspaces/fork/remap/remap-files' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' +import { getSubBlocksDependingOnChange } from '@/blocks/utils' /** * Resource kinds the fork remapper rewrites across workspaces, derived from the @@ -82,9 +84,11 @@ export const REGISTRY_KIND_TO_FORK_KIND: Partial< // map when its referenced document was copied into the fork; an unmapped document (its // parent KB wasn't copied, or the doc wasn't copyable) resolves to null and is cleared, // and `clearDependentsOnRemap` still clears it as a `knowledgeBaseId` dependent when the -// parent KB itself is unmapped. `mcp-tool-selector` is cleared by `dependsOn` when its -// `mcp-server-selector` parent is remapped - the tool list is server-scoped and may -// differ in the target. +// parent KB itself is unmapped. `mcp-tool-selector` follows its `mcp-server-selector` +// parent's remap: mapping asserts the servers are equivalent, so the tool SELECTION is +// kept (its embedded server id swapped to the target's, the tool name verbatim - see +// {@link remapForkSubBlocks}) and `clearDependentsOnRemap` exempts it. When the server +// is CLEARED (unmapped / fork-create) the tool still clears as a dependent. /** Matches `{{ENV_KEY}}` references inside subblock values; shared with cascade detection. */ export const ENV_REF_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g @@ -128,6 +132,22 @@ export type ForkReferenceResolver = ( sourceId: string ) => string | null | undefined +/** Identity metadata of a mapped TARGET MCP server row (url is null for url-less transports). */ +export interface ForkMcpServerMeta { + name: string + url: string | null +} + +/** + * Resolves a mapped TARGET MCP server id to its row metadata, so a remapped tool-input + * entry's embedded `serverUrl`/`serverName` are rewritten from the target server instead + * of carrying the source server's (which would show a false "URL changed" stale badge in + * the target UI). Undefined when the row is unknown - the entry's metadata is then left + * as-is. Threaded by promote (which batch-loads the mapped targets); scan-only callers + * omit it because they never persist the remapped value. + */ +export type ForkMcpServerMetaResolver = (targetServerId: string) => ForkMcpServerMeta | undefined + export interface ForkReference { kind: ForkRemapKind sourceId: string @@ -153,6 +173,8 @@ export interface RemapForkContext { blockType?: string /** Canonical-mode overrides (`block.data.canonicalModes`), picking the active member per pair. */ canonicalModes?: CanonicalModeOverrides + /** Target MCP server row lookup for rewriting remapped tool-input entries' server metadata. */ + resolveMcpServerMeta?: ForkMcpServerMetaResolver } function remapEnvInValue( @@ -349,6 +371,8 @@ interface ForkToolInputOptions { /** Fork-create drops unresolved tools / clears params; promote keeps + records. */ clearUnresolved: boolean record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void + /** Target MCP server row lookup for rewriting a remapped MCP entry's server metadata. */ + resolveMcpServerMeta?: ForkMcpServerMetaResolver } /** @@ -394,10 +418,22 @@ function remapForkToolInputValue( changed = true const toolName = typeof tool.params.toolName === 'string' ? tool.params.toolName : undefined + let nextParams: Record = { ...tool.params, serverId: target } + // The entry embeds the server's identity metadata (`serverUrl`/`serverName`); rewrite + // it from the mapped TARGET row so the target UI never flags a false "URL changed" + // stale badge against the source server's url (a url-less target drops the stale key). + // The tool NAME stays verbatim - mapping asserts server equivalence; a name missing on + // the target degrades to the existing tool_not_found badge / runtime skip. Without a + // meta resolver (scan-only callers) the metadata is left as-is. + const meta = opts.resolveMcpServerMeta?.(target) + if (meta) { + nextParams = { ...omit(nextParams, ['serverUrl']), serverName: meta.name } + if (meta.url) nextParams.serverUrl = meta.url + } return [ { ...tool, - params: { ...tool.params, serverId: target }, + params: nextParams, toolId: toolName ? createMcpToolId(target, toolName) : tool.toolId, }, ] @@ -476,6 +512,8 @@ export function remapForkSubBlocks( const references = new Map() const unmapped = new Map() const remappedKeys = new Set() + /** MCP server ids remapped to a DIFFERENT mapped target this pass (source id -> target id). */ + const mcpServerRemaps = new Map() const recordReference = (key: string, reference: ForkReference, mapped: boolean) => { if (mode !== 'promote') return @@ -542,6 +580,7 @@ export function remapForkSubBlocks( if (!isDormant) recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped) if (mapped) { if (target !== ref.rawValue) { + if (forkKind === 'mcp-server') mcpServerRemaps.set(ref.rawValue, target) const replaceResult = definition.codec.replace(value, ref.rawValue, target) if (replaceResult.success) value = replaceResult.nextValue } @@ -591,7 +630,11 @@ export function remapForkSubBlocks( ) value = subBlockType === 'tool-input' - ? remapForkToolInputValue(value, resolve, { clearUnresolved, record }) + ? remapForkToolInputValue(value, resolve, { + clearUnresolved, + record, + resolveMcpServerMeta: context?.resolveMcpServerMeta, + }) : remapForkSkillInputValue(value, resolve, { clearUnresolved, record }) } @@ -618,6 +661,32 @@ export function remapForkSubBlocks( result[subBlockKey] = { ...subBlock, value } } + // An MCP block's tool SELECTION follows its server's remap instead of clearing: the stored + // value embeds the server id (`mcp--`), so swap the embedded id for the + // mapped target's and keep the tool NAME verbatim - mapping asserts the servers are + // equivalent, mirroring how tool-input MCP entries keep their tool name. A value that does + // not embed a remapped server id (a bare tool name) is already server-agnostic and kept + // as-is. Deliberately NOT added to `remappedKeys`: the selection is preserved, so its own + // dependents (the tool's arguments) must be preserved with it, and `clearDependentsOnRemap` + // exempts the selector under a remapped (non-cleared) server parent. + if (mcpServerRemaps.size > 0) { + for (const [subBlockKey, subBlock] of Object.entries(result)) { + if (!subBlock || typeof subBlock !== 'object') continue + if (subBlock.type !== 'mcp-tool-selector') continue + const toolValue = subBlock.value + if (typeof toolValue !== 'string' || !toolValue) continue + for (const [sourceServerId, targetServerId] of mcpServerRemaps) { + const sourcePrefix = createMcpToolId(sourceServerId, '') + if (!toolValue.startsWith(sourcePrefix)) continue + result[subBlockKey] = { + ...subBlock, + value: createMcpToolId(targetServerId, toolValue.slice(sourcePrefix.length)), + } + break + } + } + } + return { subBlocks: result, references: Array.from(references.values()), @@ -629,11 +698,17 @@ export function remapForkSubBlocks( /** * Clear every subblock whose `dependsOn` parent was remapped to a different * target this pass, so a child scoped to the old parent (a KB's document, a - * Slack channel, a sheet tab) never carries a stale id into the target. Reuses - * the search-replace dependent-clear walk (canonical-pair aware, transitive over - * `dependsOn` chains) so fork/promote and in-editor search-replace clear - * identically. Children of an unchanged parent are preserved; a no-op for - * unknown block types or when nothing was remapped. + * Slack channel, a sheet tab) never carries a stale id into the target. Uses + * the same dependent walk as search-replace (canonical-pair aware, transitive + * over `dependsOn` chains) so fork/promote and in-editor search-replace clear + * identically - with ONE remap-specific exemption: an `mcp-tool-selector` under + * an `mcp-server-selector` parent that was REMAPPED to a mapped target (its + * post-remap value is non-empty) is preserved along with its own dependents + * (the tool's arguments), because mapping asserts the servers are equivalent + * and {@link remapForkSubBlocks} already followed the selection onto the target + * server. A CLEARED server (unmapped / fork-create) still clears its dependents. + * Children of an unchanged parent are preserved; a no-op for unknown block + * types or when nothing was remapped. */ export function clearDependentsOnRemap( subBlocks: SubBlockRecord, @@ -661,11 +736,50 @@ export function clearDependentsOnRemap( return (mode === 'advanced') !== group.advancedIds.includes(baseKey) } + // The exemption's parent test: an mcp-server selector whose POST-remap value is non-empty was + // remapped to a mapped target (a cleared one is empty), so its tool selection is preserved. + const configTypeById = new Map( + config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg.type]) + ) + const isRemappedMcpServerParent = (key: string): boolean => { + if (configTypeById.get(key.replace(/_\d+$/, '')) !== 'mcp-server-selector') return false + const parent = subBlocks[key] + return parent && typeof parent === 'object' ? isNonEmptyValue(parent.value) : false + } + + // The preserve decision is hoisted out of the per-key walk and keyed on the SELECTOR (not on + // which remapped key reaches it): `toClear` is a union across per-key BFS passes (each with its + // own `visited`), so an in-loop exemption holds only against the exempting key - a second + // remapped key (or a longer dependsOn path) reaching the same tool selector would re-add it. + // Unreachable with today's registry (the tool selector's only dependsOn parent is its server), + // but this makes the exemption independent of key order and path by construction. + const preservedMcpToolSelectors = new Set() + for (const key of remappedKeys) { + if (isDormantCanonicalMember(key) || !isRemappedMcpServerParent(key)) continue + for (const dependent of getSubBlocksDependingOnChange(config.subBlocks, key)) { + if (dependent.id && dependent.type === 'mcp-tool-selector') { + preservedMcpToolSelectors.add(dependent.id) + } + } + } + + // Same BFS as `getWorkflowSearchDependentClears`, with the preserved tool selector's subtree + // pruned (skipping it keeps its own dependents - the arguments - out of the clear set too). const toClear = new Set() for (const key of remappedKeys) { if (isDormantCanonicalMember(key)) continue - for (const clear of getWorkflowSearchDependentClears(config.subBlocks, key)) { - if (!remappedKeys.has(clear.subBlockId)) toClear.add(clear.subBlockId) + const visited = new Set([key]) + const queue = [key] + while (queue.length > 0) { + const current = queue.shift() + if (!current) continue + for (const dependent of getSubBlocksDependingOnChange(config.subBlocks, current)) { + if (!dependent.id || visited.has(dependent.id)) continue + if (preservedMcpToolSelectors.has(dependent.id)) continue + visited.add(dependent.id) + if (!remappedKeys.has(dependent.id)) toClear.add(dependent.id) + queue.push(dependent.id) + } } } @@ -975,14 +1089,20 @@ export function remapSubBlocks( /** A `copyWorkflowStateIntoTarget` subBlock transform that rewrites references via the resolver. */ export function createForkSubBlockTransform( - resolve: ForkReferenceResolver + resolve: ForkReferenceResolver, + options?: { + /** Mapped-target MCP server rows, so remapped tool-input entries rewrite their server metadata. */ + resolveMcpServerMeta?: ForkMcpServerMetaResolver + } ): ( subBlocks: SubBlockRecord, blockType: string, canonicalModes?: CanonicalModeOverrides ) => SubBlockRecord { return (subBlocks, blockType, canonicalModes) => { - const result = remapSubBlocks(subBlocks, resolve) + const result = remapSubBlocks(subBlocks, resolve, { + resolveMcpServerMeta: options?.resolveMcpServerMeta, + }) return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys, canonicalModes) } } diff --git a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts index e05b8b00971..c49ee410b03 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts @@ -3,7 +3,10 @@ */ import { describe, expect, it } from 'vitest' import type { TableSchema } from '@/lib/table/types' -import { deriveForkBlockId } from '@/lib/workspaces/fork/remap/block-identity' +import { + buildForkBlockIdResolver, + deriveForkBlockId, +} from '@/lib/workspaces/fork/remap/block-identity' import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/remap/remap-table-groups' describe('remapForkTableWorkflowGroups', () => { @@ -28,6 +31,36 @@ describe('remapForkTableWorkflowGroups', () => { expect(result.columns[0].workflowGroupId).toBe('g1') }) + // Promote threads its persisted-pair resolver: a paired block resolves to the pair's target id + // (on push, the parent's ORIGINAL id - never the derive); an unpaired block falls back to the + // derive, matching the workflow write path. + it('prefers a provided block-id resolver (persisted pair) over the derive, deriving unpaired blocks', () => { + const map = new Map([['src-wf', 'child-wf']]) + const schema: TableSchema = { + columns: [], + workflowGroups: [ + { + id: 'g1', + workflowId: 'src-wf', + outputs: [ + { blockId: 'src-block', path: 'out', columnName: 'col_1' }, + { blockId: 'src-unpaired', path: 'out2', columnName: 'col_2' }, + ], + }, + ], + } + const resolver = buildForkBlockIdResolver(true, { + parentToChild: new Map([ + ['src-block', { targetBlockId: 'original-parent-block', targetWorkflowId: 'child-wf' }], + ]), + childToParent: new Map(), + }) + const result = remapForkTableWorkflowGroups(schema, map, resolver) + const outputs = result.workflowGroups?.[0].outputs + expect(outputs?.[0].blockId).toBe('original-parent-block') + expect(outputs?.[1].blockId).toBe(deriveForkBlockId('child-wf', 'src-unpaired')) + }) + it('drops a group whose backing workflow was not copied and clears its column wiring', () => { const schema: TableSchema = { columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }], diff --git a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts index 9eb00d914c3..1d278f2b2ad 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts @@ -1,19 +1,28 @@ import type { TableSchema } from '@/lib/table/types' -import { deriveForkBlockId } from '@/lib/workspaces/fork/remap/block-identity' +import { + deriveForkBlockId, + type ForkBlockIdResolver, +} from '@/lib/workspaces/fork/remap/block-identity' /** * Remap the workflow/block references embedded in a copied table's schema so its * workflow groups keep working in the child workspace. `workflowGroups[].workflowId` * is rewritten through the source→child workflow identity map, and each - * `outputs[].blockId` is rewritten to the deterministic forked block id (matching - * `copyWorkflowStateIntoTarget`). Manual groups whose backing workflow was not + * `outputs[].blockId` is rewritten through `resolveBlockId` - which MUST be the + * same resolver that assigns the target workflows' block ids, or the outputs + * point at nonexistent blocks. Fork-create omits it and defaults to the + * deterministic {@link deriveForkBlockId} (a fresh child has no persisted + * pairs, matching `copyWorkflowStateIntoTarget`); promote passes its + * persisted-pair resolver (a push keeps the parent's ORIGINAL block ids, which + * never equal the derive). Manual groups whose backing workflow was not * copied are dropped, and any columns wired to a dropped group have their * `workflowGroupId` cleared. Enrichment groups (empty `workflowId`) and column * ids are left untouched. */ export function remapForkTableWorkflowGroups( schema: TableSchema, - workflowIdMap: Map + workflowIdMap: Map, + resolveBlockId: ForkBlockIdResolver = deriveForkBlockId ): TableSchema { const groups = schema.workflowGroups ?? [] if (groups.length === 0) return schema @@ -33,7 +42,7 @@ export function remapForkTableWorkflowGroups( outputs: group.outputs.map((output) => ({ ...output, blockId: output.blockId - ? deriveForkBlockId(childWorkflowId, output.blockId) + ? resolveBlockId(childWorkflowId, output.blockId) : output.blockId, })), }, diff --git a/apps/sim/package.json b/apps/sim/package.json index b89a1fed3d7..1fd1b983bc4 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -140,7 +140,7 @@ "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", - "echarts": "6.0.0", + "echarts": "6.1.0", "es-toolkit": "1.45.1", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", diff --git a/apps/sim/tools/ahrefs/backlinks.ts b/apps/sim/tools/ahrefs/backlinks.ts index e5ce1551280..783cc09110b 100644 --- a/apps/sim/tools/ahrefs/backlinks.ts +++ b/apps/sim/tools/ahrefs/backlinks.ts @@ -1,6 +1,9 @@ import type { AhrefsBacklinksParams, AhrefsBacklinksResponse } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = + 'url_from,url_to,anchor,domain_rating_source,is_dofollow,first_seen,last_visited' + export const backlinksTool: ToolConfig = { id: 'ahrefs_backlinks', name: 'Ahrefs Backlinks', @@ -21,25 +24,20 @@ export const backlinksTool: ToolConfig { - const url = new URL('https://api.ahrefs.com/v3/site-explorer/backlinks') + const url = new URL('https://api.ahrefs.com/v3/site-explorer/all-backlinks') url.searchParams.set('target', params.target) - // Date is required - default to today if not provided - const date = params.date || new Date().toISOString().split('T')[0] - url.searchParams.set('date', date) + url.searchParams.set('select', SELECT_FIELDS) if (params.mode) url.searchParams.set('mode', params.mode) + url.searchParams.set('history', params.history || 'all_time') if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -79,8 +75,8 @@ export const backlinksTool: ToolConfig { const url = new URL('https://api.ahrefs.com/v3/site-explorer/broken-backlinks') url.searchParams.set('target', params.target) - // Date is required - default to today if not provided - const date = params.date || new Date().toISOString().split('T')[0] - url.searchParams.set('date', date) + url.searchParams.set('select', SELECT_FIELDS) if (params.mode) url.searchParams.set('mode', params.mode) if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -81,12 +68,12 @@ export const brokenBacklinksTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get broken backlinks') } - const brokenBacklinks = (data.backlinks || data.broken_backlinks || []).map((link: any) => ({ + const brokenBacklinks = (data.backlinks || []).map((link: any) => ({ urlFrom: link.url_from || '', urlTo: link.url_to || '', - httpCode: link.http_code ?? link.status_code ?? 404, + httpCode: link.http_code_target ?? null, anchor: link.anchor || '', - domainRatingSource: link.domain_rating_source ?? link.domain_rating ?? 0, + domainRatingSource: link.domain_rating_source ?? 0, })) return { @@ -109,7 +96,11 @@ export const brokenBacklinksTool: ToolConfig< description: 'The URL of the page containing the broken link', }, urlTo: { type: 'string', description: 'The broken URL being linked to' }, - httpCode: { type: 'number', description: 'HTTP status code (e.g., 404, 410)' }, + httpCode: { + type: 'number', + description: 'HTTP status code of the broken target URL (e.g., 404, 410)', + optional: true, + }, anchor: { type: 'string', description: 'The anchor text of the link' }, domainRatingSource: { type: 'number', diff --git a/apps/sim/tools/ahrefs/domain_rating.ts b/apps/sim/tools/ahrefs/domain_rating.ts index 75066ae0c5d..3b3378c9079 100644 --- a/apps/sim/tools/ahrefs/domain_rating.ts +++ b/apps/sim/tools/ahrefs/domain_rating.ts @@ -55,8 +55,8 @@ export const domainRatingTool: ToolConfig { const url = new URL('https://api.ahrefs.com/v3/keywords-explorer/overview') - url.searchParams.set('keyword', params.keyword) + url.searchParams.set('keywords', params.keyword) url.searchParams.set('country', params.country || 'us') + url.searchParams.set('select', SELECT_FIELDS) return url.toString() }, method: 'GET', @@ -56,18 +60,21 @@ export const keywordOverviewTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get keyword overview') } + const result = (data.keywords || [])[0] || {} + return { success: true, output: { overview: { - keyword: data.keyword || '', - searchVolume: data.volume ?? 0, - keywordDifficulty: data.keyword_difficulty ?? data.difficulty ?? 0, - cpc: data.cpc ?? 0, - clicks: data.clicks ?? 0, - clicksPercentage: data.clicks_percentage ?? 0, - parentTopic: data.parent_topic || '', - trafficPotential: data.traffic_potential ?? 0, + keyword: result.keyword || '', + searchVolume: result.volume ?? 0, + keywordDifficulty: result.difficulty ?? null, + cpc: result.cpc ?? null, + clicks: result.clicks ?? null, + clicksPercentage: result.searches_pct_clicks_organic_only ?? null, + parentTopic: result.parent_topic ?? null, + trafficPotential: result.traffic_potential ?? null, + intents: result.intents ?? null, }, }, } @@ -83,17 +90,38 @@ export const keywordOverviewTool: ToolConfig< keywordDifficulty: { type: 'number', description: 'Keyword difficulty score (0-100)', + optional: true, }, - cpc: { type: 'number', description: 'Cost per click in USD' }, - clicks: { type: 'number', description: 'Estimated clicks per month' }, + cpc: { type: 'number', description: 'Cost per click in USD', optional: true }, + clicks: { type: 'number', description: 'Estimated clicks per month', optional: true }, clicksPercentage: { type: 'number', - description: 'Percentage of searches that result in clicks', + description: 'Percentage of searches that result in an organic click', + optional: true, + }, + parentTopic: { + type: 'string', + description: 'The parent topic for this keyword', + optional: true, }, - parentTopic: { type: 'string', description: 'The parent topic for this keyword' }, trafficPotential: { type: 'number', description: 'Estimated traffic potential if ranking #1', + optional: true, + }, + intents: { + type: 'object', + description: + 'Search intent flags (informational, navigational, commercial, transactional, branded, local)', + optional: true, + properties: { + informational: { type: 'boolean', description: 'Query seeks information' }, + navigational: { type: 'boolean', description: 'Query seeks a specific site or page' }, + commercial: { type: 'boolean', description: 'Query researches a purchase decision' }, + transactional: { type: 'boolean', description: 'Query intends to complete a purchase' }, + branded: { type: 'boolean', description: 'Query references a specific brand' }, + local: { type: 'boolean', description: 'Query seeks local results' }, + }, }, }, }, diff --git a/apps/sim/tools/ahrefs/metrics.ts b/apps/sim/tools/ahrefs/metrics.ts new file mode 100644 index 00000000000..99184349d74 --- /dev/null +++ b/apps/sim/tools/ahrefs/metrics.ts @@ -0,0 +1,116 @@ +import type { AhrefsMetricsParams, AhrefsMetricsResponse } from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +export const metricsTool: ToolConfig = { + id: 'ahrefs_metrics', + name: 'Ahrefs Metrics', + description: + 'Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for traffic data. Example: "us", "gb", "de"', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', + }, + date: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Date to report metrics on, in YYYY-MM-DD format (defaults to today)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/metrics') + url.searchParams.set('target', params.target) + // Date is required - default to today if not provided + const date = params.date || new Date().toISOString().split('T')[0] + url.searchParams.set('date', date) + url.searchParams.set('country', params.country || 'us') + if (params.mode) url.searchParams.set('mode', params.mode) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || data.error || 'Failed to get metrics') + } + + const metrics = data.metrics || {} + + return { + success: true, + output: { + metrics: { + organicTraffic: metrics.org_traffic ?? 0, + organicKeywords: metrics.org_keywords ?? 0, + organicKeywordsTop3: metrics.org_keywords_1_3 ?? 0, + organicCost: metrics.org_cost ?? null, + paidTraffic: metrics.paid_traffic ?? 0, + paidKeywords: metrics.paid_keywords ?? 0, + paidPages: metrics.paid_pages ?? 0, + paidCost: metrics.paid_cost ?? null, + }, + }, + } + }, + + outputs: { + metrics: { + type: 'object', + description: 'Organic and paid search overview', + properties: { + organicTraffic: { type: 'number', description: 'Estimated monthly organic traffic' }, + organicKeywords: { type: 'number', description: 'Number of organic keywords ranked' }, + organicKeywordsTop3: { + type: 'number', + description: 'Number of organic keywords ranking in positions 1-3', + }, + organicCost: { + type: 'number', + description: 'Estimated monthly cost to replicate organic traffic via ads (USD)', + optional: true, + }, + paidTraffic: { type: 'number', description: 'Estimated monthly paid search traffic' }, + paidKeywords: { type: 'number', description: 'Number of paid keywords targeted' }, + paidPages: { type: 'number', description: 'Number of pages receiving paid traffic' }, + paidCost: { + type: 'number', + description: 'Estimated monthly paid search spend (USD)', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/organic_competitors.ts b/apps/sim/tools/ahrefs/organic_competitors.ts new file mode 100644 index 00000000000..e304f567d0e --- /dev/null +++ b/apps/sim/tools/ahrefs/organic_competitors.ts @@ -0,0 +1,138 @@ +import type { + AhrefsOrganicCompetitorsParams, + AhrefsOrganicCompetitorsResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = + 'competitor_domain,domain_rating,keywords_common,keywords_target,keywords_competitor,traffic' + +export const organicCompetitorsTool: ToolConfig< + AhrefsOrganicCompetitorsParams, + AhrefsOrganicCompetitorsResponse +> = { + id: 'ahrefs_organic_competitors', + name: 'Ahrefs Organic Competitors', + description: + 'Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for search results. Example: "us", "gb", "de" (default: "us")', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', + }, + date: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Date to report metrics on, in YYYY-MM-DD format (defaults to today)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/organic-competitors') + url.searchParams.set('target', params.target) + url.searchParams.set('country', params.country || 'us') + url.searchParams.set('select', SELECT_FIELDS) + // Date is required - default to today if not provided + const date = params.date || new Date().toISOString().split('T')[0] + url.searchParams.set('date', date) + if (params.mode) url.searchParams.set('mode', params.mode) + if (params.limit) url.searchParams.set('limit', String(params.limit)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || data.error || 'Failed to get organic competitors') + } + + const competitors = (data.competitors || []).map((competitor: any) => ({ + domain: competitor.competitor_domain ?? null, + domainRating: competitor.domain_rating ?? 0, + commonKeywords: competitor.keywords_common ?? 0, + targetKeywords: competitor.keywords_target ?? 0, + competitorKeywords: competitor.keywords_competitor ?? 0, + traffic: competitor.traffic ?? null, + })) + + return { + success: true, + output: { + competitors, + }, + } + }, + + outputs: { + competitors: { + type: 'array', + description: 'List of organic search competitors ranked by keyword overlap', + items: { + type: 'object', + properties: { + domain: { + type: 'string', + description: 'The competitor domain', + optional: true, + }, + domainRating: { type: 'number', description: 'Domain Rating of the competitor' }, + commonKeywords: { + type: 'number', + description: 'Number of keywords the competitor and target both rank for', + }, + targetKeywords: { + type: 'number', + description: 'Number of keywords the target ranks for', + }, + competitorKeywords: { + type: 'number', + description: 'Number of keywords the competitor ranks for', + }, + traffic: { + type: 'number', + description: 'Estimated monthly organic traffic for the competitor', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/organic_keywords.ts b/apps/sim/tools/ahrefs/organic_keywords.ts index 3565b901c8b..73ff0e0a4f2 100644 --- a/apps/sim/tools/ahrefs/organic_keywords.ts +++ b/apps/sim/tools/ahrefs/organic_keywords.ts @@ -4,6 +4,9 @@ import type { } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = + 'keyword,volume,best_position,best_position_url,sum_traffic,keyword_difficulty' + export const organicKeywordsTool: ToolConfig< AhrefsOrganicKeywordsParams, AhrefsOrganicKeywordsResponse @@ -33,25 +36,19 @@ export const organicKeywordsTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains), exact (exact URL match). Example: "domain"', + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', }, date: { type: 'string', required: false, visibility: 'user-only', - description: 'Date for historical data in YYYY-MM-DD format (defaults to today)', + description: 'Date to report metrics on, in YYYY-MM-DD format (defaults to today)', }, limit: { type: 'number', required: false, visibility: 'user-or-llm', - description: 'Maximum number of results to return. Example: 50 (default: 100)', - }, - offset: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Number of results to skip for pagination. Example: 100', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', }, apiKey: { type: 'string', @@ -66,12 +63,12 @@ export const organicKeywordsTool: ToolConfig< const url = new URL('https://api.ahrefs.com/v3/site-explorer/organic-keywords') url.searchParams.set('target', params.target) url.searchParams.set('country', params.country || 'us') + url.searchParams.set('select', SELECT_FIELDS) // Date is required - default to today if not provided const date = params.date || new Date().toISOString().split('T')[0] url.searchParams.set('date', date) if (params.mode) url.searchParams.set('mode', params.mode) if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -88,13 +85,13 @@ export const organicKeywordsTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get organic keywords') } - const keywords = (data.keywords || data.organic_keywords || []).map((kw: any) => ({ + const keywords = (data.keywords || []).map((kw: any) => ({ keyword: kw.keyword || '', volume: kw.volume ?? 0, - position: kw.position ?? 0, - url: kw.url || '', - traffic: kw.traffic ?? 0, - keywordDifficulty: kw.keyword_difficulty ?? kw.difficulty ?? 0, + position: kw.best_position ?? null, + url: kw.best_position_url ?? null, + traffic: kw.sum_traffic ?? 0, + keywordDifficulty: kw.keyword_difficulty ?? null, })) return { @@ -114,12 +111,21 @@ export const organicKeywordsTool: ToolConfig< properties: { keyword: { type: 'string', description: 'The keyword' }, volume: { type: 'number', description: 'Monthly search volume' }, - position: { type: 'number', description: 'Current ranking position' }, - url: { type: 'string', description: 'The URL that ranks for this keyword' }, + position: { + type: 'number', + description: 'Best ranking position for this keyword', + optional: true, + }, + url: { + type: 'string', + description: 'The URL that ranks at the best position for this keyword', + optional: true, + }, traffic: { type: 'number', description: 'Estimated monthly organic traffic' }, keywordDifficulty: { type: 'number', description: 'Keyword difficulty score (0-100)', + optional: true, }, }, }, diff --git a/apps/sim/tools/ahrefs/referring_domains.ts b/apps/sim/tools/ahrefs/referring_domains.ts index 87a21367c52..f6120d1a4bf 100644 --- a/apps/sim/tools/ahrefs/referring_domains.ts +++ b/apps/sim/tools/ahrefs/referring_domains.ts @@ -4,6 +4,8 @@ import type { } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = 'domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen' + export const referringDomainsTool: ToolConfig< AhrefsReferringDomainsParams, AhrefsReferringDomainsResponse @@ -27,25 +29,20 @@ export const referringDomainsTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains), exact (exact URL match). Example: "domain"', + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', }, - date: { + history: { type: 'string', required: false, - visibility: 'user-only', - description: 'Date for historical data in YYYY-MM-DD format (defaults to today)', - }, - limit: { - type: 'number', - required: false, visibility: 'user-or-llm', - description: 'Maximum number of results to return. Example: 50 (default: 100)', + description: + 'Historical scope: "live" (currently live), "all_time" (default, includes lost domains), or "since:YYYY-MM-DD" (domains found since a date).', }, - offset: { + limit: { type: 'number', required: false, visibility: 'user-or-llm', - description: 'Number of results to skip for pagination. Example: 100', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', }, apiKey: { type: 'string', @@ -59,12 +56,10 @@ export const referringDomainsTool: ToolConfig< url: (params) => { const url = new URL('https://api.ahrefs.com/v3/site-explorer/refdomains') url.searchParams.set('target', params.target) - // Date is required - default to today if not provided - const date = params.date || new Date().toISOString().split('T')[0] - url.searchParams.set('date', date) + url.searchParams.set('select', SELECT_FIELDS) if (params.mode) url.searchParams.set('mode', params.mode) + url.searchParams.set('history', params.history || 'all_time') if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -81,16 +76,14 @@ export const referringDomainsTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get referring domains') } - const referringDomains = (data.refdomains || data.referring_domains || []).map( - (domain: any) => ({ - domain: domain.domain || domain.refdomain || '', - domainRating: domain.domain_rating ?? 0, - backlinks: domain.backlinks ?? 0, - dofollowBacklinks: domain.dofollow_backlinks ?? domain.dofollow ?? 0, - firstSeen: domain.first_seen || '', - lastVisited: domain.last_visited || '', - }) - ) + const referringDomains = (data.refdomains || []).map((domain: any) => ({ + domain: domain.domain || '', + domainRating: domain.domain_rating ?? 0, + backlinks: domain.links_to_target ?? 0, + dofollowBacklinks: domain.dofollow_links ?? 0, + firstSeen: domain.first_seen || '', + lastVisited: domain.last_seen ?? null, + })) return { success: true, @@ -111,14 +104,18 @@ export const referringDomainsTool: ToolConfig< domainRating: { type: 'number', description: 'Domain Rating of the referring domain' }, backlinks: { type: 'number', - description: 'Total number of backlinks from this domain', + description: 'Total number of backlinks from this domain to the target', }, dofollowBacklinks: { type: 'number', description: 'Number of dofollow backlinks from this domain', }, firstSeen: { type: 'string', description: 'When the domain was first seen linking' }, - lastVisited: { type: 'string', description: 'When the domain was last checked' }, + lastVisited: { + type: 'string', + description: 'When the domain was last seen linking (null if never re-crawled)', + optional: true, + }, }, }, }, diff --git a/apps/sim/tools/ahrefs/top_pages.ts b/apps/sim/tools/ahrefs/top_pages.ts index bb48d8cf0d0..9feb05c40b6 100644 --- a/apps/sim/tools/ahrefs/top_pages.ts +++ b/apps/sim/tools/ahrefs/top_pages.ts @@ -1,6 +1,8 @@ import type { AhrefsTopPagesParams, AhrefsTopPagesResponse } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = 'url,sum_traffic,keywords,top_keyword,value' + export const topPagesTool: ToolConfig = { id: 'ahrefs_top_pages', name: 'Ahrefs Top Pages', @@ -26,32 +28,19 @@ export const topPagesTool: ToolConfig ({ - url: page.url || '', - traffic: page.traffic ?? 0, - keywords: page.keywords ?? page.keyword_count ?? 0, - topKeyword: page.top_keyword || '', - value: page.value ?? page.traffic_value ?? 0, + const pages = (data.pages || []).map((page: any) => ({ + url: page.url ?? null, + traffic: page.sum_traffic ?? 0, + keywords: page.keywords ?? null, + topKeyword: page.top_keyword ?? null, + value: page.value ?? null, })) return { @@ -114,14 +100,23 @@ export const topPagesTool: ToolConfig { - const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}` + const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}` if (params.objectID) { - return `${base}/${encodeURIComponent(params.objectID)}` + return `${base}/${encodeURIComponent(params.objectID.trim())}` } return base }, diff --git a/apps/sim/tools/algolia/batch_operations.ts b/apps/sim/tools/algolia/batch_operations.ts index d65c69c0feb..7016dc26717 100644 --- a/apps/sim/tools/algolia/batch_operations.ts +++ b/apps/sim/tools/algolia/batch_operations.ts @@ -38,13 +38,13 @@ export const batchOperationsTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Array of batch operations. Each item has "action" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject) and "body" (the record data, must include objectID for update/delete)', + 'Array of batch operations. Each item has "action" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and "body" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)', }, }, request: { url: (params) => - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/batch`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/batch`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, diff --git a/apps/sim/tools/algolia/browse_records.ts b/apps/sim/tools/algolia/browse_records.ts index 7b6dc3064f6..c466879fae0 100644 --- a/apps/sim/tools/algolia/browse_records.ts +++ b/apps/sim/tools/algolia/browse_records.ts @@ -62,11 +62,36 @@ export const browseRecordsTool: ToolConfig< visibility: 'user-or-llm', description: 'Cursor from a previous browse response for pagination', }, + aroundLatLng: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Coordinates for geo-search (e.g., "40.71,-74.01")', + }, + aroundRadius: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum radius in meters for geo-search, or "all" for unlimited', + }, + insideBoundingBox: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search', + }, + insidePolygon: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search', + }, }, request: { url: (params) => - `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/browse`, + `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/browse`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, @@ -86,6 +111,22 @@ export const browseRecordsTool: ToolConfig< .map((a: string) => a.trim()) } if (params.hitsPerPage !== undefined) body.hitsPerPage = Number(params.hitsPerPage) + if (params.aroundLatLng) body.aroundLatLng = params.aroundLatLng + if (params.aroundRadius !== undefined) { + body.aroundRadius = params.aroundRadius === 'all' ? 'all' : Number(params.aroundRadius) + } + if (params.insideBoundingBox) { + body.insideBoundingBox = + typeof params.insideBoundingBox === 'string' + ? JSON.parse(params.insideBoundingBox) + : params.insideBoundingBox + } + if (params.insidePolygon) { + body.insidePolygon = + typeof params.insidePolygon === 'string' + ? JSON.parse(params.insidePolygon) + : params.insidePolygon + } return body }, }, diff --git a/apps/sim/tools/algolia/clear_records.ts b/apps/sim/tools/algolia/clear_records.ts index 1c789ea8fdc..b647e3912f9 100644 --- a/apps/sim/tools/algolia/clear_records.ts +++ b/apps/sim/tools/algolia/clear_records.ts @@ -32,7 +32,7 @@ export const clearRecordsTool: ToolConfig - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/clear`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/clear`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, diff --git a/apps/sim/tools/algolia/copy_move_index.ts b/apps/sim/tools/algolia/copy_move_index.ts index 7174d1f9912..970d18d6414 100644 --- a/apps/sim/tools/algolia/copy_move_index.ts +++ b/apps/sim/tools/algolia/copy_move_index.ts @@ -55,7 +55,7 @@ export const copyMoveIndexTool: ToolConfig< request: { url: (params) => - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/operation`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/operation`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, @@ -65,7 +65,7 @@ export const copyMoveIndexTool: ToolConfig< body: (params) => { const body: Record = { operation: params.operation, - destination: params.destination, + destination: params.destination.trim(), } if (params.scope) { const scope = typeof params.scope === 'string' ? JSON.parse(params.scope) : params.scope diff --git a/apps/sim/tools/algolia/delete_by_filter.ts b/apps/sim/tools/algolia/delete_by_filter.ts index ca030f07f5c..b79cc0f2d80 100644 --- a/apps/sim/tools/algolia/delete_by_filter.ts +++ b/apps/sim/tools/algolia/delete_by_filter.ts @@ -63,7 +63,7 @@ export const deleteByFilterTool: ToolConfig< description: 'Coordinates for geo-search filter (e.g., "40.71,-74.01")', }, aroundRadius: { - type: 'number', + type: 'string', required: false, visibility: 'user-or-llm', description: 'Maximum radius in meters for geo-search, or "all" for unlimited', @@ -85,7 +85,7 @@ export const deleteByFilterTool: ToolConfig< request: { url: (params) => - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/deleteByQuery`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/deleteByQuery`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, diff --git a/apps/sim/tools/algolia/delete_index.ts b/apps/sim/tools/algolia/delete_index.ts index 7e206aafcbf..130f56ee5a9 100644 --- a/apps/sim/tools/algolia/delete_index.ts +++ b/apps/sim/tools/algolia/delete_index.ts @@ -31,7 +31,7 @@ export const deleteIndexTool: ToolConfig - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}`, headers: (params) => ({ 'x-algolia-application-id': params.applicationId, 'x-algolia-api-key': params.apiKey, diff --git a/apps/sim/tools/algolia/delete_record.ts b/apps/sim/tools/algolia/delete_record.ts index db63ee70593..5332d322a67 100644 --- a/apps/sim/tools/algolia/delete_record.ts +++ b/apps/sim/tools/algolia/delete_record.ts @@ -38,7 +38,7 @@ export const deleteRecordTool: ToolConfig - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}`, headers: (params) => ({ 'x-algolia-application-id': params.applicationId, 'x-algolia-api-key': params.apiKey, diff --git a/apps/sim/tools/algolia/get_record.ts b/apps/sim/tools/algolia/get_record.ts index 88992c0feb8..61aa7482765 100644 --- a/apps/sim/tools/algolia/get_record.ts +++ b/apps/sim/tools/algolia/get_record.ts @@ -43,7 +43,7 @@ export const getRecordTool: ToolConfig { - const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}` + const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}` if (params.attributesToRetrieve) { return `${base}?attributesToRetrieve=${encodeURIComponent(params.attributesToRetrieve)}` } diff --git a/apps/sim/tools/algolia/get_records.ts b/apps/sim/tools/algolia/get_records.ts index 15199f0a8e7..4c1b2f355fe 100644 --- a/apps/sim/tools/algolia/get_records.ts +++ b/apps/sim/tools/algolia/get_records.ts @@ -48,7 +48,8 @@ export const getRecordsTool: ToolConfig[]).map((req) => ({ ...req, - indexName: req.indexName ?? params.indexName, + indexName: + typeof req.indexName === 'string' ? req.indexName.trim() : params.indexName.trim(), })) return { requests } }, diff --git a/apps/sim/tools/algolia/get_settings.ts b/apps/sim/tools/algolia/get_settings.ts index 16bc90ddfd0..db3b7aeebd8 100644 --- a/apps/sim/tools/algolia/get_settings.ts +++ b/apps/sim/tools/algolia/get_settings.ts @@ -31,7 +31,7 @@ export const getSettingsTool: ToolConfig - `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/settings`, + `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/settings`, headers: (params) => ({ 'x-algolia-application-id': params.applicationId, 'x-algolia-api-key': params.apiKey, diff --git a/apps/sim/tools/algolia/get_task_status.ts b/apps/sim/tools/algolia/get_task_status.ts new file mode 100644 index 00000000000..c8ea30d0809 --- /dev/null +++ b/apps/sim/tools/algolia/get_task_status.ts @@ -0,0 +1,70 @@ +import type { + AlgoliaGetTaskStatusParams, + AlgoliaGetTaskStatusResponse, +} from '@/tools/algolia/types' +import type { ToolConfig } from '@/tools/types' + +export const getTaskStatusTool: ToolConfig< + AlgoliaGetTaskStatusParams, + AlgoliaGetTaskStatusResponse +> = { + id: 'algolia_get_task_status', + name: 'Algolia Get Task Status', + description: 'Check whether an Algolia indexing task has finished publishing', + version: '1.0', + + params: { + applicationId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Algolia Application ID', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Algolia API Key', + }, + indexName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the Algolia index the task ran against', + }, + taskID: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The taskID returned by a previous write operation', + }, + }, + + request: { + method: 'GET', + url: (params) => + `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/task/${encodeURIComponent(String(params.taskID).trim())}`, + headers: (params) => ({ + 'x-algolia-application-id': params.applicationId, + 'x-algolia-api-key': params.apiKey, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + status: data.status ?? '', + }, + } + }, + + outputs: { + status: { + type: 'string', + description: + 'Task status: "published" once the operation has been applied, "notPublished" while still pending', + }, + }, +} diff --git a/apps/sim/tools/algolia/index.ts b/apps/sim/tools/algolia/index.ts index b5fdc4bd70f..a86ac448f73 100644 --- a/apps/sim/tools/algolia/index.ts +++ b/apps/sim/tools/algolia/index.ts @@ -9,6 +9,7 @@ import { deleteRecordTool } from '@/tools/algolia/delete_record' import { getRecordTool } from '@/tools/algolia/get_record' import { getRecordsTool } from '@/tools/algolia/get_records' import { getSettingsTool } from '@/tools/algolia/get_settings' +import { getTaskStatusTool } from '@/tools/algolia/get_task_status' import { listIndicesTool } from '@/tools/algolia/list_indices' import { partialUpdateRecordTool } from '@/tools/algolia/partial_update_record' import { searchTool } from '@/tools/algolia/search' @@ -24,6 +25,7 @@ export const algoliaBrowseRecordsTool = browseRecordsTool export const algoliaBatchOperationsTool = batchOperationsTool export const algoliaListIndicesTool = listIndicesTool export const algoliaGetSettingsTool = getSettingsTool +export const algoliaGetTaskStatusTool = getTaskStatusTool export const algoliaUpdateSettingsTool = updateSettingsTool export const algoliaDeleteIndexTool = deleteIndexTool export const algoliaCopyMoveIndexTool = copyMoveIndexTool diff --git a/apps/sim/tools/algolia/list_indices.ts b/apps/sim/tools/algolia/list_indices.ts index 5e6f3d30e75..b938be24405 100644 --- a/apps/sim/tools/algolia/list_indices.ts +++ b/apps/sim/tools/algolia/list_indices.ts @@ -37,7 +37,7 @@ export const listIndicesTool: ToolConfig { - const base = `https://${params.applicationId}.algolia.net/1/indexes` + const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes` const queryParams: string[] = [] if (params.page !== undefined) queryParams.push(`page=${params.page}`) if (params.hitsPerPage !== undefined) queryParams.push(`hitsPerPage=${params.hitsPerPage}`) diff --git a/apps/sim/tools/algolia/partial_update_record.ts b/apps/sim/tools/algolia/partial_update_record.ts index ce6430ecbd8..80f6214c07c 100644 --- a/apps/sim/tools/algolia/partial_update_record.ts +++ b/apps/sim/tools/algolia/partial_update_record.ts @@ -55,7 +55,7 @@ export const partialUpdateRecordTool: ToolConfig< request: { url: (params) => { - const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}/partial` + const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}/partial` if (params.createIfNotExists === false) { return `${base}?createIfNotExists=false` } diff --git a/apps/sim/tools/algolia/search.ts b/apps/sim/tools/algolia/search.ts index fb46fd7dc41..f02901eeaaa 100644 --- a/apps/sim/tools/algolia/search.ts +++ b/apps/sim/tools/algolia/search.ts @@ -56,6 +56,44 @@ export const searchTool: ToolConfig visibility: 'user-or-llm', description: 'Comma-separated list of attributes to retrieve', }, + facets: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated list of facet attribute names to retrieve counts for (use "*" for all)', + }, + getRankingInfo: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include detailed ranking information in each hit', + }, + aroundLatLng: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Coordinates for geo-search (e.g., "40.71,-74.01")', + }, + aroundRadius: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum radius in meters for geo-search, or "all" for unlimited', + }, + insideBoundingBox: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search', + }, + insidePolygon: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search', + }, }, request: { @@ -68,7 +106,7 @@ export const searchTool: ToolConfig }), body: (params) => { const request: Record = { - indexName: params.indexName, + indexName: params.indexName.trim(), query: params.query, } if (params.hitsPerPage !== undefined) request.hitsPerPage = Number(params.hitsPerPage) @@ -79,6 +117,26 @@ export const searchTool: ToolConfig .split(',') .map((a: string) => a.trim()) } + if (params.facets) { + request.facets = params.facets.split(',').map((f: string) => f.trim()) + } + if (params.getRankingInfo) request.getRankingInfo = true + if (params.aroundLatLng) request.aroundLatLng = params.aroundLatLng + if (params.aroundRadius !== undefined) { + request.aroundRadius = params.aroundRadius === 'all' ? 'all' : Number(params.aroundRadius) + } + if (params.insideBoundingBox) { + request.insideBoundingBox = + typeof params.insideBoundingBox === 'string' + ? JSON.parse(params.insideBoundingBox) + : params.insideBoundingBox + } + if (params.insidePolygon) { + request.insidePolygon = + typeof params.insidePolygon === 'string' + ? JSON.parse(params.insidePolygon) + : params.insidePolygon + } return { requests: [request] } }, }, diff --git a/apps/sim/tools/algolia/types.ts b/apps/sim/tools/algolia/types.ts index c297871d6d0..3e30293a52a 100644 --- a/apps/sim/tools/algolia/types.ts +++ b/apps/sim/tools/algolia/types.ts @@ -13,6 +13,12 @@ export interface AlgoliaSearchParams extends AlgoliaBaseParams { page?: number | string filters?: string attributesToRetrieve?: string + facets?: string + getRankingInfo?: boolean | string + aroundLatLng?: string + aroundRadius?: number | string + insideBoundingBox?: string | number[][] + insidePolygon?: string | number[][] } export interface AlgoliaSearchResponse extends ToolResponse { @@ -116,6 +122,10 @@ export interface AlgoliaBrowseRecordsParams extends AlgoliaBaseParams { attributesToRetrieve?: string hitsPerPage?: number | string cursor?: string + aroundLatLng?: string + aroundRadius?: number | string + insideBoundingBox?: string | number[][] + insidePolygon?: string | number[][] } export interface AlgoliaBrowseRecordsResponse extends ToolResponse { @@ -266,3 +276,15 @@ export interface AlgoliaDeleteByFilterResponse extends ToolResponse { updatedAt: string | null } } + +// Get Task Status +export interface AlgoliaGetTaskStatusParams extends AlgoliaBaseParams { + indexName: string + taskID: number | string +} + +export interface AlgoliaGetTaskStatusResponse extends ToolResponse { + output: { + status: string + } +} diff --git a/apps/sim/tools/algolia/update_settings.ts b/apps/sim/tools/algolia/update_settings.ts index e8ff7f873aa..e3c4dd367fa 100644 --- a/apps/sim/tools/algolia/update_settings.ts +++ b/apps/sim/tools/algolia/update_settings.ts @@ -49,7 +49,7 @@ export const updateSettingsTool: ToolConfig< request: { url: (params) => { - const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/settings` + const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/settings` if (params.forwardToReplicas) { return `${base}?forwardToReplicas=true` } diff --git a/apps/sim/tools/amplitude/event_segmentation.ts b/apps/sim/tools/amplitude/event_segmentation.ts index d5cbab3357a..394adf07ccf 100644 --- a/apps/sim/tools/amplitude/event_segmentation.ts +++ b/apps/sim/tools/amplitude/event_segmentation.ts @@ -2,6 +2,7 @@ import type { AmplitudeEventSegmentationParams, AmplitudeEventSegmentationResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const eventSegmentationTool: ToolConfig< @@ -64,25 +65,81 @@ export const eventSegmentationTool: ToolConfig< visibility: 'user-or-llm', description: 'Property name to group by (prefix custom user properties with "gp:")', }, + groupBy2: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Second property name to group by (prefix custom user properties with "gp:")', + }, limit: { type: 'string', required: false, visibility: 'user-or-llm', description: 'Maximum number of group-by values (max 1000)', }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects applied to the event, e.g. [{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":["San Francisco"]}]', + }, + formula: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Required when metric is "formula", e.g. "UNIQUES(A)/UNIQUES(B)"', + }, + segment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON segment definition(s) applied to the query', + }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { url: (params) => { - const url = new URL('https://amplitude.com/api/2/events/segmentation') - const eventObj = JSON.stringify({ event_type: params.eventType }) - url.searchParams.set('e', eventObj) + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/events/segmentation`) + const event: Record = { event_type: params.eventType } + + if (params.filters) { + let parsedFilters: unknown + try { + parsedFilters = JSON.parse(params.filters) + } catch { + parsedFilters = undefined + } + if (!Array.isArray(parsedFilters)) { + throw new Error( + 'Amplitude Event Segmentation: "filters" must be a valid JSON array of filter objects' + ) + } + event.filters = parsedFilters + } + + if (params.metric === 'formula' && !params.formula) { + throw new Error( + 'Amplitude Event Segmentation: "formula" is required when metric is "formula"' + ) + } + + url.searchParams.set('e', JSON.stringify(event)) url.searchParams.set('start', params.start) url.searchParams.set('end', params.end) if (params.metric) url.searchParams.set('m', params.metric) if (params.interval) url.searchParams.set('i', params.interval) if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.groupBy2) url.searchParams.set('g2', params.groupBy2) if (params.limit) url.searchParams.set('limit', params.limit) + if (params.formula) url.searchParams.set('formula', params.formula) + if (params.segment) url.searchParams.set('s', params.segment) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/amplitude/funnels.ts b/apps/sim/tools/amplitude/funnels.ts new file mode 100644 index 00000000000..7eed8cdc883 --- /dev/null +++ b/apps/sim/tools/amplitude/funnels.ts @@ -0,0 +1,195 @@ +import type { AmplitudeFunnelsParams, AmplitudeFunnelsResponse } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' +import type { ToolConfig } from '@/tools/types' + +export const funnelsTool: ToolConfig = { + id: 'amplitude_funnels', + name: 'Amplitude Funnels', + description: 'Analyze conversion rates and drop-off between a sequence of events.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Amplitude API Key', + }, + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Amplitude Secret Key', + }, + events: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of event objects, one per funnel step in order, e.g. [{"event_type":"signup"},{"event_type":"purchase"}]', + }, + start: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date in YYYYMMDD format', + }, + end: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date in YYYYMMDD format', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Funnel ordering: "ordered", "unordered", or "sequential" (default: ordered)', + }, + userType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'User type: "new" or "active" (default: active)', + }, + interval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)', + }, + conversionWindowSeconds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Conversion window in seconds (default: 2592000, i.e. 30 days)', + }, + groupBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Property to group by (limit: one; prefix custom properties with "gp:")', + }, + limit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of group-by values (default: 100, max: 1000)', + }, + segment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON segment definition(s) applied to the query', + }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/funnels`) + let parsed: unknown + try { + parsed = JSON.parse(params.events) + } catch { + throw new Error('Amplitude Funnels: "events" must be a valid JSON array of event objects') + } + const isPlainObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + + if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isPlainObject)) { + throw new Error( + 'Amplitude Funnels: "events" must be a non-empty JSON array of event objects' + ) + } + for (const step of parsed) { + url.searchParams.append('e', JSON.stringify(step)) + } + url.searchParams.set('start', params.start) + url.searchParams.set('end', params.end) + if (params.mode) url.searchParams.set('mode', params.mode) + if (params.userType) url.searchParams.set('n', params.userType) + if (params.interval) url.searchParams.set('i', params.interval) + if (params.conversionWindowSeconds) url.searchParams.set('cs', params.conversionWindowSeconds) + if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.limit) url.searchParams.set('limit', params.limit) + if (params.segment) url.searchParams.set('s', params.segment) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || `Amplitude Funnels API error: ${response.status}`) + } + + const results = (Array.isArray(data.data) ? data.data : []) as Array> + + const funnels = results.map((r) => { + const dayFunnels = r.dayFunnels as Record | undefined + return { + stepByStep: (r.stepByStep as number[]) ?? [], + cumulative: (r.cumulative as number[]) ?? [], + cumulativeRaw: (r.cumulativeRaw as number[]) ?? [], + medianTransTimes: (r.medianTransTimes as number[]) ?? [], + avgTransTimes: (r.avgTransTimes as number[]) ?? [], + events: (r.events as string[]) ?? [], + dayFunnels: dayFunnels + ? { + series: (dayFunnels.series as number[][]) ?? [], + xValues: (dayFunnels.xValues as string[]) ?? [], + } + : null, + } + }) + + return { + success: true, + output: { funnels }, + } + }, + + outputs: { + funnels: { + type: 'array', + description: 'Funnel results, one entry per segment', + items: { + type: 'object', + properties: { + stepByStep: { type: 'json', description: 'Conversion count at each step' }, + cumulative: { + type: 'json', + description: 'Cumulative conversion percentage at each step', + }, + cumulativeRaw: { type: 'json', description: 'Cumulative conversion count at each step' }, + medianTransTimes: { + type: 'json', + description: 'Median transition time between steps (ms)', + }, + avgTransTimes: { + type: 'json', + description: 'Average transition time between steps (ms)', + }, + events: { type: 'json', description: 'Event names for each funnel step' }, + dayFunnels: { + type: 'json', + description: 'Daily funnel breakdown {series, xValues}', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/amplitude/get_active_users.ts b/apps/sim/tools/amplitude/get_active_users.ts index 6670e5ab150..a935d9919be 100644 --- a/apps/sim/tools/amplitude/get_active_users.ts +++ b/apps/sim/tools/amplitude/get_active_users.ts @@ -2,6 +2,7 @@ import type { AmplitudeGetActiveUsersParams, AmplitudeGetActiveUsersResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const getActiveUsersTool: ToolConfig< @@ -50,15 +51,35 @@ export const getActiveUsersTool: ToolConfig< visibility: 'user-or-llm', description: 'Time interval: 1 (daily), 7 (weekly), or 30 (monthly)', }, + groupBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Property name to group by', + }, + segment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON segment definition(s) applied to the query', + }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { url: (params) => { - const url = new URL('https://amplitude.com/api/2/users') + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/users`) url.searchParams.set('start', params.start) url.searchParams.set('end', params.end) if (params.metric) url.searchParams.set('m', params.metric) if (params.interval) url.searchParams.set('i', params.interval) + if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.segment) url.searchParams.set('s', params.segment) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/amplitude/get_revenue.ts b/apps/sim/tools/amplitude/get_revenue.ts index 264eb1e0f95..5f1861bdd86 100644 --- a/apps/sim/tools/amplitude/get_revenue.ts +++ b/apps/sim/tools/amplitude/get_revenue.ts @@ -2,6 +2,7 @@ import type { AmplitudeGetRevenueParams, AmplitudeGetRevenueResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const getRevenueTool: ToolConfig = { @@ -47,15 +48,35 @@ export const getRevenueTool: ToolConfig { - const url = new URL('https://amplitude.com/api/2/revenue/ltv') + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/revenue/ltv`) url.searchParams.set('start', params.start) url.searchParams.set('end', params.end) if (params.metric) url.searchParams.set('m', params.metric) if (params.interval) url.searchParams.set('i', params.interval) + if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.segment) url.searchParams.set('s', params.segment) return url.toString() }, method: 'GET', @@ -78,25 +99,35 @@ export const getRevenueTool: ToolConfig: {r1d..r90d, count, paid, total_amount}}}]', + items: { + type: 'json', + properties: { + dates: { + type: 'array', + description: 'Dates covered by this series', + items: { type: 'string' }, + }, + values: { + type: 'json', + description: + 'Per-date metric values keyed by date (r1d..r90d, count, paid, total_amount)', + }, + }, + }, }, seriesLabels: { type: 'array', description: 'Labels for each data series', items: { type: 'string' }, }, - xValues: { - type: 'array', - description: 'Date values for the x-axis', - items: { type: 'string' }, - }, }, } diff --git a/apps/sim/tools/amplitude/group_identify.ts b/apps/sim/tools/amplitude/group_identify.ts index b0bd548c49d..6cdefbb320f 100644 --- a/apps/sim/tools/amplitude/group_identify.ts +++ b/apps/sim/tools/amplitude/group_identify.ts @@ -2,6 +2,7 @@ import type { AmplitudeGroupIdentifyParams, AmplitudeGroupIdentifyResponse, } from '@/tools/amplitude/types' +import { getIngestionHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const groupIdentifyTool: ToolConfig< @@ -40,13 +41,19 @@ export const groupIdentifyTool: ToolConfig< description: 'JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset.', }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { - url: 'https://api2.amplitude.com/groupidentify', + url: (params) => `${getIngestionHost(params.dataResidency)}/groupidentify`, method: 'POST', headers: () => ({ - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', }), body: (params) => { let groupProperties: Record = {} @@ -56,16 +63,20 @@ export const groupIdentifyTool: ToolConfig< groupProperties = {} } - return { + const identification = [ + { + group_type: params.groupType, + group_value: params.groupValue, + group_properties: groupProperties, + }, + ] + + const body = new URLSearchParams({ api_key: params.apiKey, - identification: [ - { - group_type: params.groupType, - group_value: params.groupValue, - group_properties: groupProperties, - }, - ], - } + identification: JSON.stringify(identification), + }) + + return body.toString() }, }, diff --git a/apps/sim/tools/amplitude/identify_user.ts b/apps/sim/tools/amplitude/identify_user.ts index a0cb0316805..754316dbea1 100644 --- a/apps/sim/tools/amplitude/identify_user.ts +++ b/apps/sim/tools/amplitude/identify_user.ts @@ -2,6 +2,7 @@ import type { AmplitudeIdentifyUserParams, AmplitudeIdentifyUserResponse, } from '@/tools/amplitude/types' +import { getIngestionHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const identifyUserTool: ToolConfig< @@ -40,13 +41,19 @@ export const identifyUserTool: ToolConfig< description: 'JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset.', }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { - url: 'https://api2.amplitude.com/identify', + url: (params) => `${getIngestionHost(params.dataResidency)}/identify`, method: 'POST', headers: () => ({ - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', }), body: (params) => { const identification: Record = {} @@ -60,10 +67,12 @@ export const identifyUserTool: ToolConfig< identification.user_properties = {} } - return { + const body = new URLSearchParams({ api_key: params.apiKey, - identification: [identification], - } + identification: JSON.stringify([identification]), + }) + + return body.toString() }, }, diff --git a/apps/sim/tools/amplitude/index.ts b/apps/sim/tools/amplitude/index.ts index b0f1aab792f..6aca5c1ff9f 100644 --- a/apps/sim/tools/amplitude/index.ts +++ b/apps/sim/tools/amplitude/index.ts @@ -1,10 +1,12 @@ import { eventSegmentationTool } from '@/tools/amplitude/event_segmentation' +import { funnelsTool } from '@/tools/amplitude/funnels' import { getActiveUsersTool } from '@/tools/amplitude/get_active_users' import { getRevenueTool } from '@/tools/amplitude/get_revenue' import { groupIdentifyTool } from '@/tools/amplitude/group_identify' import { identifyUserTool } from '@/tools/amplitude/identify_user' import { listEventsTool } from '@/tools/amplitude/list_events' import { realtimeActiveUsersTool } from '@/tools/amplitude/realtime_active_users' +import { retentionTool } from '@/tools/amplitude/retention' import { sendEventTool } from '@/tools/amplitude/send_event' import { userActivityTool } from '@/tools/amplitude/user_activity' import { userProfileTool } from '@/tools/amplitude/user_profile' @@ -21,3 +23,5 @@ export const amplitudeGetActiveUsersTool = getActiveUsersTool export const amplitudeRealtimeActiveUsersTool = realtimeActiveUsersTool export const amplitudeListEventsTool = listEventsTool export const amplitudeGetRevenueTool = getRevenueTool +export const amplitudeFunnelsTool = funnelsTool +export const amplitudeRetentionTool = retentionTool diff --git a/apps/sim/tools/amplitude/list_events.ts b/apps/sim/tools/amplitude/list_events.ts index fa89d3ddf11..a1685bf0ee6 100644 --- a/apps/sim/tools/amplitude/list_events.ts +++ b/apps/sim/tools/amplitude/list_events.ts @@ -2,6 +2,7 @@ import type { AmplitudeListEventsParams, AmplitudeListEventsResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const listEventsTool: ToolConfig = { @@ -24,10 +25,16 @@ export const listEventsTool: ToolConfig `${getDashboardHost(params.dataResidency)}/api/2/events/list`, method: 'GET', headers: (params) => ({ Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`, @@ -49,6 +56,8 @@ export const listEventsTool: ToolConfig

${escapeHtml(pageContent)}

`, }, ], }, diff --git a/apps/sim/tools/sharepoint/delete_file.ts b/apps/sim/tools/sharepoint/delete_file.ts new file mode 100644 index 00000000000..183ac484762 --- /dev/null +++ b/apps/sim/tools/sharepoint/delete_file.ts @@ -0,0 +1,66 @@ +import type { SharepointDeleteFileResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteFileTool: ToolConfig = { + id: 'sharepoint_delete_file', + name: 'Delete SharePoint File', + description: 'Delete a file (or folder) from a SharePoint document library', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + driveId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document library (drive). Example: b!abc123def456', + }, + driveItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the file (drive item) to delete', + }, + }, + + request: { + url: (params) => { + const driveId = optionalTrim(params.driveId) + const driveItemId = optionalTrim(params.driveItemId) + if (!driveId) throw new Error('driveId must be provided') + if (!driveItemId) throw new Error('driveItemId must be provided') + return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + deleted: true, + itemId: params?.driveItemId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the file was deleted' }, + itemId: { type: 'string', description: 'The ID of the deleted file' }, + }, +} diff --git a/apps/sim/tools/sharepoint/delete_list_item.ts b/apps/sim/tools/sharepoint/delete_list_item.ts new file mode 100644 index 00000000000..3c0c7dbb4ff --- /dev/null +++ b/apps/sim/tools/sharepoint/delete_list_item.ts @@ -0,0 +1,88 @@ +import type { + SharepointDeleteListItemResponse, + SharepointToolParams, +} from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteListItemTool: ToolConfig< + SharepointToolParams, + SharepointDeleteListItemResponse +> = { + id: 'sharepoint_delete_list_item', + name: 'Delete SharePoint List Item', + description: 'Delete an item from a SharePoint list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the list item to delete. Example: 1, 42, or 123', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const listId = optionalTrim(params.listId) + const itemId = optionalTrim(params.itemId) + if (!listId) throw new Error('listId must be provided') + if (!itemId) throw new Error('itemId must be provided') + const listSegment = encodeURIComponent(listId) + const itemSegment = encodeURIComponent(itemId) + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + deleted: true, + itemId: params?.itemId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the list item was deleted' }, + itemId: { type: 'string', description: 'The ID of the deleted list item' }, + }, +} diff --git a/apps/sim/tools/sharepoint/delete_page.ts b/apps/sim/tools/sharepoint/delete_page.ts new file mode 100644 index 00000000000..6a061951cb7 --- /dev/null +++ b/apps/sim/tools/sharepoint/delete_page.ts @@ -0,0 +1,72 @@ +import type { SharepointDeletePageResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const deletePageTool: ToolConfig = { + id: 'sharepoint_delete_page', + name: 'Delete SharePoint Page', + description: 'Delete a page from a SharePoint site', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + pageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the page to delete. Example: a GUID like 12345678-1234-1234-1234-123456789012', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const pageId = optionalTrim(params.pageId) + if (!pageId) throw new Error('pageId must be provided') + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + deleted: true, + pageId: params?.pageId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the page was deleted' }, + pageId: { type: 'string', description: 'The ID of the deleted page' }, + }, +} diff --git a/apps/sim/tools/sharepoint/download_file.ts b/apps/sim/tools/sharepoint/download_file.ts new file mode 100644 index 00000000000..add8673b254 --- /dev/null +++ b/apps/sim/tools/sharepoint/download_file.ts @@ -0,0 +1,59 @@ +import type { SharepointDownloadFileResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import type { ToolConfig } from '@/tools/types' + +export const downloadFileTool: ToolConfig = { + id: 'sharepoint_download_file', + name: 'Download File from SharePoint', + description: 'Download a file from a SharePoint document library', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + driveId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document library (drive). Example: b!abc123def456', + }, + driveItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the file (drive item) to download', + }, + fileName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional filename override (e.g., "report.pdf", "data.xlsx")', + }, + }, + + request: { + url: '/api/tools/sharepoint/download-file', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + driveId: params.driveId, + itemId: params.driveItemId, + fileName: params.fileName, + }), + }, + + outputs: { + file: { type: 'file', description: 'Downloaded file stored in execution files' }, + }, +} diff --git a/apps/sim/tools/sharepoint/get_drive_item.ts b/apps/sim/tools/sharepoint/get_drive_item.ts new file mode 100644 index 00000000000..0b6f689aaf6 --- /dev/null +++ b/apps/sim/tools/sharepoint/get_drive_item.ts @@ -0,0 +1,106 @@ +import type { + SharepointDriveItem, + SharepointGetDriveItemResponse, + SharepointToolParams, +} from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const getDriveItemTool: ToolConfig = { + id: 'sharepoint_get_drive_item', + name: 'Get SharePoint Drive Item', + description: 'Get metadata for a file or folder in a SharePoint document library', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + driveId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document library (drive). Example: b!abc123def456', + }, + driveItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the file or folder (drive item) to retrieve', + }, + }, + + request: { + url: (params) => { + const driveId = optionalTrim(params.driveId) + const driveItemId = optionalTrim(params.driveItemId) + if (!driveId) throw new Error('driveId must be provided') + if (!driveItemId) throw new Error('driveItemId must be provided') + return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data: Record = await response.json() + + const driveItem: SharepointDriveItem = { + id: data.id as string, + name: data.name as string, + webUrl: data.webUrl as string | undefined, + size: data.size as number | undefined, + createdDateTime: data.createdDateTime as string | undefined, + lastModifiedDateTime: data.lastModifiedDateTime as string | undefined, + file: (data.file as SharepointDriveItem['file']) ?? null, + folder: (data.folder as SharepointDriveItem['folder']) ?? null, + parentReference: (data.parentReference as SharepointDriveItem['parentReference']) ?? null, + } + + return { + success: true, + output: { driveItem }, + } + }, + + outputs: { + driveItem: { + type: 'object', + description: 'Metadata for the SharePoint file or folder', + properties: { + id: { type: 'string', description: 'The unique ID of the drive item' }, + name: { type: 'string', description: 'The name of the file or folder' }, + webUrl: { type: 'string', description: 'The URL to access the item' }, + size: { type: 'number', description: 'The size of the item in bytes', optional: true }, + createdDateTime: { type: 'string', description: 'When the item was created' }, + lastModifiedDateTime: { type: 'string', description: 'When the item was last modified' }, + file: { + type: 'object', + description: 'Present if the item is a file (contains mimeType)', + optional: true, + }, + folder: { + type: 'object', + description: 'Present if the item is a folder (contains childCount)', + optional: true, + }, + parentReference: { + type: 'object', + description: 'Reference to the parent folder/drive', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/sharepoint/get_list.ts b/apps/sim/tools/sharepoint/get_list.ts index bcf3036a977..a4061e32f57 100644 --- a/apps/sim/tools/sharepoint/get_list.ts +++ b/apps/sim/tools/sharepoint/get_list.ts @@ -73,10 +73,11 @@ export const getListTool: ToolConfig 0) url.searchParams.append('$expand', expandParts.join(',')) const finalUrl = url.toString() @@ -128,21 +129,23 @@ export const getListTool: ToolConfig { - const data = await response.json() + const data: Record = await response.json() + const value = data.value // If the response is a collection of items (from the items endpoint) if ( - Array.isArray((data as any).value) && - (data as any).value.length > 0 && - (data as any).value[0] && - 'fields' in (data as any).value[0] + Array.isArray(value) && + value.length > 0 && + value[0] && + typeof value[0] === 'object' && + 'fields' in value[0] ) { - const items = (data as any).value.map((i: any) => ({ - id: i.id, + const items = value.map((i: Record) => ({ + id: i.id as string, fields: i.fields as Record, })) - const nextPageUrl = getGraphNextPageUrl(data as Record) + const nextPageUrl = getGraphNextPageUrl(data) return { success: true, @@ -154,18 +157,18 @@ export const getListTool: ToolConfig ({ - id: l.id, - displayName: l.displayName ?? l.name, - name: l.name, - webUrl: l.webUrl, - createdDateTime: l.createdDateTime, - lastModifiedDateTime: l.lastModifiedDateTime, - list: l.list, + if (Array.isArray(value)) { + const lists: SharepointList[] = value.map((l: Record) => ({ + id: l.id as string, + displayName: (l.displayName ?? l.name) as string | undefined, + name: l.name as string | undefined, + webUrl: l.webUrl as string | undefined, + createdDateTime: l.createdDateTime as string | undefined, + lastModifiedDateTime: l.lastModifiedDateTime as string | undefined, + list: l.list as SharepointList['list'], })) - const nextPageUrl = getGraphNextPageUrl(data as Record) + const nextPageUrl = getGraphNextPageUrl(data) return { success: true, @@ -174,29 +177,32 @@ export const getListTool: ToolConfig ({ - id: c.id, - name: c.name, - displayName: c.displayName, - description: c.description, - indexed: c.indexed, - enforcedUniqueValues: c.enforcedUniqueValues, - hidden: c.hidden, - readOnly: c.readOnly, - required: c.required, - columnGroup: c.columnGroup, + ? data.columns.map((c: Record) => ({ + id: c.id as string | undefined, + name: c.name as string | undefined, + displayName: c.displayName as string | undefined, + description: c.description as string | undefined, + indexed: c.indexed as boolean | undefined, + enforcedUniqueValues: c.enforcedUniqueValues as boolean | undefined, + hidden: c.hidden as boolean | undefined, + readOnly: c.readOnly as boolean | undefined, + required: c.required as boolean | undefined, + columnGroup: c.columnGroup as string | undefined, })) : undefined, items: Array.isArray(data.items) - ? data.items.map((i: any) => ({ id: i.id, fields: i.fields as Record })) + ? data.items.map((i: Record) => ({ + id: i.id as string, + fields: i.fields as Record, + })) : undefined, } diff --git a/apps/sim/tools/sharepoint/get_list_item.ts b/apps/sim/tools/sharepoint/get_list_item.ts new file mode 100644 index 00000000000..691ca97a45a --- /dev/null +++ b/apps/sim/tools/sharepoint/get_list_item.ts @@ -0,0 +1,96 @@ +import type { SharepointGetListItemResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const getListItemTool: ToolConfig = { + id: 'sharepoint_get_list_item', + name: 'Get SharePoint List Item', + description: 'Get a single item (with field values) from a SharePoint list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the list item to retrieve. Example: 1, 42, or 123', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const listId = optionalTrim(params.listId) + const itemId = optionalTrim(params.itemId) + if (!listId) throw new Error('listId must be provided') + if (!itemId) throw new Error('itemId must be provided') + const listSegment = encodeURIComponent(listId) + const itemSegment = encodeURIComponent(itemId) + const url = new URL( + `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}` + ) + url.searchParams.set('$expand', 'fields') + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data: Record = await response.json() + + return { + success: true, + output: { + item: { + id: data.id as string, + fields: data.fields as Record | undefined, + }, + }, + } + }, + + outputs: { + item: { + type: 'object', + description: 'SharePoint list item with field values', + properties: { + id: { type: 'string', description: 'Item ID' }, + fields: { type: 'object', description: 'Field values for the item' }, + }, + }, + }, +} diff --git a/apps/sim/tools/sharepoint/index.ts b/apps/sim/tools/sharepoint/index.ts index eaa798cd192..01feb9f8618 100644 --- a/apps/sim/tools/sharepoint/index.ts +++ b/apps/sim/tools/sharepoint/index.ts @@ -1,19 +1,35 @@ import { addListItemTool } from '@/tools/sharepoint/add_list_items' import { createListTool } from '@/tools/sharepoint/create_list' import { createPageTool } from '@/tools/sharepoint/create_page' +import { deleteFileTool } from '@/tools/sharepoint/delete_file' +import { deleteListItemTool } from '@/tools/sharepoint/delete_list_item' +import { deletePageTool } from '@/tools/sharepoint/delete_page' +import { downloadFileTool } from '@/tools/sharepoint/download_file' +import { getDriveItemTool } from '@/tools/sharepoint/get_drive_item' import { getListTool } from '@/tools/sharepoint/get_list' +import { getListItemTool } from '@/tools/sharepoint/get_list_item' import { listSitesTool } from '@/tools/sharepoint/list_sites' +import { publishPageTool } from '@/tools/sharepoint/publish_page' import { readPageTool } from '@/tools/sharepoint/read_page' import { updateListItemTool } from '@/tools/sharepoint/update_list' +import { updatePageTool } from '@/tools/sharepoint/update_page' import { uploadFileTool } from '@/tools/sharepoint/upload_file' +export const sharepointAddListItemTool = addListItemTool export const sharepointCreatePageTool = createPageTool export const sharepointCreateListTool = createListTool +export const sharepointDeleteFileTool = deleteFileTool +export const sharepointDeleteListItemTool = deleteListItemTool +export const sharepointDeletePageTool = deletePageTool +export const sharepointDownloadFileTool = downloadFileTool +export const sharepointGetDriveItemTool = getDriveItemTool export const sharepointGetListTool = getListTool +export const sharepointGetListItemTool = getListItemTool export const sharepointListSitesTool = listSitesTool +export const sharepointPublishPageTool = publishPageTool export const sharepointReadPageTool = readPageTool export const sharepointUpdateListItemTool = updateListItemTool -export const sharepointAddListItemTool = addListItemTool +export const sharepointUpdatePageTool = updatePageTool export const sharepointUploadFileTool = uploadFileTool export * from '@/tools/sharepoint/types' diff --git a/apps/sim/tools/sharepoint/list_sites.ts b/apps/sim/tools/sharepoint/list_sites.ts index 089010ac4c3..609ed400237 100644 --- a/apps/sim/tools/sharepoint/list_sites.ts +++ b/apps/sim/tools/sharepoint/list_sites.ts @@ -62,9 +62,9 @@ export const listSitesTool: ToolConfig = { + id: 'sharepoint_publish_page', + name: 'Publish SharePoint Page', + description: 'Publish the latest version of a SharePoint page, making it available to all users', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + pageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the page to publish. Example: a GUID like 12345678-1234-1234-1234-123456789012', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const pageId = optionalTrim(params.pageId) + if (!pageId) throw new Error('pageId must be provided') + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage/publish` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + published: true, + pageId: params?.pageId ?? '', + }, + } + }, + + outputs: { + published: { type: 'boolean', description: 'Whether the page was published' }, + pageId: { type: 'string', description: 'The ID of the published page' }, + }, +} diff --git a/apps/sim/tools/sharepoint/read_page.ts b/apps/sim/tools/sharepoint/read_page.ts index 554f747af0b..8785b21a5da 100644 --- a/apps/sim/tools/sharepoint/read_page.ts +++ b/apps/sim/tools/sharepoint/read_page.ts @@ -85,12 +85,13 @@ export const readPageTool: ToolConfig ({ id: p.id, name: p.name, title: p.title })), + foundPages: data.value.map((p) => ({ id: p.id, name: p.name, title: p.title })), totalCount: data.value.length, }) if (params?.pageName) { const pageData = data.value[0] - const siteId = params?.siteId || params?.siteSelector || 'root' - const contentUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageData.id}/microsoft.graph.sitePage?$expand=canvasLayout` + const siteId = optionalTrim(params?.siteId) || optionalTrim(params?.siteSelector) || 'root' + const contentUrl = `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageData.id)}/microsoft.graph.sitePage?$expand=canvasLayout` logger.info('Making API call to get page content for searched page', { pageId: pageData.id, @@ -253,7 +254,7 @@ export const readPageTool: ToolConfig - }> - webparts?: Array<{ - id: string - innerHtml: string - }> - }> - } + canvasLayout?: CanvasLayout } export interface SharepointPageContent { content: string - canvasLayout?: { - horizontalSections: Array<{ - layout: string - id: string - emphasis: string - webparts: Array<{ - id: string - innerHtml: string - }> - }> - } | null + canvasLayout?: CanvasLayout | null } interface SharepointColumn { @@ -132,9 +104,8 @@ export interface SharepointReadSiteResponse extends ToolResponse { createdDateTime?: string lastModifiedDateTime?: string isPersonalSite?: boolean - root?: { - serverRelativeUrl: string - } + // Graph returns an empty object marker (not a URL) when this site is the root of its site collection + root?: Record siteCollection?: { hostname: string } @@ -177,14 +148,15 @@ export interface SharepointToolParams { listDisplayName?: string listDescription?: string listTemplate?: string - // Update List Item + // Update List Item / Delete List Item / Get List Item itemId?: string listItemFields?: Record - // Upload File + // Upload File / Download File / Delete File / Get Drive Item driveId?: string folderPath?: string fileName?: string files?: UserFile[] + driveItemId?: string } export interface GraphApiResponse { @@ -220,6 +192,8 @@ export interface CanvasLayout { id?: string emphasis?: string columns?: Array<{ + id?: string + width?: number webparts?: Array<{ id?: string innerHtml?: string @@ -242,6 +216,14 @@ export type SharepointResponse = | SharepointUpdateListItemResponse | SharepointAddListItemResponse | SharepointUploadFileResponse + | SharepointDeleteListItemResponse + | SharepointGetListItemResponse + | SharepointDeletePageResponse + | SharepointUpdatePageResponse + | SharepointPublishPageResponse + | SharepointDownloadFileResponse + | SharepointDeleteFileResponse + | SharepointGetDriveItemResponse export interface SharepointGetListResponse extends ToolResponse { output: { @@ -307,3 +289,83 @@ export interface SharepointUploadFileResponse extends ToolResponse { errors?: SharepointUploadError[] } } + +export interface SharepointDeleteListItemResponse extends ToolResponse { + output: { + deleted: boolean + itemId: string + } +} + +export interface SharepointGetListItemResponse extends ToolResponse { + output: { + item: { + id: string + fields?: Record + } + } +} + +export interface SharepointDeletePageResponse extends ToolResponse { + output: { + deleted: boolean + pageId: string + } +} + +export interface SharepointUpdatePageResponse extends ToolResponse { + output: { + page: SharepointPage + } +} + +export interface SharepointPublishPageResponse extends ToolResponse { + output: { + published: boolean + pageId: string + } +} + +export interface SharepointDriveItem { + id: string + name: string + webUrl?: string + size?: number + createdDateTime?: string + lastModifiedDateTime?: string + file?: { + mimeType?: string + } | null + folder?: { + childCount?: number + } | null + parentReference?: { + id?: string + driveId?: string + path?: string + } | null +} + +export interface SharepointDownloadFileResponse extends ToolResponse { + output: { + file: { + name: string + mimeType: string + data: Buffer | string + size: number + } + } +} + +export interface SharepointDeleteFileResponse extends ToolResponse { + output: { + deleted: boolean + itemId: string + } +} + +export interface SharepointGetDriveItemResponse extends ToolResponse { + output: { + driveItem: SharepointDriveItem + } +} diff --git a/apps/sim/tools/sharepoint/update_list.ts b/apps/sim/tools/sharepoint/update_list.ts index a0511e45f2b..850c97f8f03 100644 --- a/apps/sim/tools/sharepoint/update_list.ts +++ b/apps/sim/tools/sharepoint/update_list.ts @@ -1,13 +1,10 @@ -import { createLogger } from '@sim/logger' import type { SharepointToolParams, SharepointUpdateListItemResponse, } from '@/tools/sharepoint/types' -import { optionalTrim } from '@/tools/sharepoint/utils' +import { optionalTrim, sanitizeListItemFields } from '@/tools/sharepoint/utils' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('SharePointUpdateListItem') - export const updateListItemTool: ToolConfig< SharepointToolParams, SharepointUpdateListItemResponse @@ -72,7 +69,7 @@ export const updateListItemTool: ToolConfig< throw new Error('listId must be provided') } const listSegment = encodeURIComponent(listId) - return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields` + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields` }, method: 'PATCH', headers: (params) => ({ @@ -85,53 +82,7 @@ export const updateListItemTool: ToolConfig< throw new Error('listItemFields must not be empty') } - // Filter out system/read-only fields that cannot be updated via Graph - const readOnlyFields = new Set([ - 'Id', - 'id', - 'UniqueId', - 'GUID', - 'ContentTypeId', - 'Created', - 'Modified', - 'Author', - 'Editor', - 'CreatedBy', - 'ModifiedBy', - 'AuthorId', - 'EditorId', - '_UIVersionString', - 'Attachments', - 'FileRef', - 'FileDirRef', - 'FileLeafRef', - ]) - - const entries = Object.entries(params.listItemFields) - const updatableEntries = entries.filter(([key]) => !readOnlyFields.has(key)) - - if (updatableEntries.length !== entries.length) { - const removed = entries.filter(([key]) => readOnlyFields.has(key)).map(([key]) => key) - logger.warn('Removed read-only SharePoint fields from update', { - removed, - }) - } - - if (updatableEntries.length === 0) { - const requestedKeys = Object.keys(params.listItemFields) - throw new Error( - `All provided fields are read-only and cannot be updated: ${requestedKeys.join(', ')}` - ) - } - - const sanitizedFields = Object.fromEntries(updatableEntries) - - logger.info('Updating SharePoint list item fields', { - listItemId: params.itemId, - listId: params.listId, - fieldsKeys: Object.keys(sanitizedFields), - }) - return sanitizedFields + return sanitizeListItemFields(params.listItemFields, { action: 'update' }) }, }, diff --git a/apps/sim/tools/sharepoint/update_page.ts b/apps/sim/tools/sharepoint/update_page.ts new file mode 100644 index 00000000000..52f5da6153b --- /dev/null +++ b/apps/sim/tools/sharepoint/update_page.ts @@ -0,0 +1,168 @@ +import { createLogger } from '@sim/logger' +import type { + CanvasLayout, + SharepointToolParams, + SharepointUpdatePageResponse, +} from '@/tools/sharepoint/types' +import { escapeHtml, optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SharePointUpdatePage') + +export const updatePageTool: ToolConfig = { + id: 'sharepoint_update_page', + name: 'Update SharePoint Page', + description: 'Update the title and/or content of a SharePoint page', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + pageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the page to update. Example: a GUID like 12345678-1234-1234-1234-123456789012', + }, + pageTitle: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The new title of the page', + }, + pageContent: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'The new text content of the page. Replaces the entire canvas layout of the page.', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const pageId = optionalTrim(params.pageId) + if (!pageId) throw new Error('pageId must be provided') + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage` + }, + method: 'PATCH', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const pageTitle = optionalTrim(params.pageTitle) + const pageContent = typeof params.pageContent === 'string' ? params.pageContent : undefined + + if (!pageTitle && !pageContent) { + throw new Error('At least one of pageTitle or pageContent must be provided') + } + + const pageData: { + '@odata.type': string + title?: string + canvasLayout?: CanvasLayout + } = { + '@odata.type': '#microsoft.graph.sitePage', + } + if (pageTitle) pageData.title = pageTitle + + if (pageContent) { + pageData.canvasLayout = { + horizontalSections: [ + { + layout: 'oneColumn', + id: '1', + emphasis: 'none', + columns: [ + { + id: '1', + width: 12, + webparts: [ + { + id: '6f9230af-2a98-4952-b205-9ede4f9ef548', + innerHtml: `

${escapeHtml(pageContent)}

`, + }, + ], + }, + ], + }, + ], + } + } + + logger.info('Updating SharePoint page', { + pageId: params.pageId, + hasTitle: !!pageTitle, + hasContent: !!pageContent, + }) + + return pageData + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('SharePoint page updated successfully', { + pageId: data.id, + pageName: data.name, + pageTitle: data.title, + }) + + return { + success: true, + output: { + page: { + id: data.id, + name: data.name, + title: data.title || data.name, + webUrl: data.webUrl, + pageLayout: data.pageLayout, + createdDateTime: data.createdDateTime, + lastModifiedDateTime: data.lastModifiedDateTime, + }, + }, + } + }, + + outputs: { + page: { + type: 'object', + description: 'Updated SharePoint page information', + properties: { + id: { type: 'string', description: 'The unique ID of the page' }, + name: { type: 'string', description: 'The name of the page' }, + title: { type: 'string', description: 'The title of the page' }, + webUrl: { type: 'string', description: 'The URL to access the page' }, + pageLayout: { type: 'string', description: 'The layout type of the page' }, + createdDateTime: { type: 'string', description: 'When the page was created' }, + lastModifiedDateTime: { type: 'string', description: 'When the page was last modified' }, + }, + }, + }, +} diff --git a/apps/sim/tools/sharepoint/utils.ts b/apps/sim/tools/sharepoint/utils.ts index 200e67156eb..9e7d60abacd 100644 --- a/apps/sim/tools/sharepoint/utils.ts +++ b/apps/sim/tools/sharepoint/utils.ts @@ -13,6 +13,15 @@ export function escapeODataString(value: string): string { return value.replace(/'/g, "''") } +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + export function getGraphNextPageUrl(data: object): string | undefined { const nextLink = (data as Record)['@odata.nextLink'] return typeof nextLink === 'string' ? nextLink : undefined @@ -102,6 +111,57 @@ export function extractTextFromCanvasLayout(canvasLayout: CanvasLayout | null | return finalContent } +/** SharePoint list item fields that are system-managed and cannot be set via the Graph API. */ +export const READ_ONLY_LIST_ITEM_FIELDS = new Set([ + 'Id', + 'id', + 'UniqueId', + 'GUID', + 'ContentTypeId', + 'Created', + 'Modified', + 'Author', + 'Editor', + 'CreatedBy', + 'ModifiedBy', + 'AuthorId', + 'EditorId', + '_UIVersionString', + 'Attachments', + 'FileRef', + 'FileDirRef', + 'FileLeafRef', +]) + +/** + * Removes read-only/system-managed fields from a SharePoint list item field set, logging any + * fields that were stripped. Throws if no updatable fields remain. + */ +export function sanitizeListItemFields( + fields: Record, + context: { action: 'update' | 'create' } +): Record { + const entries = Object.entries(fields) + const updatableEntries = entries.filter(([key]) => !READ_ONLY_LIST_ITEM_FIELDS.has(key)) + + if (updatableEntries.length !== entries.length) { + const removed = entries + .filter(([key]) => READ_ONLY_LIST_ITEM_FIELDS.has(key)) + .map(([key]) => key) + logger.warn(`Removed read-only SharePoint fields from ${context.action}`, { removed }) + } + + if (updatableEntries.length === 0) { + const requestedKeys = Object.keys(fields) + const verb = context.action === 'update' ? 'updated' : 'set' + throw new Error( + `All provided fields are read-only and cannot be ${verb}: ${requestedKeys.join(', ')}` + ) + } + + return Object.fromEntries(updatableEntries) +} + export function cleanODataMetadata(obj: T): T { if (!obj || typeof obj !== 'object') return obj diff --git a/apps/sim/tools/similarweb/index.ts b/apps/sim/tools/similarweb/index.ts index 8418ba21ca1..8f5e4acdeff 100644 --- a/apps/sim/tools/similarweb/index.ts +++ b/apps/sim/tools/similarweb/index.ts @@ -1,4 +1,5 @@ export { similarwebBounceRateTool } from './bounce_rate' +export { similarwebPageViewsTool } from './page_views' export { similarwebPagesPerVisitTool } from './pages_per_visit' export { similarwebTrafficVisitsTool } from './traffic_visits' export * from './types' diff --git a/apps/sim/tools/similarweb/page_views.ts b/apps/sim/tools/similarweb/page_views.ts new file mode 100644 index 00000000000..87bf4ad11a6 --- /dev/null +++ b/apps/sim/tools/similarweb/page_views.ts @@ -0,0 +1,148 @@ +import type { + SimilarwebPageViewsParams, + SimilarwebPageViewsResponse, +} from '@/tools/similarweb/types' +import type { ToolConfig } from '@/tools/types' + +export const similarwebPageViewsTool: ToolConfig< + SimilarwebPageViewsParams, + SimilarwebPageViewsResponse +> = { + id: 'similarweb_page_views', + name: 'SimilarWeb Page Views', + description: 'Get total page views over time (desktop and mobile combined)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SimilarWeb API key', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Website domain to analyze (e.g., "example.com" without www or protocol)', + }, + country: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + '2-letter ISO country code (e.g., "us", "gb", "de") or "world" for worldwide data', + }, + granularity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Data granularity: daily, weekly, or monthly', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Start date in YYYY-MM format (e.g., "2024-01")', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date in YYYY-MM format (e.g., "2024-12")', + }, + mainDomainOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Exclude subdomains from results', + }, + }, + + request: { + url: (params) => { + const domain = params.domain + ?.trim() + .replace(/^(https?:\/\/)?(www\.)?/, '') + .replace(/\/$/, '') + const url = new URL( + `https://api.similarweb.com/v1/website/${domain}/total-traffic-and-engagement/page-views` + ) + url.searchParams.set('api_key', params.apiKey?.trim()) + url.searchParams.set('country', params.country?.trim() ?? 'world') + url.searchParams.set('granularity', params.granularity ?? 'monthly') + url.searchParams.set('format', 'json') + if (params.startDate) url.searchParams.set('start_date', params.startDate) + if (params.endDate) url.searchParams.set('end_date', params.endDate) + if (params.mainDomainOnly !== undefined) + url.searchParams.set('main_domain_only', String(params.mainDomainOnly)) + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || data.message || 'Failed to get page views') + } + + const meta = data.meta ?? {} + const request = meta.request ?? {} + + return { + success: true, + output: { + domain: request.domain ?? null, + country: request.country ?? null, + granularity: request.granularity ?? null, + lastUpdated: meta.last_updated ?? null, + // SimilarWeb's own docs example response uses "pages_views" (with the extra "s") for + // this endpoint, unlike its sibling total-traffic-and-engagement endpoints; fall back + // to "page_views" too in case that spelling changes or varies by account. + pageViews: + (data.pages_views ?? data.page_views)?.map( + (p: { date: string; pages_views?: number; page_views?: number }) => ({ + date: p.date, + pageViews: p.pages_views ?? p.page_views ?? 0, + }) + ) ?? [], + }, + } + }, + + outputs: { + domain: { + type: 'string', + description: 'Analyzed domain', + }, + country: { + type: 'string', + description: 'Country filter applied', + }, + granularity: { + type: 'string', + description: 'Data granularity', + }, + lastUpdated: { + type: 'string', + description: 'Data last updated timestamp', + optional: true, + }, + pageViews: { + type: 'array', + description: 'Page view data over time', + items: { + type: 'object', + properties: { + date: { type: 'string', description: 'Date (YYYY-MM-DD)' }, + pageViews: { type: 'number', description: 'Total page views' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/similarweb/types.ts b/apps/sim/tools/similarweb/types.ts index d8d542ae866..2ae5efd7b6c 100644 --- a/apps/sim/tools/similarweb/types.ts +++ b/apps/sim/tools/similarweb/types.ts @@ -117,6 +117,27 @@ export interface SimilarwebPagesPerVisitResponse extends ToolResponse { } } +/** + * Page Views parameters + */ +export interface SimilarwebPageViewsParams extends SimilarwebTimeSeriesParams {} + +/** + * Page Views response + */ +export interface SimilarwebPageViewsResponse extends ToolResponse { + output: { + domain: string + country: string + granularity: string + lastUpdated: string | null + pageViews: Array<{ + date: string + pageViews: number + }> + } +} + /** * Average Visit Duration parameters */ diff --git a/apps/sim/tools/similarweb/website_overview.ts b/apps/sim/tools/similarweb/website_overview.ts index c48d2cb54a4..c0667082a58 100644 --- a/apps/sim/tools/similarweb/website_overview.ts +++ b/apps/sim/tools/similarweb/website_overview.ts @@ -116,7 +116,13 @@ export const similarwebWebsiteOverviewTool: ToolConfig< search: sources.Search ?? sources.search ?? null, social: sources.Social ?? sources.social ?? null, mail: sources.Mail ?? sources.mail ?? null, - paidReferrals: sources['Paid Referrals'] ?? sources.paid_referrals ?? null, + paidReferrals: + sources['Paid Referrals'] ?? + // SimilarWeb's API Lite response literally uses "paid _referrals" (space before + // the underscore) as the key for this field. + sources['paid _referrals'] ?? + sources.paid_referrals ?? + null, }, }, } diff --git a/apps/sim/tools/supabase/count.ts b/apps/sim/tools/supabase/count.ts index 7e5d2c0f3ad..144b41f00bd 100644 --- a/apps/sim/tools/supabase/count.ts +++ b/apps/sim/tools/supabase/count.ts @@ -7,7 +7,7 @@ export const countTool: ToolConfig = id: 'supabase_count', name: 'Supabase Count', description: 'Count rows in a Supabase table', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/delete.ts b/apps/sim/tools/supabase/delete.ts index 967229868e1..d7460d6a6bd 100644 --- a/apps/sim/tools/supabase/delete.ts +++ b/apps/sim/tools/supabase/delete.ts @@ -7,7 +7,7 @@ export const deleteTool: ToolConfig 63) { - throw new Error(`Invalid value: ${value}`) - } - return value.replace(/'/g, "''") -} - /** - * SQL query filtered by specific schema - */ -const getSchemaFilteredSQL = (schema: string) => { - const safeSchema = escapeSqlString(schema) - return ` -WITH table_info AS ( - SELECT - t.table_schema, - t.table_name - FROM information_schema.tables t - WHERE t.table_type = 'BASE TABLE' - AND t.table_schema = '${safeSchema}' -), -columns_info AS ( - SELECT - c.table_schema, - c.table_name, - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.ordinal_position - FROM information_schema.columns c - INNER JOIN table_info t ON c.table_schema = t.table_schema AND c.table_name = t.table_name -), -pk_info AS ( - SELECT - tc.table_schema, - tc.table_name, - kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '${safeSchema}' -), -fk_info AS ( - SELECT - tc.table_schema, - tc.table_name, - kcu.column_name, - ccu.table_name AS foreign_table_name, - ccu.column_name AS foreign_column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = '${safeSchema}' -), -index_info AS ( - SELECT - schemaname AS table_schema, - tablename AS table_name, - indexname AS index_name, - CASE WHEN indexdef LIKE '%UNIQUE%' THEN true ELSE false END AS is_unique, - indexdef - FROM pg_indexes - WHERE schemaname = '${safeSchema}' -) -SELECT json_build_object( - 'tables', ( - SELECT json_agg( - json_build_object( - 'schema', t.table_schema, - 'name', t.table_name, - 'columns', ( - SELECT json_agg( - json_build_object( - 'name', c.column_name, - 'type', c.data_type, - 'nullable', c.is_nullable = 'YES', - 'default', c.column_default, - 'isPrimaryKey', EXISTS ( - SELECT 1 FROM pk_info pk - WHERE pk.table_schema = c.table_schema - AND pk.table_name = c.table_name - AND pk.column_name = c.column_name - ), - 'isForeignKey', EXISTS ( - SELECT 1 FROM fk_info fk - WHERE fk.table_schema = c.table_schema - AND fk.table_name = c.table_name - AND fk.column_name = c.column_name - ), - 'references', ( - SELECT json_build_object('table', fk.foreign_table_name, 'column', fk.foreign_column_name) - FROM fk_info fk - WHERE fk.table_schema = c.table_schema - AND fk.table_name = c.table_name - AND fk.column_name = c.column_name - LIMIT 1 - ) - ) - ORDER BY c.ordinal_position - ) - FROM columns_info c - WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name - ), - 'primaryKey', ( - SELECT COALESCE(json_agg(pk.column_name), '[]'::json) - FROM pk_info pk - WHERE pk.table_schema = t.table_schema AND pk.table_name = t.table_name - ), - 'foreignKeys', ( - SELECT COALESCE(json_agg( - json_build_object( - 'column', fk.column_name, - 'referencesTable', fk.foreign_table_name, - 'referencesColumn', fk.foreign_column_name - ) - ), '[]'::json) - FROM fk_info fk - WHERE fk.table_schema = t.table_schema AND fk.table_name = t.table_name - ), - 'indexes', ( - SELECT COALESCE(json_agg( - json_build_object( - 'name', idx.index_name, - 'unique', idx.is_unique, - 'definition', idx.indexdef - ) - ), '[]'::json) - FROM index_info idx - WHERE idx.table_schema = t.table_schema AND idx.table_name = t.table_name - ) - ) - ) - FROM table_info t - ), - 'schemas', ( - SELECT COALESCE(json_agg(DISTINCT table_schema), '[]'::json) - FROM table_info - ) -) AS result; -` -} - -/** - * Tool for introspecting Supabase database schema - * Uses raw SQL execution via PostgREST to retrieve table structures + * Tool for introspecting Supabase database schema. + * + * PostgREST (which powers `/rest/v1`) has no generic "run arbitrary SQL" + * endpoint, so schema introspection is derived from the project's + * auto-generated OpenAPI spec (`GET /rest/v1/` with an OpenAPI `Accept` + * header) rather than a live `information_schema` query. Primary-key + * detection is a best-effort naming heuristic (`id` column), and + * foreign-key detection only succeeds if the table owner has added a + * matching `references table.column` SQL comment — the OpenAPI spec does + * not expose constraint metadata directly. Index information is not + * available via this API at all. `nullable` is also best-effort: PostgREST + * only lists a column under `required` when it's NOT NULL *and* has no + * default, so a NOT NULL column with a default is reported as nullable. */ export const introspectTool: ToolConfig = { id: 'supabase_introspect', name: 'Supabase Introspect', description: - 'Introspect Supabase database schema to get table structures, columns, and relationships', - version: '1.0', + 'Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)', + version: '1.0.0', params: { projectId: { @@ -336,127 +53,31 @@ export const introspectTool: ToolConfig { - return `${supabaseBaseUrl(params.projectId)}/rest/v1/rpc/` - }, - method: 'POST', + url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/`, + method: 'GET', headers: (params) => ({ apikey: params.apiKey, Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', + Accept: 'application/openapi+json', + ...(params.schema ? { 'Accept-Profile': params.schema } : {}), }), - body: () => ({}), }, - directExecution: async ( - params: SupabaseIntrospectParams - ): Promise => { - const { apiKey, projectId, schema } = params - - try { - const sqlQuery = schema ? getSchemaFilteredSQL(schema) : INTROSPECTION_SQL - const baseUrl = supabaseBaseUrl(projectId) - - const response = await fetch(`${baseUrl}/rest/v1/rpc/`, { - method: 'POST', - headers: { - apikey: apiKey, - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - Prefer: 'return=representation', - }, - body: JSON.stringify({ - query: sqlQuery, - }), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.warn('Direct RPC call failed, attempting alternative approach', { - status: response.status, - }) - - const pgResponse = await fetch(`${baseUrl}/rest/v1/?select=*`, { - method: 'GET', - headers: { - apikey: apiKey, - Authorization: `Bearer ${apiKey}`, - Accept: 'application/openapi+json', - }, - }) - - if (!pgResponse.ok) { - throw new Error(`Failed to introspect database: ${errorText}`) - } - - const openApiSpec = await pgResponse.json() - const tables = parseOpenApiSpec(openApiSpec, schema) - - return { - success: true, - output: { - message: `Successfully introspected ${tables.length} table(s) from database schema`, - tables, - schemas: [...new Set(tables.map((t) => t.schema))], - }, - } - } - - const data = await response.json() - const result = Array.isArray(data) && data.length > 0 ? data[0].result : data.result || data - - const tables: SupabaseTableSchema[] = (result.tables || []).map((table: any) => ({ - name: table.name, - schema: table.schema, - columns: (table.columns || []).map((col: any) => ({ - name: col.name, - type: col.type, - nullable: col.nullable, - default: col.default, - isPrimaryKey: col.isPrimaryKey, - isForeignKey: col.isForeignKey, - references: col.references, - })), - primaryKey: table.primaryKey || [], - foreignKeys: table.foreignKeys || [], - indexes: (table.indexes || []).map((idx: any) => ({ - name: idx.name, - columns: parseIndexColumns(idx.definition || ''), - unique: idx.unique, - })), - })) - - return { - success: true, - output: { - message: `Successfully introspected ${tables.length} table(s) from database`, - tables, - schemas: result.schemas || [], - }, - } - } catch (error) { - logger.error('Supabase introspection failed', { error }) - const errorMessage = getErrorMessage(error, 'Unknown error occurred') - return { - success: false, - output: { - message: 'Failed to introspect database schema', - tables: [], - schemas: [], - }, - error: errorMessage, - } + transformResponse: async (response: Response, params?: SupabaseIntrospectParams) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to introspect database: ${errorText}`) } - }, - transformResponse: async (response: Response) => { - const data = await response.json() + const openApiSpec = await response.json() + const tables = parseOpenApiSpec(openApiSpec, params?.schema) + return { success: true, output: { - message: 'Schema introspection completed', - tables: data.tables || [], - schemas: data.schemas || [], + message: `Successfully introspected ${tables.length} table(s) from database schema`, + tables, + schemas: [...new Set(tables.map((t) => t.schema))], }, } }, @@ -476,19 +97,13 @@ export const introspectTool: ToolConfig col.trim().replace(/"/g, '')) - } - return [] -} - -/** - * Parse OpenAPI spec to extract table schema information - * This is a fallback method when direct SQL execution is not available + * Parse a PostgREST-generated OpenAPI spec into table schemas. + * + * `isPrimaryKey` is a naming heuristic (`id` column) — PostgREST does not + * expose real constraint metadata in the spec. `isForeignKey`/`references` + * only populate when the table owner has added a `references table.column` + * SQL comment on the column. `indexes` is always empty: index definitions + * are not part of the OpenAPI spec. */ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema[] { const tables: SupabaseTableSchema[] = [] @@ -511,7 +126,7 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema for (const [colName, colDef] of Object.entries(properties)) { const col = colDef as any - const isPK = col.description?.includes('primary key') || colName === 'id' + const isPK = colName === 'id' const fkMatch = col.description?.match(/references\s+(\w+)\.(\w+)/) const column: SupabaseColumnSchema = { @@ -539,18 +154,18 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema columns.push(column) } - const schemaName = filterSchema || 'public' - - if (!filterSchema || schemaName === filterSchema) { - tables.push({ - name: tableName, - schema: schemaName, - columns, - primaryKey, - foreignKeys, - indexes: [], - }) - } + tables.push({ + name: tableName, + // The OpenAPI spec doesn't map tables to schemas, so this can only + // reflect the schema that was actually requested (or "public" when + // introspecting the default schema) — not necessarily the table's + // true schema in a multi-schema database. + schema: filterSchema || 'public', + columns, + primaryKey, + foreignKeys, + indexes: [], + }) } return tables diff --git a/apps/sim/tools/supabase/invoke_function.ts b/apps/sim/tools/supabase/invoke_function.ts index 4b02f65bd85..016a508f741 100644 --- a/apps/sim/tools/supabase/invoke_function.ts +++ b/apps/sim/tools/supabase/invoke_function.ts @@ -34,7 +34,7 @@ export const invokeFunctionTool: ToolConfig< id: 'supabase_invoke_function', name: 'Supabase Invoke Edge Function', description: 'Invoke a Supabase Edge Function over HTTP', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/query.ts b/apps/sim/tools/supabase/query.ts index 0fcf1b75dda..f80bc866b31 100644 --- a/apps/sim/tools/supabase/query.ts +++ b/apps/sim/tools/supabase/query.ts @@ -7,7 +7,7 @@ export const queryTool: ToolConfig = id: 'supabase_query', name: 'Supabase Query', description: 'Query data from a Supabase table', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/rpc.ts b/apps/sim/tools/supabase/rpc.ts index c9295c0854a..19c394646a9 100644 --- a/apps/sim/tools/supabase/rpc.ts +++ b/apps/sim/tools/supabase/rpc.ts @@ -7,7 +7,7 @@ export const rpcTool: ToolConfig = { id: 'supabase_rpc', name: 'Supabase RPC', description: 'Call a PostgreSQL function in Supabase', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/storage_copy.ts b/apps/sim/tools/supabase/storage_copy.ts index 027a03b823f..ac396f0f687 100644 --- a/apps/sim/tools/supabase/storage_copy.ts +++ b/apps/sim/tools/supabase/storage_copy.ts @@ -10,7 +10,7 @@ export const storageCopyTool: ToolConfig = { + id: 'supabase_storage_create_signed_upload_url', + name: 'Supabase Storage Create Signed Upload URL', + description: + 'Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket', + version: '1.0.0', + + params: { + projectId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)', + }, + bucket: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the storage bucket', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The destination path for the uploaded file (e.g., "folder/file.jpg")', + }, + upsert: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'If true, allows overwriting an existing file at this path (default: false)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase service role secret key', + }, + }, + + request: { + url: (params) => { + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/upload/sign/${bucket}/${path}` + }, + method: 'POST', + headers: (params) => ({ + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + ...(params.upsert ? { 'x-upsert': 'true' } : {}), + }), + body: () => ({}), + }, + + transformResponse: async ( + response: Response, + params?: SupabaseStorageCreateSignedUploadUrlParams + ) => { + let data + try { + data = await response.json() + } catch (parseError) { + throw new Error( + `Failed to parse Supabase storage create signed upload URL response: ${parseError}` + ) + } + + if (!response.ok) { + throw new Error( + `Failed to create signed upload URL: ${data.message || data.error || response.statusText}` + ) + } + + const relativeUrl = data.url + if (!relativeUrl) { + throw new Error('Supabase did not return a signed upload URL path in its response') + } + if (!params?.projectId) { + throw new Error('projectId is required to construct the signed upload URL') + } + + return { + success: true, + output: { + message: 'Successfully created signed upload URL', + signedUrl: `${supabaseBaseUrl(params.projectId)}/storage/v1${relativeUrl}`, + // The API response has no `path` field — it's the caller-supplied + // destination path, echoed back the same way the official + // storage-js client does. + path: params.path, + token: data.token, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + signedUrl: { + type: 'string', + description: 'The temporary signed URL a client can PUT the file to', + }, + path: { type: 'string', description: 'The destination object path' }, + token: { type: 'string', description: 'The upload token embedded in the signed URL' }, + }, +} diff --git a/apps/sim/tools/supabase/storage_create_signed_url.ts b/apps/sim/tools/supabase/storage_create_signed_url.ts index 93153af43db..eb10d497667 100644 --- a/apps/sim/tools/supabase/storage_create_signed_url.ts +++ b/apps/sim/tools/supabase/storage_create_signed_url.ts @@ -2,7 +2,7 @@ import type { SupabaseStorageCreateSignedUrlParams, SupabaseStorageCreateSignedUrlResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageCreateSignedUrlTool: ToolConfig< @@ -12,7 +12,7 @@ export const storageCreateSignedUrlTool: ToolConfig< id: 'supabase_storage_create_signed_url', name: 'Supabase Storage Create Signed URL', description: 'Create a temporary signed URL for a file in a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -55,7 +55,9 @@ export const storageCreateSignedUrlTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${params.bucket}/${params.path}` + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${bucket}/${path}` }, method: 'POST', headers: (params) => ({ @@ -63,17 +65,9 @@ export const storageCreateSignedUrlTool: ToolConfig< Authorization: `Bearer ${params.apiKey}`, 'Content-Type': 'application/json', }), - body: (params) => { - const payload: any = { - expiresIn: Number(params.expiresIn), - } - - if (params.download !== undefined) { - payload.download = params.download - } - - return payload - }, + body: (params) => ({ + expiresIn: Number(params.expiresIn), + }), }, transformResponse: async (response: Response, params?: SupabaseStorageCreateSignedUrlParams) => { @@ -84,11 +78,28 @@ export const storageCreateSignedUrlTool: ToolConfig< throw new Error(`Failed to parse Supabase storage create signed URL response: ${parseError}`) } + if (!response.ok) { + throw new Error( + `Failed to create signed URL: ${data.message || data.error || response.statusText}` + ) + } + const relativePath = data.signedURL || data.signedUrl + if (!relativePath) { + throw new Error('Supabase did not return a signed URL path in its response') + } if (!params?.projectId) { throw new Error('projectId is required to construct the signed URL') } - const fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}` + let fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}` + + // The Storage API ignores a `download` field in the sign request body — + // forcing download is a client-side query param on the resulting URL. + // An empty value preserves the original filename; a non-empty value + // would override it, so a boolean "true" must never be sent literally. + if (params.download) { + fullUrl += fullUrl.includes('?') ? '&download=' : '?download=' + } return { success: true, diff --git a/apps/sim/tools/supabase/storage_delete.ts b/apps/sim/tools/supabase/storage_delete.ts index e0228bb1810..9cf876a41c9 100644 --- a/apps/sim/tools/supabase/storage_delete.ts +++ b/apps/sim/tools/supabase/storage_delete.ts @@ -3,7 +3,7 @@ import { type SupabaseStorageDeleteParams, type SupabaseStorageDeleteResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageDeleteTool: ToolConfig< @@ -13,7 +13,7 @@ export const storageDeleteTool: ToolConfig< id: 'supabase_storage_delete', name: 'Supabase Storage Delete', description: 'Delete files from a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -44,7 +44,7 @@ export const storageDeleteTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}` + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${encodeStorageSegment(params.bucket)}` }, method: 'DELETE', headers: (params) => ({ diff --git a/apps/sim/tools/supabase/storage_delete_bucket.ts b/apps/sim/tools/supabase/storage_delete_bucket.ts index 621ba2341ec..25b0dae0829 100644 --- a/apps/sim/tools/supabase/storage_delete_bucket.ts +++ b/apps/sim/tools/supabase/storage_delete_bucket.ts @@ -1,9 +1,9 @@ import { - STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES, + STORAGE_MESSAGE_OUTPUT_PROPERTIES, type SupabaseStorageDeleteBucketParams, type SupabaseStorageDeleteBucketResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageDeleteBucketTool: ToolConfig< @@ -13,7 +13,7 @@ export const storageDeleteBucketTool: ToolConfig< id: 'supabase_storage_delete_bucket', name: 'Supabase Storage Delete Bucket', description: 'Delete a storage bucket in Supabase', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -38,7 +38,7 @@ export const storageDeleteBucketTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${params.bucket}` + return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}` }, method: 'DELETE', headers: (params) => ({ @@ -70,7 +70,7 @@ export const storageDeleteBucketTool: ToolConfig< results: { type: 'object', description: 'Delete operation result', - properties: STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES, + properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES, }, }, } diff --git a/apps/sim/tools/supabase/storage_download.ts b/apps/sim/tools/supabase/storage_download.ts index d47977f61d8..4079f2e4330 100644 --- a/apps/sim/tools/supabase/storage_download.ts +++ b/apps/sim/tools/supabase/storage_download.ts @@ -4,7 +4,7 @@ import { type SupabaseStorageDownloadParams, type SupabaseStorageDownloadResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SupabaseStorageDownloadTool') @@ -16,7 +16,7 @@ export const storageDownloadTool: ToolConfig< id: 'supabase_storage_download', name: 'Supabase Storage Download', description: 'Download a file from a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -53,7 +53,9 @@ export const storageDownloadTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}/${params.path}` + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${bucket}/${path}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/supabase/storage_empty_bucket.ts b/apps/sim/tools/supabase/storage_empty_bucket.ts new file mode 100644 index 00000000000..ba86d845bea --- /dev/null +++ b/apps/sim/tools/supabase/storage_empty_bucket.ts @@ -0,0 +1,86 @@ +import { + STORAGE_MESSAGE_OUTPUT_PROPERTIES, + type SupabaseStorageEmptyBucketParams, + type SupabaseStorageEmptyBucketResponse, +} from '@/tools/supabase/types' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' +import type { ToolConfig } from '@/tools/types' + +export const storageEmptyBucketTool: ToolConfig< + SupabaseStorageEmptyBucketParams, + SupabaseStorageEmptyBucketResponse +> = { + id: 'supabase_storage_empty_bucket', + name: 'Supabase Storage Empty Bucket', + description: + 'Delete all objects inside a Supabase storage bucket without deleting the bucket itself', + version: '1.0.0', + + params: { + projectId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)', + }, + bucket: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the bucket to empty', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase service role secret key', + }, + }, + + request: { + url: (params) => { + const bucket = encodeStorageSegment(params.bucket) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${bucket}/empty` + }, + method: 'POST', + headers: (params) => ({ + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: () => ({}), + }, + + transformResponse: async (response: Response) => { + let data + try { + data = await response.json() + } catch (parseError) { + throw new Error(`Failed to parse Supabase storage empty bucket response: ${parseError}`) + } + + if (!response.ok) { + throw new Error( + `Failed to empty storage bucket: ${data.message || data.error || response.statusText}` + ) + } + + return { + success: true, + output: { + message: data.message || 'Successfully emptied storage bucket', + results: data, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + results: { + type: 'object', + description: 'Empty bucket operation result', + properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/supabase/storage_get_public_url.ts b/apps/sim/tools/supabase/storage_get_public_url.ts index 64bee51541a..b189f9bd543 100644 --- a/apps/sim/tools/supabase/storage_get_public_url.ts +++ b/apps/sim/tools/supabase/storage_get_public_url.ts @@ -2,7 +2,7 @@ import type { SupabaseStorageGetPublicUrlParams, SupabaseStorageGetPublicUrlResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageGetPublicUrlTool: ToolConfig< @@ -12,7 +12,7 @@ export const storageGetPublicUrlTool: ToolConfig< id: 'supabase_storage_get_public_url', name: 'Supabase Storage Get Public URL', description: 'Get the public URL for a file in a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -48,10 +48,16 @@ export const storageGetPublicUrlTool: ToolConfig< * its response. */ directExecution: async (params: SupabaseStorageGetPublicUrlParams) => { - let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}` + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` if (params.download) { - publicUrl += '?download=true' + // Supabase's `download` query param is a filename override, not a + // boolean flag — an empty value forces a download while preserving + // the original filename. Sending the literal string "true" would + // instead rename the downloaded file to "true". + publicUrl += '?download=' } return { @@ -65,8 +71,11 @@ export const storageGetPublicUrlTool: ToolConfig< }, request: { - url: (params) => - `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}`, + url: (params) => { + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` + }, method: 'GET', headers: () => ({}), }, diff --git a/apps/sim/tools/supabase/storage_list.ts b/apps/sim/tools/supabase/storage_list.ts index fd13ca475cb..2d20468f3d4 100644 --- a/apps/sim/tools/supabase/storage_list.ts +++ b/apps/sim/tools/supabase/storage_list.ts @@ -3,14 +3,14 @@ import { type SupabaseStorageListParams, type SupabaseStorageListResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageListTool: ToolConfig = { id: 'supabase_storage_list', name: 'Supabase Storage List', description: 'List files in a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -72,7 +72,7 @@ export const storageListTool: ToolConfig { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${params.bucket}` + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${encodeStorageSegment(params.bucket)}` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/supabase/storage_list_buckets.ts b/apps/sim/tools/supabase/storage_list_buckets.ts index a7ede783ca4..e03ddff3016 100644 --- a/apps/sim/tools/supabase/storage_list_buckets.ts +++ b/apps/sim/tools/supabase/storage_list_buckets.ts @@ -13,7 +13,7 @@ export const storageListBucketsTool: ToolConfig< id: 'supabase_storage_list_buckets', name: 'Supabase Storage List Buckets', description: 'List all storage buckets in Supabase', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/storage_move.ts b/apps/sim/tools/supabase/storage_move.ts index 5f3c2edf30a..23e149e21ee 100644 --- a/apps/sim/tools/supabase/storage_move.ts +++ b/apps/sim/tools/supabase/storage_move.ts @@ -10,7 +10,7 @@ export const storageMoveTool: ToolConfig = { + id: 'supabase_storage_update_bucket', + name: 'Supabase Storage Update Bucket', + description: 'Update the configuration of an existing Supabase storage bucket', + version: '1.0.0', + + params: { + projectId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)', + }, + bucket: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the bucket to update', + }, + isPublic: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether the bucket should be publicly accessible (leave unset to keep the current value)', + }, + fileSizeLimit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum file size in bytes (leave unset to keep the current value)', + }, + allowedMimeTypes: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Array of allowed MIME types (e.g., ["image/png", "image/jpeg"]) — leave unset to keep the current value', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase service role secret key', + }, + }, + + /** + * Unreachable: `directExecution` below always handles this tool because + * the update must first read the bucket's current configuration (the + * Storage API's update-bucket endpoint is a full-replace PUT, not a + * partial patch). Declared only to satisfy `ToolConfig`'s required + * `request` field. + */ + request: { + url: (params) => + `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`, + method: 'PUT', + headers: (params) => ({ + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + }, + + /** + * The Storage API's update-bucket endpoint is a full-replace PUT + * (`{id, name, public, file_size_limit?, allowed_mime_types?}`), not a + * partial patch. Fetching the bucket's current configuration first lets + * unset params fall back to their existing value instead of silently + * resetting to a default (e.g. flipping a public bucket private just + * because `isPublic` wasn't provided). + */ + directExecution: async ( + params: SupabaseStorageUpdateBucketParams + ): Promise => { + const baseUrl = supabaseBaseUrl(params.projectId) + const bucket = encodeStorageSegment(params.bucket) + const headers = { + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + } + + try { + const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { + method: 'GET', + headers, + }) + + if (!currentResponse.ok) { + const errorText = await currentResponse.text() + throw new Error(`Failed to read current bucket configuration: ${errorText}`) + } + + const current = await currentResponse.json() + + // Block subBlocks for a shared field can forward an empty string + // (e.g. an untouched short-input) rather than omitting the key + // entirely — treat that the same as "not provided" so it falls + // back to the bucket's current value instead of coercing to 0/false. + const hasValue = (value: unknown): boolean => + value !== undefined && value !== null && value !== '' + + const payload: any = { + id: params.bucket, + name: params.bucket, + public: hasValue(params.isPublic) ? params.isPublic : Boolean(current.public), + file_size_limit: hasValue(params.fileSizeLimit) + ? Number(params.fileSizeLimit) + : (current.file_size_limit ?? null), + allowed_mime_types: hasValue(params.allowedMimeTypes) + ? params.allowedMimeTypes + : (current.allowed_mime_types ?? null), + } + + const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { + method: 'PUT', + headers, + body: JSON.stringify(payload), + }) + + if (!updateResponse.ok) { + const errorText = await updateResponse.text() + throw new Error(`Failed to update bucket: ${errorText}`) + } + + const data = await updateResponse.json() + + return { + success: true, + output: { + message: 'Successfully updated storage bucket', + results: data, + }, + error: undefined, + } + } catch (error) { + return { + success: false, + output: { + message: 'Failed to update storage bucket', + results: {}, + }, + error: getErrorMessage(error, 'Unknown error occurred'), + } + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + results: { + type: 'object', + description: 'Update operation result', + properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/supabase/storage_upload.ts b/apps/sim/tools/supabase/storage_upload.ts index 8f99c182797..b4298fd6bc8 100644 --- a/apps/sim/tools/supabase/storage_upload.ts +++ b/apps/sim/tools/supabase/storage_upload.ts @@ -12,7 +12,7 @@ export const storageUploadTool: ToolConfig< id: 'supabase_storage_upload', name: 'Supabase Storage Upload', description: 'Upload a file to a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/text_search.ts b/apps/sim/tools/supabase/text_search.ts index 5aaef93bdc1..271518afaba 100644 --- a/apps/sim/tools/supabase/text_search.ts +++ b/apps/sim/tools/supabase/text_search.ts @@ -7,7 +7,7 @@ export const textSearchTool: ToolConfig /** - * Output definition for storage delete bucket response - * Returns a confirmation message + * Output definition for storage bucket operations that only return a + * confirmation message (delete bucket, update bucket, empty bucket) + * @see https://github.com/supabase/storage-js/blob/main/src/packages/StorageBucketApi.ts */ -export const STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES = { +export const STORAGE_MESSAGE_OUTPUT_PROPERTIES = { message: { type: 'string', description: 'Operation status message' }, } as const satisfies Record @@ -231,13 +232,24 @@ export const INTROSPECT_REFERENCE_OUTPUT_PROPERTIES = { export const INTROSPECT_COLUMN_OUTPUT_PROPERTIES = { name: { type: 'string', description: 'Column name' }, type: { type: 'string', description: 'Column data type' }, - nullable: { type: 'boolean', description: 'Whether the column allows null values' }, + nullable: { + type: 'boolean', + description: + 'Whether the column allows null values — a NOT NULL column that has a default value is misreported as nullable, since the OpenAPI spec this is derived from omits it from the required list in that case', + }, default: { type: 'string', description: 'Default value for the column', optional: true }, - isPrimaryKey: { type: 'boolean', description: 'Whether the column is a primary key' }, - isForeignKey: { type: 'boolean', description: 'Whether the column is a foreign key' }, + isPrimaryKey: { + type: 'boolean', + description: 'Best-effort guess based on the column being named "id" (not authoritative)', + }, + isForeignKey: { + type: 'boolean', + description: + 'True only if the column has a "references table.column" SQL comment; most databases will show false even for real foreign keys', + }, references: { type: 'object', - description: 'Foreign key reference details', + description: 'Foreign key reference details, when detected via SQL comment', optional: true, properties: INTROSPECT_REFERENCE_OUTPUT_PROPERTIES, }, @@ -294,7 +306,8 @@ export const INTROSPECT_TABLE_OUTPUT_PROPERTIES = { }, indexes: { type: 'array', - description: 'Array of index definitions', + description: + 'Always empty — index definitions are not exposed by the OpenAPI spec this tool reads', items: { type: 'object', properties: INTROSPECT_INDEX_OUTPUT_PROPERTIES, @@ -589,6 +602,43 @@ export interface SupabaseStorageCreateSignedUrlResponse extends ToolResponse { error?: string } +export interface SupabaseStorageCreateSignedUploadUrlParams { + apiKey: string + projectId: string + bucket: string + path: string + upsert?: boolean +} + +export interface SupabaseStorageCreateSignedUploadUrlResponse extends ToolResponse { + output: { + message: string + signedUrl: string + path: string + token: string + } + error?: string +} + +export interface SupabaseStorageUpdateBucketParams { + apiKey: string + projectId: string + bucket: string + isPublic?: boolean + fileSizeLimit?: number + allowedMimeTypes?: string[] +} + +export interface SupabaseStorageUpdateBucketResponse extends SupabaseBaseResponse {} + +export interface SupabaseStorageEmptyBucketParams { + apiKey: string + projectId: string + bucket: string +} + +export interface SupabaseStorageEmptyBucketResponse extends SupabaseBaseResponse {} + /** * Parameters for introspecting a Supabase database schema */ diff --git a/apps/sim/tools/supabase/update.ts b/apps/sim/tools/supabase/update.ts index 28c26e53d89..b8e429cefac 100644 --- a/apps/sim/tools/supabase/update.ts +++ b/apps/sim/tools/supabase/update.ts @@ -7,7 +7,7 @@ export const updateTool: ToolConfig encodeURIComponent(segment.trim())) + .join('/') +} diff --git a/apps/sim/tools/supabase/vector_search.ts b/apps/sim/tools/supabase/vector_search.ts index e8c8cc5d166..6ddfbe0fd6a 100644 --- a/apps/sim/tools/supabase/vector_search.ts +++ b/apps/sim/tools/supabase/vector_search.ts @@ -13,7 +13,7 @@ export const vectorSearchTool: ToolConfig< id: 'supabase_vector_search', name: 'Supabase Vector Search', description: 'Perform similarity search using pgvector in a Supabase table', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/tailscale/delete_user.ts b/apps/sim/tools/tailscale/delete_user.ts new file mode 100644 index 00000000000..374c8a7c291 --- /dev/null +++ b/apps/sim/tools/tailscale/delete_user.ts @@ -0,0 +1,77 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { TailscaleBaseParams } from './types' + +interface TailscaleDeleteUserParams extends TailscaleBaseParams { + userId: string +} + +interface TailscaleDeleteUserResponse extends ToolResponse { + output: { + success: boolean + userId: string + } +} + +export const tailscaleDeleteUserTool: ToolConfig< + TailscaleDeleteUserParams, + TailscaleDeleteUserResponse +> = { + id: 'tailscale_delete_user', + name: 'Tailscale Delete User', + description: 'Delete a user from the tailnet', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'User ID to delete', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/users/${encodeURIComponent(params.userId.trim())}/delete`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey.trim()}`, + }), + }, + + transformResponse: async (response: Response, params?: TailscaleDeleteUserParams) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { success: false, userId: '' }, + error: (data as Record).message ?? 'Failed to delete user', + } + } + + return { + success: true, + output: { + success: true, + userId: params?.userId ?? '', + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the user was successfully deleted' }, + userId: { type: 'string', description: 'ID of the deleted user' }, + }, +} diff --git a/apps/sim/tools/tailscale/expire_device_key.ts b/apps/sim/tools/tailscale/expire_device_key.ts new file mode 100644 index 00000000000..70801d7871c --- /dev/null +++ b/apps/sim/tools/tailscale/expire_device_key.ts @@ -0,0 +1,74 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { TailscaleDeviceParams } from './types' + +interface TailscaleExpireDeviceKeyResponse extends ToolResponse { + output: { + success: boolean + deviceId: string + } +} + +export const tailscaleExpireDeviceKeyTool: ToolConfig< + TailscaleDeviceParams, + TailscaleExpireDeviceKeyResponse +> = { + id: 'tailscale_expire_device_key', + name: 'Tailscale Expire Device Key', + description: + "Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet", + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + deviceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Device ID to expire the key for', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/device/${encodeURIComponent(params.deviceId.trim())}/expire`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey.trim()}`, + }), + }, + + transformResponse: async (response: Response, params?: TailscaleDeviceParams) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { success: false, deviceId: '' }, + error: (data as Record).message ?? 'Failed to expire device key', + } + } + + return { + success: true, + output: { + success: true, + deviceId: params?.deviceId ?? '', + }, + } + }, + + outputs: { + success: { type: 'boolean', description: "Whether the device's key was successfully expired" }, + deviceId: { type: 'string', description: 'Device ID' }, + }, +} diff --git a/apps/sim/tools/tailscale/get_device.ts b/apps/sim/tools/tailscale/get_device.ts index fe3ba670a7c..eaa2f7d0b90 100644 --- a/apps/sim/tools/tailscale/get_device.ts +++ b/apps/sim/tools/tailscale/get_device.ts @@ -45,6 +45,7 @@ export const tailscaleGetDeviceTool: ToolConfig - `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/keys`, + `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/keys?all=true`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey.trim()}`, diff --git a/apps/sim/tools/tailscale/list_devices.ts b/apps/sim/tools/tailscale/list_devices.ts index b55835d4828..fec321170bf 100644 --- a/apps/sim/tools/tailscale/list_devices.ts +++ b/apps/sim/tools/tailscale/list_devices.ts @@ -47,6 +47,7 @@ export const tailscaleListDevicesTool: ToolConfig< const data = await response.json() const devices = (data.devices ?? []).map((device: Record) => ({ id: (device.id as string) ?? null, + nodeId: (device.nodeId as string) ?? null, name: (device.name as string) ?? null, hostname: (device.hostname as string) ?? null, user: (device.user as string) ?? null, @@ -56,6 +57,8 @@ export const tailscaleListDevicesTool: ToolConfig< tags: (device.tags as string[]) ?? [], authorized: (device.authorized as boolean) ?? false, blocksIncomingConnections: (device.blocksIncomingConnections as boolean) ?? false, + keyExpiryDisabled: (device.keyExpiryDisabled as boolean) ?? false, + expires: (device.expires as string) ?? null, lastSeen: (device.lastSeen as string) ?? null, created: (device.created as string) ?? null, })) @@ -76,7 +79,8 @@ export const tailscaleListDevicesTool: ToolConfig< items: { type: 'object', properties: { - id: { type: 'string', description: 'Device ID' }, + id: { type: 'string', description: 'Legacy device ID' }, + nodeId: { type: 'string', description: 'Preferred device ID' }, name: { type: 'string', description: 'Device name' }, hostname: { type: 'string', description: 'Device hostname' }, user: { type: 'string', description: 'Associated user' }, @@ -89,6 +93,11 @@ export const tailscaleListDevicesTool: ToolConfig< type: 'boolean', description: 'Whether the device blocks incoming connections', }, + keyExpiryDisabled: { + type: 'boolean', + description: 'Whether the device key is exempt from expiring', + }, + expires: { type: 'string', description: "The device's auth key expiration timestamp" }, lastSeen: { type: 'string', description: 'Last seen timestamp' }, created: { type: 'string', description: 'Creation timestamp' }, }, diff --git a/apps/sim/tools/tailscale/list_dns_nameservers.ts b/apps/sim/tools/tailscale/list_dns_nameservers.ts index 67b0ac6745c..fa3bb18c378 100644 --- a/apps/sim/tools/tailscale/list_dns_nameservers.ts +++ b/apps/sim/tools/tailscale/list_dns_nameservers.ts @@ -39,7 +39,7 @@ export const tailscaleListDnsNameserversTool: ToolConfig< const data = await response.json().catch(() => ({})) return { success: false, - output: { dns: [], magicDNS: false }, + output: { dns: [] }, error: (data as Record).message ?? 'Failed to list DNS nameservers', } } @@ -49,13 +49,11 @@ export const tailscaleListDnsNameserversTool: ToolConfig< success: true, output: { dns: data.dns ?? [], - magicDNS: data.magicDNS ?? false, }, } }, outputs: { dns: { type: 'array', description: 'List of DNS nameserver addresses' }, - magicDNS: { type: 'boolean', description: 'Whether MagicDNS is enabled' }, }, } diff --git a/apps/sim/tools/tailscale/list_users.ts b/apps/sim/tools/tailscale/list_users.ts index 100719d637b..5d0c58c28fd 100644 --- a/apps/sim/tools/tailscale/list_users.ts +++ b/apps/sim/tools/tailscale/list_users.ts @@ -46,7 +46,7 @@ export const tailscaleListUsersTool: ToolConfig = { + id: 'tailscale_set_acl', + name: 'Tailscale Set ACL', + description: 'Replace the ACL policy file for the tailnet', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + acl: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The new ACL policy file, as a JSON string', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ETag from a prior Get ACL call to avoid overwriting concurrent updates. Use "ts-default" to only replace an untouched default policy file.', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/acl`, + method: 'POST', + headers: (params) => { + const headers: Record = { + Authorization: `Bearer ${params.apiKey.trim()}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + } + if (params.ifMatch) headers['If-Match'] = `"${params.ifMatch.trim().replace(/^"|"$/g, '')}"` + return headers + }, + body: (params) => params.acl.trim(), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { acl: '', etag: '' }, + error: (data as Record).message ?? 'Failed to set ACL', + } + } + + const etag = response.headers.get('ETag') ?? '' + const data = await response.json() + + return { + success: true, + output: { + acl: JSON.stringify(data, null, 2), + etag, + }, + } + }, + + outputs: { + acl: { type: 'string', description: 'Updated ACL policy as JSON string' }, + etag: { + type: 'string', + description: 'ETag for the new ACL version (use with If-Match header for future updates)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tailscale/suspend_user.ts b/apps/sim/tools/tailscale/suspend_user.ts new file mode 100644 index 00000000000..ac744155da5 --- /dev/null +++ b/apps/sim/tools/tailscale/suspend_user.ts @@ -0,0 +1,77 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { TailscaleBaseParams } from './types' + +interface TailscaleSuspendUserParams extends TailscaleBaseParams { + userId: string +} + +interface TailscaleSuspendUserResponse extends ToolResponse { + output: { + success: boolean + userId: string + } +} + +export const tailscaleSuspendUserTool: ToolConfig< + TailscaleSuspendUserParams, + TailscaleSuspendUserResponse +> = { + id: 'tailscale_suspend_user', + name: 'Tailscale Suspend User', + description: "Suspend a user's access to the tailnet", + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'User ID to suspend', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/users/${encodeURIComponent(params.userId.trim())}/suspend`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey.trim()}`, + }), + }, + + transformResponse: async (response: Response, params?: TailscaleSuspendUserParams) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { success: false, userId: '' }, + error: (data as Record).message ?? 'Failed to suspend user', + } + } + + return { + success: true, + output: { + success: true, + userId: params?.userId ?? '', + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the user was successfully suspended' }, + userId: { type: 'string', description: 'ID of the suspended user' }, + }, +} diff --git a/apps/sim/tools/tailscale/types.ts b/apps/sim/tools/tailscale/types.ts index 6f358964089..e97fa82953b 100644 --- a/apps/sim/tools/tailscale/types.ts +++ b/apps/sim/tools/tailscale/types.ts @@ -32,6 +32,7 @@ export interface TailscaleCreateAuthKeyParams extends TailscaleBaseParams { interface TailscaleDeviceOutput { id: string + nodeId: string name: string hostname: string user: string @@ -41,6 +42,8 @@ interface TailscaleDeviceOutput { tags: string[] authorized: boolean blocksIncomingConnections: boolean + keyExpiryDisabled: boolean + expires: string lastSeen: string created: string } @@ -126,7 +129,6 @@ export interface TailscaleSetDeviceRoutesResponse extends ToolResponse { export interface TailscaleListDnsNameserversResponse extends ToolResponse { output: { dns: string[] - magicDNS: boolean } } diff --git a/apps/sim/tools/trello/add_checklist_item.ts b/apps/sim/tools/trello/add_checklist_item.ts new file mode 100644 index 00000000000..085768bbc61 --- /dev/null +++ b/apps/sim/tools/trello/add_checklist_item.ts @@ -0,0 +1,148 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloChecklistItem, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { + TrelloAddChecklistItemParams, + TrelloAddChecklistItemResponse, +} from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloAddChecklistItemTool: ToolConfig< + TrelloAddChecklistItemParams, + TrelloAddChecklistItemResponse +> = { + id: 'trello_add_checklist_item', + name: 'Trello Add Checklist Item', + description: 'Add an item to a Trello checklist', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello checklist ID to add the item to (24-character hex string)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the checklist item', + }, + pos: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Position of the item (top, bottom, or positive float)', + }, + checked: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the item should start checked off', + }, + }, + + request: { + url: (params) => { + if (!params.checklistId) { + throw new Error('Checklist ID is required') + } + if (!params.name) { + throw new Error('Checklist item name is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/checklists/${params.checklistId.trim()}/checkItems` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('name', params.name.trim()) + + if (params.pos) url.searchParams.set('pos', params.pos) + if (params.checked !== undefined) url.searchParams.set('checked', String(params.checked)) + + return url.toString() + }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to add checklist item') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const item = mapTrelloChecklistItem(data) + + return { + success: true, + output: { + item, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse created checklist item') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + item: { + type: 'json', + description: 'Created checklist item (id, name, state, pos, idChecklist)', + optional: true, + properties: { + id: { type: 'string', description: 'Checklist item ID' }, + name: { type: 'string', description: 'Checklist item name' }, + state: { type: 'string', description: 'Item state (complete or incomplete)' }, + pos: { type: 'number', description: 'Item position on the checklist' }, + idChecklist: { + type: 'string', + description: 'Checklist ID containing the item', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/create_card.ts b/apps/sim/tools/trello/create_card.ts index b71f128a433..26b9d78d7e0 100644 --- a/apps/sim/tools/trello/create_card.ts +++ b/apps/sim/tools/trello/create_card.ts @@ -72,6 +72,16 @@ export const trelloCreateCardTool: ToolConfig = { + id: 'trello_delete_card', + name: 'Trello Delete Card', + description: 'Permanently delete a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to permanently delete (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'DELETE', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to delete card') + + return { + success: false, + output: { + success: false, + error, + }, + error, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the card was deleted', + }, + }, +} diff --git a/apps/sim/tools/trello/get_actions.ts b/apps/sim/tools/trello/get_actions.ts index 00857a5bede..edeab1a830c 100644 --- a/apps/sim/tools/trello/get_actions.ts +++ b/apps/sim/tools/trello/get_actions.ts @@ -56,6 +56,20 @@ export const trelloGetActionsTool: ToolConfig = + { + id: 'trello_list_members', + name: 'Trello List Members', + description: 'List members of a Trello board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello board ID (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.boardId) { + throw new Error('Board ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/boards/${params.boardId.trim()}/members`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to list board members') + + return { + success: false, + output: { + members: [], + count: 0, + error, + }, + error, + } + } + + if (!Array.isArray(data)) { + const error = 'Trello returned an invalid member collection' + + return { + success: false, + output: { + members: [], + count: 0, + error, + }, + error, + } + } + + try { + const members = data + .map((item) => mapTrelloMember(item)) + .filter((member): member is NonNullable => member !== null) + + return { + success: true, + output: { + members, + count: members.length, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse board members') + + return { + success: false, + output: { + members: [], + count: 0, + error: message, + }, + error: message, + } + } + }, + + outputs: { + members: { + type: 'array', + description: 'Members on the selected board', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Member ID' }, + fullName: { type: 'string', description: 'Member full name', optional: true }, + username: { type: 'string', description: 'Member username', optional: true }, + }, + }, + }, + count: { type: 'number', description: 'Number of members returned' }, + }, + } diff --git a/apps/sim/tools/trello/remove_label.ts b/apps/sim/tools/trello/remove_label.ts new file mode 100644 index 00000000000..27be742794d --- /dev/null +++ b/apps/sim/tools/trello/remove_label.ts @@ -0,0 +1,97 @@ +import { env } from '@/lib/core/config/env' +import { extractTrelloErrorMessage, TRELLO_API_BASE_URL } from '@/tools/trello/shared' +import type { TrelloRemoveLabelParams, TrelloRemoveLabelResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloRemoveLabelTool: ToolConfig = + { + id: 'trello_remove_label', + name: 'Trello Remove Label', + description: 'Detach a label from a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to detach the label from (24-character hex string)', + }, + labelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the label to detach (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.labelId) { + throw new Error('Label ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idLabels/${params.labelId.trim()}` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'DELETE', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to remove label') + + return { + success: false, + output: { + success: false, + error, + }, + error, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the label was removed from the card', + }, + }, + } diff --git a/apps/sim/tools/trello/remove_member.ts b/apps/sim/tools/trello/remove_member.ts new file mode 100644 index 00000000000..f722efd5e05 --- /dev/null +++ b/apps/sim/tools/trello/remove_member.ts @@ -0,0 +1,99 @@ +import { env } from '@/lib/core/config/env' +import { extractTrelloErrorMessage, TRELLO_API_BASE_URL } from '@/tools/trello/shared' +import type { TrelloRemoveMemberParams, TrelloRemoveMemberResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloRemoveMemberTool: ToolConfig< + TrelloRemoveMemberParams, + TrelloRemoveMemberResponse +> = { + id: 'trello_remove_member', + name: 'Trello Remove Member', + description: 'Unassign a member from a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to unassign the member from (24-character hex string)', + }, + memberId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the member to unassign (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.memberId) { + throw new Error('Member ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idMembers/${params.memberId.trim()}` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'DELETE', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to remove member') + + return { + success: false, + output: { + success: false, + error, + }, + error, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the member was removed from the card', + }, + }, +} diff --git a/apps/sim/tools/trello/search.ts b/apps/sim/tools/trello/search.ts new file mode 100644 index 00000000000..6d737ad155a --- /dev/null +++ b/apps/sim/tools/trello/search.ts @@ -0,0 +1,195 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloBoard, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloSearchParams, TrelloSearchResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloSearchTool: ToolConfig = { + id: 'trello_search', + name: 'Trello Search', + description: 'Search Trello cards and boards by keyword', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Search text, supports Trello search operators (e.g. board:, list:, due:)', + }, + idBoards: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Restrict the search to these board IDs', + items: { + type: 'string', + description: 'A Trello board ID', + }, + }, + modelTypes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated result types to search: cards, boards, or all (default all)', + }, + cardsLimit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of cards to return (1-1000, default 10)', + }, + }, + + request: { + url: (params) => { + if (!params.query) { + throw new Error('Search query is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/search`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('query', params.query) + url.searchParams.set('modelTypes', params.modelTypes || 'all') + + if (params.idBoards?.length) { + url.searchParams.set('idBoards', params.idBoards.join(',')) + } + + if (params.cardsLimit !== undefined) { + url.searchParams.set('cards_limit', String(params.cardsLimit)) + } + + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to search Trello') + + return { + success: false, + output: { + cards: [], + boards: [], + count: 0, + error, + }, + error, + } + } + + if (!isRecordLike(data)) { + const error = 'Trello returned an invalid search result' + + return { + success: false, + output: { + cards: [], + boards: [], + count: 0, + error, + }, + error, + } + } + + try { + const rawCards = Array.isArray(data.cards) ? data.cards : [] + const rawBoards = Array.isArray(data.boards) ? data.boards : [] + const cards = rawCards.map((item) => mapTrelloCard(item)) + const boards = rawBoards.map((item) => mapTrelloBoard(item)) + + return { + success: true, + output: { + cards, + boards, + count: cards.length + boards.length, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse Trello search results') + + return { + success: false, + output: { + cards: [], + boards: [], + count: 0, + error: message, + }, + error: message, + } + } + }, + + outputs: { + cards: { + type: 'array', + description: 'Cards matching the search query', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + }, + }, + }, + boards: { + type: 'array', + description: 'Boards matching the search query', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + desc: { type: 'string', description: 'Board description' }, + url: { type: 'string', description: 'Full board URL' }, + closed: { type: 'boolean', description: 'Whether the board is archived' }, + idOrganization: { + type: 'string', + description: 'Workspace/organization ID that owns the board', + optional: true, + }, + }, + }, + }, + count: { type: 'number', description: 'Total number of cards and boards returned' }, + }, +} diff --git a/apps/sim/tools/trello/shared.ts b/apps/sim/tools/trello/shared.ts index 27617a5ca77..b1c2346af83 100644 --- a/apps/sim/tools/trello/shared.ts +++ b/apps/sim/tools/trello/shared.ts @@ -7,6 +7,7 @@ import type { TrelloBoard, TrelloCard, TrelloChecklist, + TrelloChecklistItem, TrelloComment, TrelloLabel, TrelloList, @@ -84,7 +85,7 @@ function mapTrelloLabel(value: unknown): TrelloLabel | null { } } -function mapTrelloMember(value: unknown): TrelloMember | null { +export function mapTrelloMember(value: unknown): TrelloMember | null { if (!isRecordLike(value) || typeof value.id !== 'string') { return null } @@ -206,6 +207,20 @@ export function mapTrelloChecklist(value: unknown): TrelloChecklist { } } +export function mapTrelloChecklistItem(value: unknown): TrelloChecklistItem { + if (!isRecordLike(value)) { + throw new Error('Trello returned an invalid checklist item object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + state: getRequiredString(value.state, 'state'), + pos: getNumber(value.pos), + idChecklist: getOptionalString(value.idChecklist), + } +} + export function mapTrelloAction(value: unknown): TrelloAction { if (!isRecordLike(value)) { throw new Error('Trello returned an invalid action object') diff --git a/apps/sim/tools/trello/types.ts b/apps/sim/tools/trello/types.ts index fdbbbc8d7e3..de253a083e4 100644 --- a/apps/sim/tools/trello/types.ts +++ b/apps/sim/tools/trello/types.ts @@ -87,12 +87,14 @@ export interface TrelloComment extends TrelloAction {} export interface TrelloListListsParams { accessToken: string boardId: string + filter?: string } export interface TrelloListCardsParams { accessToken: string boardId?: string listId?: string + filter?: string } export interface TrelloCreateCardParams { @@ -104,6 +106,7 @@ export interface TrelloCreateCardParams { due?: string dueComplete?: boolean labelIds?: string[] + memberIds?: string[] } export interface TrelloUpdateCardParams { @@ -117,6 +120,11 @@ export interface TrelloUpdateCardParams { dueComplete?: boolean } +export interface TrelloDeleteCardParams { + accessToken: string + cardId: string +} + export interface TrelloGetActionsParams { accessToken: string boardId?: string @@ -124,6 +132,8 @@ export interface TrelloGetActionsParams { filter?: string limit?: number page?: number + since?: string + before?: string } export interface TrelloAddCommentParams { @@ -164,18 +174,68 @@ export interface TrelloAddChecklistParams { pos?: string } +export interface TrelloAddChecklistItemParams { + accessToken: string + checklistId: string + name: string + pos?: string + checked?: boolean +} + +export interface TrelloUpdateChecklistItemParams { + accessToken: string + cardId: string + checkItemId: string + state?: 'complete' | 'incomplete' + name?: string +} + export interface TrelloAddLabelParams { accessToken: string cardId: string labelId: string } +export interface TrelloRemoveLabelParams { + accessToken: string + cardId: string + labelId: string +} + export interface TrelloAddMemberParams { accessToken: string cardId: string memberId: string } +export interface TrelloRemoveMemberParams { + accessToken: string + cardId: string + memberId: string +} + +export interface TrelloListMembersParams { + accessToken: string + boardId: string +} + +export interface TrelloUpdateListParams { + accessToken: string + listId: string + name?: string + closed?: boolean + idBoard?: string + pos?: string +} + +export interface TrelloSearchParams { + accessToken: string + query: string + idBoards?: string[] + modelTypes?: string + cardsLimit?: number +} + export interface TrelloListListsResponse extends ToolResponse { output: { lists: TrelloList[] @@ -256,6 +316,28 @@ export interface TrelloAddChecklistResponse extends ToolResponse { } } +export interface TrelloChecklistItem { + id: string + name: string + state: string + pos: number + idChecklist: string | null +} + +export interface TrelloAddChecklistItemResponse extends ToolResponse { + output: { + item?: TrelloChecklistItem + error?: string + } +} + +export interface TrelloUpdateChecklistItemResponse extends ToolResponse { + output: { + item?: TrelloChecklistItem + error?: string + } +} + export interface TrelloAddLabelResponse extends ToolResponse { output: { labelIds: string[] @@ -263,6 +345,13 @@ export interface TrelloAddLabelResponse extends ToolResponse { } } +export interface TrelloRemoveLabelResponse extends ToolResponse { + output: { + success: boolean + error?: string + } +} + export interface TrelloAddMemberResponse extends ToolResponse { output: { memberIds: string[] @@ -270,17 +359,63 @@ export interface TrelloAddMemberResponse extends ToolResponse { } } +export interface TrelloRemoveMemberResponse extends ToolResponse { + output: { + success: boolean + error?: string + } +} + +export interface TrelloListMembersResponse extends ToolResponse { + output: { + members: TrelloMember[] + count: number + error?: string + } +} + +export interface TrelloUpdateListResponse extends ToolResponse { + output: { + list?: TrelloList + error?: string + } +} + +export interface TrelloDeleteCardResponse extends ToolResponse { + output: { + success: boolean + error?: string + } +} + +export interface TrelloSearchResponse extends ToolResponse { + output: { + cards: TrelloCard[] + boards: TrelloBoard[] + count: number + error?: string + } +} + export type TrelloResponse = | TrelloListListsResponse | TrelloListCardsResponse | TrelloCreateCardResponse | TrelloUpdateCardResponse + | TrelloDeleteCardResponse | TrelloGetActionsResponse | TrelloAddCommentResponse | TrelloCreateBoardResponse | TrelloGetBoardResponse | TrelloCreateListResponse + | TrelloUpdateListResponse | TrelloGetCardResponse | TrelloAddChecklistResponse + | TrelloAddChecklistItemResponse + | TrelloUpdateChecklistItemResponse | TrelloAddLabelResponse + | TrelloRemoveLabelResponse | TrelloAddMemberResponse + | TrelloRemoveMemberResponse + | TrelloListMembersResponse + | TrelloSearchResponse diff --git a/apps/sim/tools/trello/update_checklist_item.ts b/apps/sim/tools/trello/update_checklist_item.ts new file mode 100644 index 00000000000..5c2f2939866 --- /dev/null +++ b/apps/sim/tools/trello/update_checklist_item.ts @@ -0,0 +1,150 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloChecklistItem, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { + TrelloUpdateChecklistItemParams, + TrelloUpdateChecklistItemResponse, +} from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloUpdateChecklistItemTool: ToolConfig< + TrelloUpdateChecklistItemParams, + TrelloUpdateChecklistItemResponse +> = { + id: 'trello_update_checklist_item', + name: 'Trello Update Checklist Item', + description: 'Check off, uncheck, or rename a Trello checklist item', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID that owns the checklist item (24-character hex string)', + }, + checkItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Checklist item ID to update (24-character hex string)', + }, + state: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Set the item state to complete or incomplete', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the checklist item', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.checkItemId) { + throw new Error('Checklist item ID is required') + } + if (!params.state && !params.name) { + throw new Error('At least one of state or name must be provided to update') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/checkItem/${params.checkItemId.trim()}` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + if (params.state) url.searchParams.set('state', params.state) + if (params.name) url.searchParams.set('name', params.name.trim()) + + return url.toString() + }, + method: 'PUT', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to update checklist item') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const item = mapTrelloChecklistItem(data) + + return { + success: true, + output: { + item, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse updated checklist item') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + item: { + type: 'json', + description: 'Updated checklist item (id, name, state, pos, idChecklist)', + optional: true, + properties: { + id: { type: 'string', description: 'Checklist item ID' }, + name: { type: 'string', description: 'Checklist item name' }, + state: { type: 'string', description: 'Item state (complete or incomplete)' }, + pos: { type: 'number', description: 'Item position on the checklist' }, + idChecklist: { + type: 'string', + description: 'Checklist ID containing the item', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/update_list.ts b/apps/sim/tools/trello/update_list.ts new file mode 100644 index 00000000000..604cc3be0fb --- /dev/null +++ b/apps/sim/tools/trello/update_list.ts @@ -0,0 +1,150 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloList, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloUpdateListParams, TrelloUpdateListResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloUpdateListTool: ToolConfig = { + id: 'trello_update_list', + name: 'Trello Update List', + description: 'Rename, move, archive, or reopen a Trello list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello list ID (24-character hex string)', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the list', + }, + closed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Archive the list (true) or reopen it (false)', + }, + idBoard: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Board ID to move the list to (24-character hex string)', + }, + pos: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New position of the list (top, bottom, or positive float)', + }, + }, + + request: { + url: (params) => { + if (!params.listId) { + throw new Error('List ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/lists/${params.listId.trim()}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'PUT', + headers: () => ({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.closed !== undefined) body.closed = params.closed + if (params.idBoard !== undefined) body.idBoard = params.idBoard.trim() + if (params.pos !== undefined) body.pos = params.pos + + if (Object.keys(body).length === 0) { + throw new Error('At least one field must be provided to update') + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to update list') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const list = mapTrelloList(data) + + return { + success: true, + output: { + list, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse updated list') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + list: { + type: 'json', + description: 'Updated list (id, name, closed, pos, idBoard)', + optional: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + closed: { type: 'boolean', description: 'Whether the list is archived' }, + pos: { type: 'number', description: 'List position on the board' }, + idBoard: { type: 'string', description: 'Board ID containing the list' }, + }, + }, + }, +} diff --git a/apps/sim/tools/vercel/add_domain.ts b/apps/sim/tools/vercel/add_domain.ts index 060730693e9..6dfb910bac5 100644 --- a/apps/sim/tools/vercel/add_domain.ts +++ b/apps/sim/tools/vercel/add_domain.ts @@ -26,12 +26,19 @@ export const vercelAddDomainTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v7/domains${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/add_project_domain.ts b/apps/sim/tools/vercel/add_project_domain.ts index a89492bad5f..1e38aa00145 100644 --- a/apps/sim/tools/vercel/add_project_domain.ts +++ b/apps/sim/tools/vercel/add_project_domain.ts @@ -56,12 +56,19 @@ export const vercelAddProjectDomainTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelAddProjectDomainParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/domains${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/cancel_deployment.ts b/apps/sim/tools/vercel/cancel_deployment.ts index e9a9150aece..d28af1cb9e2 100644 --- a/apps/sim/tools/vercel/cancel_deployment.ts +++ b/apps/sim/tools/vercel/cancel_deployment.ts @@ -32,12 +32,19 @@ export const vercelCancelDeploymentTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelCancelDeploymentParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v12/deployments/${params.deploymentId.trim()}/cancel${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/create_alias.ts b/apps/sim/tools/vercel/create_alias.ts index 8146005c0ec..3534db975d9 100644 --- a/apps/sim/tools/vercel/create_alias.ts +++ b/apps/sim/tools/vercel/create_alias.ts @@ -27,18 +27,32 @@ export const vercelCreateAliasTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v2/deployments/${params.deploymentId.trim()}/aliases${qs ? `?${qs}` : ''}` }, @@ -47,9 +61,13 @@ export const vercelCreateAliasTool: ToolConfig ({ - alias: params.alias.trim(), - }), + body: (params: VercelCreateAliasParams) => { + const body: Record = { alias: params.alias.trim() } + if (params.redirect != null && params.redirect !== '') { + body.redirect = params.redirect.trim() + } + return body + }, }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/vercel/create_check.ts b/apps/sim/tools/vercel/create_check.ts index 68c7f9b8b34..6f4ce6cd68b 100644 --- a/apps/sim/tools/vercel/create_check.ts +++ b/apps/sim/tools/vercel/create_check.ts @@ -62,12 +62,19 @@ export const vercelCreateCheckTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/create_deployment.ts b/apps/sim/tools/vercel/create_deployment.ts index a167ccc5cde..8fecd6fd339 100644 --- a/apps/sim/tools/vercel/create_deployment.ts +++ b/apps/sim/tools/vercel/create_deployment.ts @@ -64,6 +64,12 @@ export const vercelCreateDeploymentTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { @@ -71,6 +77,7 @@ export const vercelCreateDeploymentTool: ToolConfig< const query = new URLSearchParams() if (params.forceNew) query.set('forceNew', params.forceNew) if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v13/deployments${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/create_dns_record.ts b/apps/sim/tools/vercel/create_dns_record.ts index fdb64c93c24..86ab6abd533 100644 --- a/apps/sim/tools/vercel/create_dns_record.ts +++ b/apps/sim/tools/vercel/create_dns_record.ts @@ -40,9 +40,9 @@ export const vercelCreateDnsRecordTool: ToolConfig< }, value: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', - description: 'The value of the DNS record', + description: 'The value of the DNS record (not used for SRV/HTTPS records)', }, ttl: { type: 'number', @@ -56,18 +56,73 @@ export const vercelCreateDnsRecordTool: ToolConfig< visibility: 'user-or-llm', description: 'Priority for MX records', }, + srvTarget: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Target hostname for SRV records (required when recordType is SRV)', + }, + srvWeight: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Weight for SRV records (required when recordType is SRV)', + }, + srvPort: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Port for SRV records (required when recordType is SRV)', + }, + srvPriority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority for SRV records (required when recordType is SRV)', + }, + httpsTarget: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Target hostname for HTTPS records (required when recordType is HTTPS)', + }, + httpsPriority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority for HTTPS records (required when recordType is HTTPS)', + }, + httpsParams: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional service parameters for HTTPS records (e.g. "alpn=h2,h3")', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'A comment to add context on what this DNS record is for (max 500 characters)', + }, teamId: { type: 'string', required: false, visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelCreateDnsRecordParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records${qs ? `?${qs}` : ''}` }, @@ -77,13 +132,32 @@ export const vercelCreateDnsRecordTool: ToolConfig< 'Content-Type': 'application/json', }), body: (params: VercelCreateDnsRecordParams) => { + const type = params.recordType.trim().toUpperCase() const body: Record = { name: params.recordName.trim(), - type: params.recordType.trim(), - value: params.value.trim(), + type, } if (params.ttl != null) body.ttl = params.ttl - if (params.mxPriority != null) body.mxPriority = params.mxPriority + + if (type === 'SRV') { + body.srv = { + target: params.srvTarget?.trim(), + weight: params.srvWeight, + port: params.srvPort, + priority: params.srvPriority, + } + } else if (type === 'HTTPS') { + body.https = { + target: params.httpsTarget?.trim(), + priority: params.httpsPriority, + ...(params.httpsParams ? { params: params.httpsParams.trim() } : {}), + } + } else { + if (params.value != null) body.value = params.value.trim() + if (type === 'MX' && params.mxPriority != null) body.mxPriority = params.mxPriority + } + + if (params.comment != null && params.comment !== '') body.comment = params.comment return body }, }, diff --git a/apps/sim/tools/vercel/create_env_var.ts b/apps/sim/tools/vercel/create_env_var.ts index c7dc3c65661..75681ac1e3d 100644 --- a/apps/sim/tools/vercel/create_env_var.ts +++ b/apps/sim/tools/vercel/create_env_var.ts @@ -65,12 +65,19 @@ export const vercelCreateEnvVarTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelCreateEnvVarParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/create_project.ts b/apps/sim/tools/vercel/create_project.ts index b05e4b83617..8f077fa97bc 100644 --- a/apps/sim/tools/vercel/create_project.ts +++ b/apps/sim/tools/vercel/create_project.ts @@ -53,18 +53,43 @@ export const vercelCreateProjectTool: ToolConfig< visibility: 'user-or-llm', description: 'Custom install command', }, + rootDirectory: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Subdirectory of the repository the project lives in (for monorepos)', + }, + nodeVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Node.js version to use (e.g. 22.x, 20.x, 18.x)', + }, + devCommand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Custom dev server command', + }, teamId: { type: 'string', required: false, visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelCreateProjectParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v11/projects${qs ? `?${qs}` : ''}` }, @@ -80,6 +105,9 @@ export const vercelCreateProjectTool: ToolConfig< if (params.buildCommand) body.buildCommand = params.buildCommand.trim() if (params.outputDirectory) body.outputDirectory = params.outputDirectory.trim() if (params.installCommand) body.installCommand = params.installCommand.trim() + if (params.rootDirectory) body.rootDirectory = params.rootDirectory.trim() + if (params.nodeVersion) body.nodeVersion = params.nodeVersion.trim() + if (params.devCommand) body.devCommand = params.devCommand.trim() return body }, }, diff --git a/apps/sim/tools/vercel/delete_alias.ts b/apps/sim/tools/vercel/delete_alias.ts index cc476199379..9bb6556a160 100644 --- a/apps/sim/tools/vercel/delete_alias.ts +++ b/apps/sim/tools/vercel/delete_alias.ts @@ -27,12 +27,19 @@ export const vercelDeleteAliasTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v2/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/delete_deployment.ts b/apps/sim/tools/vercel/delete_deployment.ts index b2989b7f81c..175ace93e1b 100644 --- a/apps/sim/tools/vercel/delete_deployment.ts +++ b/apps/sim/tools/vercel/delete_deployment.ts @@ -32,12 +32,19 @@ export const vercelDeleteDeploymentTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelDeleteDeploymentParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const id = params.deploymentId.trim() if (id.includes('.')) { query.set('url', id) diff --git a/apps/sim/tools/vercel/delete_dns_record.ts b/apps/sim/tools/vercel/delete_dns_record.ts index 313df6192f9..397b71f2942 100644 --- a/apps/sim/tools/vercel/delete_dns_record.ts +++ b/apps/sim/tools/vercel/delete_dns_record.ts @@ -38,12 +38,19 @@ export const vercelDeleteDnsRecordTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelDeleteDnsRecordParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records/${params.recordId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/delete_domain.ts b/apps/sim/tools/vercel/delete_domain.ts index dc2ab080cc8..4839eb173b3 100644 --- a/apps/sim/tools/vercel/delete_domain.ts +++ b/apps/sim/tools/vercel/delete_domain.ts @@ -29,12 +29,19 @@ export const vercelDeleteDomainTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelDeleteDomainParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v6/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/delete_env_var.ts b/apps/sim/tools/vercel/delete_env_var.ts index 1c2f7f0ece4..75c54490baa 100644 --- a/apps/sim/tools/vercel/delete_env_var.ts +++ b/apps/sim/tools/vercel/delete_env_var.ts @@ -35,12 +35,19 @@ export const vercelDeleteEnvVarTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelDeleteEnvVarParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/env/${params.envId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/delete_project.ts b/apps/sim/tools/vercel/delete_project.ts index 7e04e41cd8c..5165966ae1a 100644 --- a/apps/sim/tools/vercel/delete_project.ts +++ b/apps/sim/tools/vercel/delete_project.ts @@ -29,12 +29,19 @@ export const vercelDeleteProjectTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelDeleteProjectParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_alias.ts b/apps/sim/tools/vercel/get_alias.ts index 640b5e7b756..b0da8e89799 100644 --- a/apps/sim/tools/vercel/get_alias.ts +++ b/apps/sim/tools/vercel/get_alias.ts @@ -26,12 +26,19 @@ export const vercelGetAliasTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v4/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_check.ts b/apps/sim/tools/vercel/get_check.ts index a02fda8a5c4..a169806b5bc 100644 --- a/apps/sim/tools/vercel/get_check.ts +++ b/apps/sim/tools/vercel/get_check.ts @@ -32,12 +32,19 @@ export const vercelGetCheckTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_deployment.ts b/apps/sim/tools/vercel/get_deployment.ts index 248c25346f4..2c656267b30 100644 --- a/apps/sim/tools/vercel/get_deployment.ts +++ b/apps/sim/tools/vercel/get_deployment.ts @@ -35,6 +35,12 @@ export const vercelGetDeploymentTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { @@ -42,6 +48,7 @@ export const vercelGetDeploymentTool: ToolConfig< const query = new URLSearchParams() if (params.withGitRepoInfo) query.set('withGitRepoInfo', params.withGitRepoInfo) if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v13/deployments/${params.deploymentId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_deployment_events.ts b/apps/sim/tools/vercel/get_deployment_events.ts index 5bffa02eb7a..0848cd72ada 100644 --- a/apps/sim/tools/vercel/get_deployment_events.ts +++ b/apps/sim/tools/vercel/get_deployment_events.ts @@ -62,6 +62,12 @@ export const vercelGetDeploymentEventsTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { @@ -73,6 +79,7 @@ export const vercelGetDeploymentEventsTool: ToolConfig< if (params.since !== undefined) query.set('since', String(params.since)) if (params.until !== undefined) query.set('until', String(params.until)) if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v3/deployments/${params.deploymentId.trim()}/events${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_domain.ts b/apps/sim/tools/vercel/get_domain.ts index 773581659ce..186fcc91e1f 100644 --- a/apps/sim/tools/vercel/get_domain.ts +++ b/apps/sim/tools/vercel/get_domain.ts @@ -26,12 +26,19 @@ export const vercelGetDomainTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v5/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_domain_config.ts b/apps/sim/tools/vercel/get_domain_config.ts index 14cbeb5d415..6ff50af4a5a 100644 --- a/apps/sim/tools/vercel/get_domain_config.ts +++ b/apps/sim/tools/vercel/get_domain_config.ts @@ -32,12 +32,19 @@ export const vercelGetDomainConfigTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelGetDomainConfigParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v6/domains/${params.domain.trim()}/config${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_env_vars.ts b/apps/sim/tools/vercel/get_env_vars.ts index 00e8c735a79..30750f9d9df 100644 --- a/apps/sim/tools/vercel/get_env_vars.ts +++ b/apps/sim/tools/vercel/get_env_vars.ts @@ -20,18 +20,40 @@ export const vercelGetEnvVarsTool: ToolConfig { const query = new URLSearchParams() + if (params.decrypt) query.set('decrypt', 'true') + if (params.gitBranch) query.set('gitBranch', params.gitBranch.trim()) if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/get_project.ts b/apps/sim/tools/vercel/get_project.ts index 4fa8f3c401f..f5255a5ebab 100644 --- a/apps/sim/tools/vercel/get_project.ts +++ b/apps/sim/tools/vercel/get_project.ts @@ -26,12 +26,19 @@ export const vercelGetProjectTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}` }, @@ -50,6 +57,8 @@ export const vercelGetProjectTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/list_deployment_files.ts b/apps/sim/tools/vercel/list_deployment_files.ts index c44c8e01b5b..edfda1f3f34 100644 --- a/apps/sim/tools/vercel/list_deployment_files.ts +++ b/apps/sim/tools/vercel/list_deployment_files.ts @@ -32,12 +32,19 @@ export const vercelListDeploymentFilesTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelListDeploymentFilesParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v6/deployments/${params.deploymentId.trim()}/files${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/list_deployments.ts b/apps/sim/tools/vercel/list_deployments.ts index e5e739d346f..be179952f4a 100644 --- a/apps/sim/tools/vercel/list_deployments.ts +++ b/apps/sim/tools/vercel/list_deployments.ts @@ -69,6 +69,12 @@ export const vercelListDeploymentsTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { @@ -82,6 +88,7 @@ export const vercelListDeploymentsTool: ToolConfig< if (params.until) query.set('until', String(params.until)) if (params.limit) query.set('limit', String(params.limit)) if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v7/deployments${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/list_dns_records.ts b/apps/sim/tools/vercel/list_dns_records.ts index f2a9106c24d..e18a955325f 100644 --- a/apps/sim/tools/vercel/list_dns_records.ts +++ b/apps/sim/tools/vercel/list_dns_records.ts @@ -35,6 +35,12 @@ export const vercelListDnsRecordsTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { @@ -42,6 +48,7 @@ export const vercelListDnsRecordsTool: ToolConfig< const query = new URLSearchParams() if (params.limit) query.set('limit', String(params.limit)) if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v5/domains/${params.domain.trim()}/records${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/list_domains.ts b/apps/sim/tools/vercel/list_domains.ts index c5ff697c920..775771f3470 100644 --- a/apps/sim/tools/vercel/list_domains.ts +++ b/apps/sim/tools/vercel/list_domains.ts @@ -27,6 +27,12 @@ export const vercelListDomainsTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) if (params.limit) query.set('limit', String(params.limit)) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains${qs ? `?${qs}` : ''}` diff --git a/apps/sim/tools/vercel/list_projects.ts b/apps/sim/tools/vercel/list_projects.ts index 12465171716..19ee34a8547 100644 --- a/apps/sim/tools/vercel/list_projects.ts +++ b/apps/sim/tools/vercel/list_projects.ts @@ -29,12 +29,25 @@ export const vercelListProjectsTool: ToolConfig< visibility: 'user-or-llm', description: 'Maximum number of projects to return', }, + from: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "Continuation token for pagination, taken from the previous response's pagination.next value. Query only projects updated after this timestamp or continuation token.", + }, teamId: { type: 'string', required: false, visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { @@ -42,7 +55,9 @@ export const vercelListProjectsTool: ToolConfig< const query = new URLSearchParams() if (params.search) query.set('search', params.search) if (params.limit) query.set('limit', String(params.limit)) + if (params.from) query.set('from', params.from.trim()) if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v10/projects${qs ? `?${qs}` : ''}` }, @@ -59,6 +74,8 @@ export const vercelListProjectsTool: ToolConfig< id: p.id, name: p.name, framework: p.framework ?? null, + rootDirectory: p.rootDirectory ?? null, + nodeVersion: p.nodeVersion ?? null, createdAt: p.createdAt, updatedAt: p.updatedAt, })) @@ -69,6 +86,7 @@ export const vercelListProjectsTool: ToolConfig< projects, count: projects.length, hasMore: data.pagination?.next != null, + nextFrom: data.pagination?.next != null ? String(data.pagination.next) : null, }, } }, @@ -83,6 +101,12 @@ export const vercelListProjectsTool: ToolConfig< id: { type: 'string', description: 'Project ID' }, name: { type: 'string', description: 'Project name' }, framework: { type: 'string', description: 'Framework', optional: true }, + rootDirectory: { + type: 'string', + description: 'Root directory of the project', + optional: true, + }, + nodeVersion: { type: 'string', description: 'Node.js version', optional: true }, createdAt: { type: 'number', description: 'Creation timestamp' }, updatedAt: { type: 'number', description: 'Last updated timestamp' }, }, @@ -96,5 +120,10 @@ export const vercelListProjectsTool: ToolConfig< type: 'boolean', description: 'Whether more projects are available', }, + nextFrom: { + type: 'string', + description: 'Continuation token to pass as `from` to fetch the next page', + optional: true, + }, }, } diff --git a/apps/sim/tools/vercel/pause_project.ts b/apps/sim/tools/vercel/pause_project.ts index e7e56f6b976..746291914f9 100644 --- a/apps/sim/tools/vercel/pause_project.ts +++ b/apps/sim/tools/vercel/pause_project.ts @@ -29,12 +29,19 @@ export const vercelPauseProjectTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelPauseProjectParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v1/projects/${params.projectId.trim()}/pause${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/promote_deployment.ts b/apps/sim/tools/vercel/promote_deployment.ts index f2b07b81915..b472912715f 100644 --- a/apps/sim/tools/vercel/promote_deployment.ts +++ b/apps/sim/tools/vercel/promote_deployment.ts @@ -38,12 +38,19 @@ export const vercelPromoteDeploymentTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelPromoteDeploymentParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/promote/${params.deploymentId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/remove_project_domain.ts b/apps/sim/tools/vercel/remove_project_domain.ts index e9b15caeb9e..26edc72faa6 100644 --- a/apps/sim/tools/vercel/remove_project_domain.ts +++ b/apps/sim/tools/vercel/remove_project_domain.ts @@ -38,12 +38,19 @@ export const vercelRemoveProjectDomainTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelRemoveProjectDomainParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/rerequest_check.ts b/apps/sim/tools/vercel/rerequest_check.ts index f0da658b586..dcd15a8ed78 100644 --- a/apps/sim/tools/vercel/rerequest_check.ts +++ b/apps/sim/tools/vercel/rerequest_check.ts @@ -35,12 +35,26 @@ export const vercelRerequestCheckTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, + autoUpdate: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to mark the check as running immediately on rerequest', + }, }, request: { url: (params: VercelRerequestCheckParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) + if (params.autoUpdate !== undefined) query.set('autoUpdate', String(params.autoUpdate)) const qs = query.toString() return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}/rerequest${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/types.ts b/apps/sim/tools/vercel/types.ts index 8e0f4f2b5ff..e5b6f65e756 100644 --- a/apps/sim/tools/vercel/types.ts +++ b/apps/sim/tools/vercel/types.ts @@ -23,6 +23,7 @@ export interface VercelListDeploymentsParams { until?: number limit?: number teamId?: string + slug?: string } export interface VercelGetDeploymentParams { @@ -30,19 +31,23 @@ export interface VercelGetDeploymentParams { deploymentId: string withGitRepoInfo?: string teamId?: string + slug?: string } export interface VercelListProjectsParams { apiKey: string search?: string limit?: number + from?: string teamId?: string + slug?: string } export interface VercelGetProjectParams { apiKey: string projectId: string teamId?: string + slug?: string } export interface VercelCreateDeploymentParams { @@ -54,18 +59,23 @@ export interface VercelCreateDeploymentParams { gitSource?: string forceNew?: string teamId?: string + slug?: string } export interface VercelListDomainsParams { apiKey: string limit?: number teamId?: string + slug?: string } export interface VercelGetEnvVarsParams { apiKey: string projectId: string + decrypt?: boolean + gitBranch?: string teamId?: string + slug?: string } export interface VercelListDeploymentsResponse extends ToolResponse { @@ -134,11 +144,14 @@ export interface VercelListProjectsResponse extends ToolResponse { id: string name: string framework: string | null + rootDirectory: string | null + nodeVersion: string | null createdAt: number updatedAt: number }> count: number hasMore: boolean + nextFrom: string | null } } @@ -147,6 +160,8 @@ export interface VercelGetProjectResponse extends ToolResponse { id: string name: string framework: string | null + rootDirectory: string | null + nodeVersion: string | null createdAt: number updatedAt: number link: { @@ -189,6 +204,10 @@ export interface VercelListDomainsResponse extends ToolResponse { boughtAt: number | null transferredAt: number | null creator: VercelDomainCreator | null + customNameservers: string[] + userId: string | null + teamId: string | null + transferStartedAt: number | null }> count: number hasMore: boolean @@ -216,6 +235,7 @@ export interface VercelCancelDeploymentParams { apiKey: string deploymentId: string teamId?: string + slug?: string } export interface VercelCancelDeploymentResponse extends ToolResponse { @@ -234,6 +254,7 @@ export interface VercelDeleteDeploymentParams { apiKey: string deploymentId: string teamId?: string + slug?: string } export interface VercelDeleteDeploymentResponse extends ToolResponse { @@ -252,6 +273,7 @@ export interface VercelGetDeploymentEventsParams { since?: number until?: number teamId?: string + slug?: string } export interface VercelGetDeploymentEventsResponse extends ToolResponse { @@ -281,6 +303,7 @@ export interface VercelCreateEnvVarParams { gitBranch?: string comment?: string teamId?: string + slug?: string } export interface VercelCreateEnvVarResponse extends ToolResponse { @@ -308,6 +331,7 @@ export interface VercelUpdateEnvVarParams { gitBranch?: string comment?: string teamId?: string + slug?: string } export interface VercelUpdateEnvVarResponse extends ToolResponse { @@ -329,6 +353,7 @@ export interface VercelDeleteEnvVarParams { projectId: string envId: string teamId?: string + slug?: string } export interface VercelDeleteEnvVarResponse extends ToolResponse { @@ -341,6 +366,7 @@ export interface VercelListDeploymentFilesParams { apiKey: string deploymentId: string teamId?: string + slug?: string } export interface VercelListDeploymentFilesResponse extends ToolResponse { @@ -365,7 +391,11 @@ export interface VercelCreateProjectParams { buildCommand?: string outputDirectory?: string installCommand?: string + rootDirectory?: string + nodeVersion?: string + devCommand?: string teamId?: string + slug?: string } export interface VercelCreateProjectResponse extends ToolResponse { @@ -386,7 +416,11 @@ export interface VercelUpdateProjectParams { buildCommand?: string outputDirectory?: string installCommand?: string + rootDirectory?: string + nodeVersion?: string + devCommand?: string teamId?: string + slug?: string } export interface VercelUpdateProjectResponse extends ToolResponse { @@ -402,6 +436,7 @@ export interface VercelDeleteProjectParams { apiKey: string projectId: string teamId?: string + slug?: string } export interface VercelDeleteProjectResponse extends ToolResponse { @@ -414,6 +449,7 @@ export interface VercelPauseProjectParams { apiKey: string projectId: string teamId?: string + slug?: string } export interface VercelPauseProjectResponse extends ToolResponse { @@ -428,6 +464,7 @@ export interface VercelUnpauseProjectParams { apiKey: string projectId: string teamId?: string + slug?: string } export interface VercelUnpauseProjectResponse extends ToolResponse { @@ -442,6 +479,7 @@ export interface VercelListProjectDomainsParams { apiKey: string projectId: string teamId?: string + slug?: string limit?: number } @@ -472,6 +510,7 @@ export interface VercelAddProjectDomainParams { redirectStatusCode?: number gitBranch?: string teamId?: string + slug?: string } export interface VercelAddProjectDomainResponse extends ToolResponse { @@ -494,6 +533,7 @@ export interface VercelRemoveProjectDomainParams { projectId: string domain: string teamId?: string + slug?: string } export interface VercelRemoveProjectDomainResponse extends ToolResponse { @@ -506,6 +546,7 @@ export interface VercelGetDomainParams { apiKey: string domain: string teamId?: string + slug?: string } export interface VercelGetDomainResponse extends ToolResponse { @@ -533,6 +574,7 @@ export interface VercelAddDomainParams { apiKey: string name: string teamId?: string + slug?: string } export interface VercelAddDomainResponse extends ToolResponse { @@ -557,6 +599,7 @@ export interface VercelDeleteDomainParams { apiKey: string domain: string teamId?: string + slug?: string } export interface VercelDeleteDomainResponse extends ToolResponse { @@ -570,6 +613,7 @@ export interface VercelGetDomainConfigParams { apiKey: string domain: string teamId?: string + slug?: string } export interface VercelGetDomainConfigResponse extends ToolResponse { @@ -587,10 +631,19 @@ export interface VercelCreateDnsRecordParams { domain: string recordName: string recordType: string - value: string + value?: string ttl?: number mxPriority?: number + srvTarget?: string + srvWeight?: number + srvPort?: number + srvPriority?: number + httpsTarget?: string + httpsPriority?: number + httpsParams?: string + comment?: string teamId?: string + slug?: string } export interface VercelCreateDnsRecordResponse extends ToolResponse { @@ -605,6 +658,7 @@ export interface VercelListDnsRecordsParams { domain: string limit?: number teamId?: string + slug?: string } export interface VercelListDnsRecordsResponse extends ToolResponse { @@ -633,6 +687,7 @@ export interface VercelDeleteDnsRecordParams { domain: string recordId: string teamId?: string + slug?: string } export interface VercelDeleteDnsRecordResponse extends ToolResponse { @@ -772,6 +827,7 @@ export interface VercelListAliasesParams { domain?: string limit?: number teamId?: string + slug?: string } export interface VercelListAliasesResponse extends ToolResponse { @@ -796,6 +852,7 @@ export interface VercelGetAliasParams { apiKey: string aliasId: string teamId?: string + slug?: string } export interface VercelGetAliasResponse extends ToolResponse { @@ -816,7 +873,9 @@ export interface VercelCreateAliasParams { apiKey: string deploymentId: string alias: string + redirect?: string teamId?: string + slug?: string } export interface VercelCreateAliasResponse extends ToolResponse { @@ -832,6 +891,7 @@ export interface VercelDeleteAliasParams { apiKey: string aliasId: string teamId?: string + slug?: string } export interface VercelDeleteAliasResponse extends ToolResponse { @@ -997,6 +1057,7 @@ export interface VercelCreateCheckParams { externalId?: string rerequestable?: boolean teamId?: string + slug?: string } export interface VercelCheckResponse extends ToolResponse { @@ -1025,12 +1086,14 @@ export interface VercelGetCheckParams { deploymentId: string checkId: string teamId?: string + slug?: string } export interface VercelListChecksParams { apiKey: string deploymentId: string teamId?: string + slug?: string } export interface VercelListChecksResponse extends ToolResponse { @@ -1069,6 +1132,7 @@ export interface VercelUpdateCheckParams { path?: string output?: string teamId?: string + slug?: string } export interface VercelRerequestCheckParams { @@ -1076,6 +1140,8 @@ export interface VercelRerequestCheckParams { deploymentId: string checkId: string teamId?: string + slug?: string + autoUpdate?: boolean } export interface VercelRerequestCheckResponse extends ToolResponse { @@ -1092,8 +1158,16 @@ export interface VercelUpdateDnsRecordParams { type?: string ttl?: number mxPriority?: number + srvTarget?: string + srvWeight?: number + srvPort?: number + srvPriority?: number + httpsTarget?: string + httpsPriority?: number + httpsParams?: string comment?: string teamId?: string + slug?: string } export interface VercelUpdateDnsRecordResponse extends ToolResponse { @@ -1149,6 +1223,7 @@ export interface VercelUpdateProjectDomainParams { redirectStatusCode?: number gitBranch?: string teamId?: string + slug?: string } export interface VercelUpdateProjectDomainResponse extends ToolResponse { @@ -1171,6 +1246,7 @@ export interface VercelVerifyProjectDomainParams { projectId: string domain: string teamId?: string + slug?: string } export interface VercelVerifyProjectDomainResponse extends ToolResponse { @@ -1192,6 +1268,7 @@ export interface VercelPromoteDeploymentParams { projectId: string deploymentId: string teamId?: string + slug?: string } export interface VercelPromoteDeploymentResponse extends ToolResponse { diff --git a/apps/sim/tools/vercel/unpause_project.ts b/apps/sim/tools/vercel/unpause_project.ts index 39e195cbed4..afe7e01f1ac 100644 --- a/apps/sim/tools/vercel/unpause_project.ts +++ b/apps/sim/tools/vercel/unpause_project.ts @@ -29,12 +29,19 @@ export const vercelUnpauseProjectTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelUnpauseProjectParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v1/projects/${params.projectId.trim()}/unpause${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/update_check.ts b/apps/sim/tools/vercel/update_check.ts index c348ad754aa..486b118f097 100644 --- a/apps/sim/tools/vercel/update_check.ts +++ b/apps/sim/tools/vercel/update_check.ts @@ -74,12 +74,19 @@ export const vercelUpdateCheckTool: ToolConfig { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/update_dns_record.ts b/apps/sim/tools/vercel/update_dns_record.ts index b84a3bb60eb..92a3852b552 100644 --- a/apps/sim/tools/vercel/update_dns_record.ts +++ b/apps/sim/tools/vercel/update_dns_record.ts @@ -56,6 +56,48 @@ export const vercelUpdateDnsRecordTool: ToolConfig< visibility: 'user-or-llm', description: 'Priority for MX records', }, + srvTarget: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Target hostname for SRV records (required together when updating SRV data)', + }, + srvWeight: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Weight for SRV records (required together when updating SRV data)', + }, + srvPort: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Port for SRV records (required together when updating SRV data)', + }, + srvPriority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority for SRV records (required together when updating SRV data)', + }, + httpsTarget: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Target hostname for HTTPS records (required together when updating HTTPS data)', + }, + httpsPriority: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Priority for HTTPS records (required together when updating HTTPS data)', + }, + httpsParams: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional service parameters for HTTPS records (e.g. "alpn=h2,h3")', + }, comment: { type: 'string', required: false, @@ -68,12 +110,19 @@ export const vercelUpdateDnsRecordTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelUpdateDnsRecordParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v1/domains/records/${params.recordId.trim()}${qs ? `?${qs}` : ''}` }, @@ -85,16 +134,35 @@ export const vercelUpdateDnsRecordTool: ToolConfig< body: (params: VercelUpdateDnsRecordParams) => { const body: Record = {} if (params.name != null && params.name !== '') body.name = params.name - if (params.value != null && params.value !== '') body.value = params.value - if (params.type != null && params.type !== '') body.type = params.type + const type = + params.type != null && params.type !== '' ? params.type.trim().toUpperCase() : null + if (type != null) body.type = type if (params.ttl != null) { const ttl = Number(params.ttl) if (!Number.isNaN(ttl)) body.ttl = ttl } - if (params.mxPriority != null) { - const mxPriority = Number(params.mxPriority) - if (!Number.isNaN(mxPriority)) body.mxPriority = mxPriority + + if (type === 'SRV') { + body.srv = { + target: params.srvTarget?.trim(), + weight: params.srvWeight, + port: params.srvPort, + priority: params.srvPriority, + } + } else if (type === 'HTTPS') { + body.https = { + target: params.httpsTarget?.trim(), + priority: params.httpsPriority, + ...(params.httpsParams ? { params: params.httpsParams.trim() } : {}), + } + } else { + if (params.value != null && params.value !== '') body.value = params.value + if (params.mxPriority != null) { + const mxPriority = Number(params.mxPriority) + if (!Number.isNaN(mxPriority)) body.mxPriority = mxPriority + } } + if (params.comment != null && params.comment !== '') body.comment = params.comment return body }, diff --git a/apps/sim/tools/vercel/update_env_var.ts b/apps/sim/tools/vercel/update_env_var.ts index 15981e49877..d1ee50f99ef 100644 --- a/apps/sim/tools/vercel/update_env_var.ts +++ b/apps/sim/tools/vercel/update_env_var.ts @@ -71,12 +71,19 @@ export const vercelUpdateEnvVarTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelUpdateEnvVarParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/env/${params.envId.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/update_project.ts b/apps/sim/tools/vercel/update_project.ts index ee76b1c485e..ef0af58750a 100644 --- a/apps/sim/tools/vercel/update_project.ts +++ b/apps/sim/tools/vercel/update_project.ts @@ -53,18 +53,43 @@ export const vercelUpdateProjectTool: ToolConfig< visibility: 'user-or-llm', description: 'Custom install command', }, + rootDirectory: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Subdirectory of the repository the project lives in (for monorepos)', + }, + nodeVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Node.js version to use (e.g. 22.x, 20.x, 18.x)', + }, + devCommand: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Custom dev server command', + }, teamId: { type: 'string', required: false, visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelUpdateProjectParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}` }, @@ -80,6 +105,9 @@ export const vercelUpdateProjectTool: ToolConfig< if (params.buildCommand) body.buildCommand = params.buildCommand.trim() if (params.outputDirectory) body.outputDirectory = params.outputDirectory.trim() if (params.installCommand) body.installCommand = params.installCommand.trim() + if (params.rootDirectory) body.rootDirectory = params.rootDirectory.trim() + if (params.nodeVersion) body.nodeVersion = params.nodeVersion.trim() + if (params.devCommand) body.devCommand = params.devCommand.trim() return body }, }, diff --git a/apps/sim/tools/vercel/update_project_domain.ts b/apps/sim/tools/vercel/update_project_domain.ts index 050675160c2..76d3ca9b43b 100644 --- a/apps/sim/tools/vercel/update_project_domain.ts +++ b/apps/sim/tools/vercel/update_project_domain.ts @@ -56,12 +56,19 @@ export const vercelUpdateProjectDomainTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelUpdateProjectDomainParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/vercel/verify_project_domain.ts b/apps/sim/tools/vercel/verify_project_domain.ts index c1610018bbe..0ea5afe30fd 100644 --- a/apps/sim/tools/vercel/verify_project_domain.ts +++ b/apps/sim/tools/vercel/verify_project_domain.ts @@ -38,12 +38,19 @@ export const vercelVerifyProjectDomainTool: ToolConfig< visibility: 'user-or-llm', description: 'Team ID to scope the request', }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Team slug to scope the request (alternative to teamId)', + }, }, request: { url: (params: VercelVerifyProjectDomainParams) => { const query = new URLSearchParams() if (params.teamId) query.set('teamId', params.teamId.trim()) + if (params.slug) query.set('slug', params.slug.trim()) const qs = query.toString() return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}/verify${qs ? `?${qs}` : ''}` }, diff --git a/apps/sim/tools/wordpress/create_category.ts b/apps/sim/tools/wordpress/create_category.ts index 61bb4076a34..f787b60b186 100644 --- a/apps/sim/tools/wordpress/create_category.ts +++ b/apps/sim/tools/wordpress/create_category.ts @@ -66,7 +66,7 @@ export const createCategoryTool: ToolConfig< } if (params.description) body.description = params.description - if (params.parent) body.parent = params.parent + if (params.parent !== undefined) body.parent = params.parent if (params.slug) body.slug = params.slug return body diff --git a/apps/sim/tools/wordpress/create_comment.ts b/apps/sim/tools/wordpress/create_comment.ts index d4f473105f1..dd6fb5898b1 100644 --- a/apps/sim/tools/wordpress/create_comment.ts +++ b/apps/sim/tools/wordpress/create_comment.ts @@ -78,7 +78,7 @@ export const createCommentTool: ToolConfig< content: params.content, } - if (params.parent) body.parent = params.parent + if (params.parent !== undefined) body.parent = params.parent if (params.authorName) body.author_name = params.authorName if (params.authorEmail) body.author_email = params.authorEmail if (params.authorUrl) body.author_url = params.authorUrl diff --git a/apps/sim/tools/wordpress/create_page.ts b/apps/sim/tools/wordpress/create_page.ts index 161cd10fb8d..2a7f7658f74 100644 --- a/apps/sim/tools/wordpress/create_page.ts +++ b/apps/sim/tools/wordpress/create_page.ts @@ -90,9 +90,9 @@ export const createPageTool: ToolConfig = { + id: 'wordpress_delete_category', + name: 'WordPress Delete Category', + description: 'Delete a category from WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + categoryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the category to delete', + }, + }, + + request: { + url: (params) => { + // Terms do not support trashing, so force=true is required to delete. + return `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}?force=true` + }, + method: 'DELETE', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + deleted: data.deleted ?? true, + category: { + id: data.id ?? data.previous?.id, + count: data.count ?? data.previous?.count, + description: data.description || data.previous?.description, + link: data.link || data.previous?.link, + name: data.name || data.previous?.name, + slug: data.slug || data.previous?.slug, + taxonomy: data.taxonomy || data.previous?.taxonomy, + parent: data.parent ?? data.previous?.parent, + }, + }, + } + }, + + outputs: { + deleted: { + type: 'boolean', + description: 'Whether the category was deleted', + }, + category: { + type: 'object', + description: 'The deleted category', + properties: { + id: { type: 'number', description: 'Category ID' }, + count: { type: 'number', description: 'Number of posts in this category' }, + description: { type: 'string', description: 'Category description' }, + link: { type: 'string', description: 'Category archive URL' }, + name: { type: 'string', description: 'Category name' }, + slug: { type: 'string', description: 'Category slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + parent: { type: 'number', description: 'Parent category ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/delete_comment.ts b/apps/sim/tools/wordpress/delete_comment.ts index 5aec306e5d8..03d4b6081df 100644 --- a/apps/sim/tools/wordpress/delete_comment.ts +++ b/apps/sim/tools/wordpress/delete_comment.ts @@ -64,12 +64,12 @@ export const deleteCommentTool: ToolConfig< return { success: true, output: { - deleted: data.deleted || true, + deleted: data.deleted ?? true, comment: { - id: data.id || data.previous?.id, - post: data.post || data.previous?.post, - parent: data.parent || data.previous?.parent, - author: data.author || data.previous?.author, + id: data.id ?? data.previous?.id, + post: data.post ?? data.previous?.post, + parent: data.parent ?? data.previous?.parent, + author: data.author ?? data.previous?.author, author_name: data.author_name || data.previous?.author_name, author_email: data.author_email || data.previous?.author_email, author_url: data.author_url || data.previous?.author_url, diff --git a/apps/sim/tools/wordpress/delete_media.ts b/apps/sim/tools/wordpress/delete_media.ts index 3b680919e3d..0dfc8e0b1de 100644 --- a/apps/sim/tools/wordpress/delete_media.ts +++ b/apps/sim/tools/wordpress/delete_media.ts @@ -31,17 +31,11 @@ export const deleteMediaTool: ToolConfig { - // Media deletion requires force=true to actually delete + // Media has no trash — deletion always requires force=true to take effect return `${WORDPRESS_COM_API_BASE}/${params.siteId}/media/${params.mediaId}?force=true` }, method: 'DELETE', @@ -62,9 +56,9 @@ export const deleteMediaTool: ToolConfig = { + id: 'wordpress_delete_tag', + name: 'WordPress Delete Tag', + description: 'Delete a tag from WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + tagId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the tag to delete', + }, + }, + + request: { + url: (params) => { + // Terms do not support trashing, so force=true is required to delete. + return `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}?force=true` + }, + method: 'DELETE', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + deleted: data.deleted ?? true, + tag: { + id: data.id ?? data.previous?.id, + count: data.count ?? data.previous?.count, + description: data.description || data.previous?.description, + link: data.link || data.previous?.link, + name: data.name || data.previous?.name, + slug: data.slug || data.previous?.slug, + taxonomy: data.taxonomy || data.previous?.taxonomy, + }, + }, + } + }, + + outputs: { + deleted: { + type: 'boolean', + description: 'Whether the tag was deleted', + }, + tag: { + type: 'object', + description: 'The deleted tag', + properties: { + id: { type: 'number', description: 'Tag ID' }, + count: { type: 'number', description: 'Number of posts with this tag' }, + description: { type: 'string', description: 'Tag description' }, + link: { type: 'string', description: 'Tag archive URL' }, + name: { type: 'string', description: 'Tag name' }, + slug: { type: 'string', description: 'Tag slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/get_category.ts b/apps/sim/tools/wordpress/get_category.ts new file mode 100644 index 00000000000..968dafe7fbc --- /dev/null +++ b/apps/sim/tools/wordpress/get_category.ts @@ -0,0 +1,86 @@ +import type { ToolConfig } from '@/tools/types' +import { + WORDPRESS_COM_API_BASE, + type WordPressGetCategoryParams, + type WordPressGetCategoryResponse, +} from '@/tools/wordpress/types' + +export const getCategoryTool: ToolConfig = + { + id: 'wordpress_get_category', + name: 'WordPress Get Category', + description: 'Get a single category from WordPress.com by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + categoryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the category to retrieve', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}`, + method: 'GET', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + category: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + parent: data.parent, + }, + }, + } + }, + + outputs: { + category: { + type: 'object', + description: 'The retrieved category', + properties: { + id: { type: 'number', description: 'Category ID' }, + count: { type: 'number', description: 'Number of posts in this category' }, + description: { type: 'string', description: 'Category description' }, + link: { type: 'string', description: 'Category archive URL' }, + name: { type: 'string', description: 'Category name' }, + slug: { type: 'string', description: 'Category slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + parent: { type: 'number', description: 'Parent category ID' }, + }, + }, + }, + } diff --git a/apps/sim/tools/wordpress/get_tag.ts b/apps/sim/tools/wordpress/get_tag.ts new file mode 100644 index 00000000000..bc4645c4fcd --- /dev/null +++ b/apps/sim/tools/wordpress/get_tag.ts @@ -0,0 +1,83 @@ +import type { ToolConfig } from '@/tools/types' +import { + WORDPRESS_COM_API_BASE, + type WordPressGetTagParams, + type WordPressGetTagResponse, +} from '@/tools/wordpress/types' + +export const getTagTool: ToolConfig = { + id: 'wordpress_get_tag', + name: 'WordPress Get Tag', + description: 'Get a single tag from WordPress.com by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + tagId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the tag to retrieve', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}`, + method: 'GET', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + tag: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + }, + }, + } + }, + + outputs: { + tag: { + type: 'object', + description: 'The retrieved tag', + properties: { + id: { type: 'number', description: 'Tag ID' }, + count: { type: 'number', description: 'Number of posts with this tag' }, + description: { type: 'string', description: 'Tag description' }, + link: { type: 'string', description: 'Tag archive URL' }, + name: { type: 'string', description: 'Tag name' }, + slug: { type: 'string', description: 'Tag slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/index.ts b/apps/sim/tools/wordpress/index.ts index 3889208a544..ab48592b46d 100644 --- a/apps/sim/tools/wordpress/index.ts +++ b/apps/sim/tools/wordpress/index.ts @@ -4,14 +4,18 @@ import { createCommentTool } from '@/tools/wordpress/create_comment' import { createPageTool } from '@/tools/wordpress/create_page' import { createPostTool } from '@/tools/wordpress/create_post' import { createTagTool } from '@/tools/wordpress/create_tag' +import { deleteCategoryTool } from '@/tools/wordpress/delete_category' import { deleteCommentTool } from '@/tools/wordpress/delete_comment' import { deleteMediaTool } from '@/tools/wordpress/delete_media' import { deletePageTool } from '@/tools/wordpress/delete_page' import { deletePostTool } from '@/tools/wordpress/delete_post' +import { deleteTagTool } from '@/tools/wordpress/delete_tag' +import { getCategoryTool } from '@/tools/wordpress/get_category' import { getCurrentUserTool } from '@/tools/wordpress/get_current_user' import { getMediaTool } from '@/tools/wordpress/get_media' import { getPageTool } from '@/tools/wordpress/get_page' import { getPostTool } from '@/tools/wordpress/get_post' +import { getTagTool } from '@/tools/wordpress/get_tag' import { getUserTool } from '@/tools/wordpress/get_user' import { listCategoriesTool } from '@/tools/wordpress/list_categories' import { listCommentsTool } from '@/tools/wordpress/list_comments' @@ -21,9 +25,11 @@ import { listPostsTool } from '@/tools/wordpress/list_posts' import { listTagsTool } from '@/tools/wordpress/list_tags' import { listUsersTool } from '@/tools/wordpress/list_users' import { searchContentTool } from '@/tools/wordpress/search_content' +import { updateCategoryTool } from '@/tools/wordpress/update_category' import { updateCommentTool } from '@/tools/wordpress/update_comment' import { updatePageTool } from '@/tools/wordpress/update_page' import { updatePostTool } from '@/tools/wordpress/update_post' +import { updateTagTool } from '@/tools/wordpress/update_tag' import { uploadMediaTool } from '@/tools/wordpress/upload_media' // Post operations @@ -55,10 +61,16 @@ export const wordpressDeleteCommentTool = deleteCommentTool // Category operations export const wordpressCreateCategoryTool = createCategoryTool export const wordpressListCategoriesTool = listCategoriesTool +export const wordpressGetCategoryTool = getCategoryTool +export const wordpressUpdateCategoryTool = updateCategoryTool +export const wordpressDeleteCategoryTool = deleteCategoryTool // Tag operations export const wordpressCreateTagTool = createTagTool export const wordpressListTagsTool = listTagsTool +export const wordpressGetTagTool = getTagTool +export const wordpressUpdateTagTool = updateTagTool +export const wordpressDeleteTagTool = deleteTagTool // User operations export const wordpressGetCurrentUserTool = getCurrentUserTool diff --git a/apps/sim/tools/wordpress/search_content.ts b/apps/sim/tools/wordpress/search_content.ts index 2d20340810c..706b510991b 100644 --- a/apps/sim/tools/wordpress/search_content.ts +++ b/apps/sim/tools/wordpress/search_content.ts @@ -36,26 +36,27 @@ export const searchContentTool: ToolConfig< perPage: { type: 'number', required: false, - visibility: 'user-only', + visibility: 'user-or-llm', description: 'Number of results per request (default: 10, max: 100)', }, page: { type: 'number', required: false, - visibility: 'user-only', + visibility: 'user-or-llm', description: 'Page number for pagination', }, type: { type: 'string', required: false, visibility: 'user-only', - description: 'Filter by content type: post, page, attachment', + description: 'Filter by search index type: post, term, or post-format', }, subtype: { type: 'string', required: false, visibility: 'user-only', - description: 'Filter by post type slug (e.g., post, page)', + description: + 'Filter by subtype within the selected type (e.g., post or page when type is post)', }, }, @@ -114,8 +115,11 @@ export const searchContentTool: ToolConfig< id: { type: 'number', description: 'Content ID' }, title: { type: 'string', description: 'Content title' }, url: { type: 'string', description: 'Content URL' }, - type: { type: 'string', description: 'Content type (post, page, attachment)' }, - subtype: { type: 'string', description: 'Post type slug' }, + type: { type: 'string', description: 'Content type (post, term, or post-format)' }, + subtype: { + type: 'string', + description: 'Subtype within the content type (e.g., post, page)', + }, }, }, }, diff --git a/apps/sim/tools/wordpress/types.ts b/apps/sim/tools/wordpress/types.ts index 81bc115e414..146b72905c1 100644 --- a/apps/sim/tools/wordpress/types.ts +++ b/apps/sim/tools/wordpress/types.ts @@ -325,7 +325,6 @@ export interface WordPressListMediaResponse extends ToolResponse { // Delete Media export interface WordPressDeleteMediaParams extends WordPressBaseParams { mediaId: number - force?: boolean } export interface WordPressDeleteMediaResponse extends ToolResponse { @@ -472,6 +471,44 @@ export interface WordPressListCategoriesResponse extends ToolResponse { } } +// Get Category +export interface WordPressGetCategoryParams extends WordPressBaseParams { + categoryId: number +} + +export interface WordPressGetCategoryResponse extends ToolResponse { + output: { + category: WordPressCategory + } +} + +// Update Category +export interface WordPressUpdateCategoryParams extends WordPressBaseParams { + categoryId: number + name?: string + description?: string + parent?: number + slug?: string +} + +export interface WordPressUpdateCategoryResponse extends ToolResponse { + output: { + category: WordPressCategory + } +} + +// Delete Category +export interface WordPressDeleteCategoryParams extends WordPressBaseParams { + categoryId: number +} + +export interface WordPressDeleteCategoryResponse extends ToolResponse { + output: { + deleted: boolean + category: WordPressCategory + } +} + // Create Tag export interface WordPressCreateTagParams extends WordPressBaseParams { name: string @@ -511,6 +548,43 @@ export interface WordPressListTagsResponse extends ToolResponse { } } +// Get Tag +export interface WordPressGetTagParams extends WordPressBaseParams { + tagId: number +} + +export interface WordPressGetTagResponse extends ToolResponse { + output: { + tag: WordPressTag + } +} + +// Update Tag +export interface WordPressUpdateTagParams extends WordPressBaseParams { + tagId: number + name?: string + description?: string + slug?: string +} + +export interface WordPressUpdateTagResponse extends ToolResponse { + output: { + tag: WordPressTag + } +} + +// Delete Tag +export interface WordPressDeleteTagParams extends WordPressBaseParams { + tagId: number +} + +export interface WordPressDeleteTagResponse extends ToolResponse { + output: { + deleted: boolean + tag: WordPressTag + } +} + // ============================================ // USER OPERATIONS // ============================================ @@ -576,7 +650,7 @@ export interface WordPressSearchContentParams extends WordPressBaseParams { query: string perPage?: number page?: number - type?: 'post' | 'page' | 'attachment' + type?: 'post' | 'term' | 'post-format' subtype?: string } @@ -620,8 +694,14 @@ export type WordPressResponse = | WordPressDeleteCommentResponse | WordPressCreateCategoryResponse | WordPressListCategoriesResponse + | WordPressGetCategoryResponse + | WordPressUpdateCategoryResponse + | WordPressDeleteCategoryResponse | WordPressCreateTagResponse | WordPressListTagsResponse + | WordPressGetTagResponse + | WordPressUpdateTagResponse + | WordPressDeleteTagResponse | WordPressGetCurrentUserResponse | WordPressListUsersResponse | WordPressGetUserResponse diff --git a/apps/sim/tools/wordpress/update_category.ts b/apps/sim/tools/wordpress/update_category.ts new file mode 100644 index 00000000000..cbb47510c21 --- /dev/null +++ b/apps/sim/tools/wordpress/update_category.ts @@ -0,0 +1,122 @@ +import type { ToolConfig } from '@/tools/types' +import { + WORDPRESS_COM_API_BASE, + type WordPressUpdateCategoryParams, + type WordPressUpdateCategoryResponse, +} from '@/tools/wordpress/types' + +export const updateCategoryTool: ToolConfig< + WordPressUpdateCategoryParams, + WordPressUpdateCategoryResponse +> = { + id: 'wordpress_update_category', + name: 'WordPress Update Category', + description: 'Update an existing category in WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + categoryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the category to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Category name', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Category description', + }, + parent: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Parent category ID for hierarchical categories', + }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL slug for the category', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}`, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + body: (params) => { + const body: Record = {} + + if (params.name) body.name = params.name + if (params.description) body.description = params.description + if (params.parent !== undefined) body.parent = params.parent + if (params.slug) body.slug = params.slug + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + category: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + parent: data.parent, + }, + }, + } + }, + + outputs: { + category: { + type: 'object', + description: 'The updated category', + properties: { + id: { type: 'number', description: 'Category ID' }, + count: { type: 'number', description: 'Number of posts in this category' }, + description: { type: 'string', description: 'Category description' }, + link: { type: 'string', description: 'Category archive URL' }, + name: { type: 'string', description: 'Category name' }, + slug: { type: 'string', description: 'Category slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + parent: { type: 'number', description: 'Parent category ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/update_page.ts b/apps/sim/tools/wordpress/update_page.ts index 37d3dc1d0fb..0c7fe51b79b 100644 --- a/apps/sim/tools/wordpress/update_page.ts +++ b/apps/sim/tools/wordpress/update_page.ts @@ -97,7 +97,7 @@ export const updatePageTool: ToolConfig = { + id: 'wordpress_update_tag', + name: 'WordPress Update Tag', + description: 'Update an existing tag in WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + tagId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the tag to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag name', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag description', + }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL slug for the tag', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}`, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + body: (params) => { + const body: Record = {} + + if (params.name) body.name = params.name + if (params.description) body.description = params.description + if (params.slug) body.slug = params.slug + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + tag: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + }, + }, + } + }, + + outputs: { + tag: { + type: 'object', + description: 'The updated tag', + properties: { + id: { type: 'number', description: 'Tag ID' }, + count: { type: 'number', description: 'Number of posts with this tag' }, + description: { type: 'string', description: 'Tag description' }, + link: { type: 'string', description: 'Tag archive URL' }, + name: { type: 'string', description: 'Tag name' }, + slug: { type: 'string', description: 'Tag slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/workflow/executor.test.ts b/apps/sim/tools/workflow/executor.test.ts index f1c5ca56bd5..90b08329e1a 100644 --- a/apps/sim/tools/workflow/executor.test.ts +++ b/apps/sim/tools/workflow/executor.test.ts @@ -52,6 +52,39 @@ describe('workflowExecutorTool', () => { }) }) + it.concurrent('should declare parentWorkspaceId when the context has a workspace', () => { + const params = { + workflowId: 'test-workflow-id', + inputMapping: { name: 'Test' }, + _context: { workspaceId: 'workspace-parent' }, + } + + const result = buildBody(params) + + expect(result).toEqual({ + input: { name: 'Test' }, + triggerType: 'workflow', + useDraftState: true, + parentWorkspaceId: 'workspace-parent', + }) + }) + + it.concurrent('should omit parentWorkspaceId when the context has no workspace', () => { + const params = { + workflowId: 'test-workflow-id', + inputMapping: { name: 'Test' }, + _context: { isDeployedContext: true }, + } + + const result = buildBody(params) + + expect(result).toEqual({ + input: { name: 'Test' }, + triggerType: 'workflow', + useDraftState: false, + }) + }) + it.concurrent('should parse JSON string inputMapping (UI-provided via tool-input)', () => { const params = { workflowId: 'test-workflow-id', diff --git a/apps/sim/tools/workflow/executor.ts b/apps/sim/tools/workflow/executor.ts index 95bfa1ff52b..6abc33eed29 100644 --- a/apps/sim/tools/workflow/executor.ts +++ b/apps/sim/tools/workflow/executor.ts @@ -44,10 +44,12 @@ export const workflowExecutorTool: ToolConfig< } // Use draft state for manual runs (not deployed), deployed state for deployed runs const isDeployedContext = params._context?.isDeployedContext + const parentWorkspaceId = params._context?.workspaceId return { input: inputData, triggerType: 'workflow', useDraftState: !isDeployedContext, + ...(parentWorkspaceId ? { parentWorkspaceId } : {}), } }, }, diff --git a/apps/sim/triggers/clerk/index.ts b/apps/sim/triggers/clerk/index.ts index 88eb2a8acd3..55f149730e8 100644 --- a/apps/sim/triggers/clerk/index.ts +++ b/apps/sim/triggers/clerk/index.ts @@ -1,6 +1,13 @@ export { clerkOrganizationCreatedTrigger } from './organization_created' +export { clerkOrganizationDeletedTrigger } from './organization_deleted' export { clerkOrganizationMembershipCreatedTrigger } from './organization_membership_created' +export { clerkOrganizationMembershipDeletedTrigger } from './organization_membership_deleted' +export { clerkOrganizationMembershipUpdatedTrigger } from './organization_membership_updated' +export { clerkOrganizationUpdatedTrigger } from './organization_updated' export { clerkSessionCreatedTrigger } from './session_created' +export { clerkSessionEndedTrigger } from './session_ended' +export { clerkSessionRemovedTrigger } from './session_removed' +export { clerkSessionRevokedTrigger } from './session_revoked' export { clerkUserCreatedTrigger } from './user_created' export { clerkUserDeletedTrigger } from './user_deleted' export { clerkUserUpdatedTrigger } from './user_updated' diff --git a/apps/sim/triggers/clerk/organization_deleted.ts b/apps/sim/triggers/clerk/organization_deleted.ts new file mode 100644 index 00000000000..3ce87d4442d --- /dev/null +++ b/apps/sim/triggers/clerk/organization_deleted.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationDeletedOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Deleted Trigger. + * Triggers when an organization is deleted. + */ +export const clerkOrganizationDeletedTrigger: TriggerConfig = { + id: 'clerk_organization_deleted', + name: 'Clerk Organization Deleted', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization is deleted', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_deleted', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organization.deleted'), + extraFields: buildClerkExtraFields('clerk_organization_deleted'), + }), + + outputs: buildOrganizationDeletedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/organization_membership_deleted.ts b/apps/sim/triggers/clerk/organization_membership_deleted.ts new file mode 100644 index 00000000000..9f8462edffc --- /dev/null +++ b/apps/sim/triggers/clerk/organization_membership_deleted.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationMembershipDeletedOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Membership Deleted Trigger. + * Triggers when a member is removed from an organization. + */ +export const clerkOrganizationMembershipDeletedTrigger: TriggerConfig = { + id: 'clerk_organization_membership_deleted', + name: 'Clerk Organization Membership Deleted', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization membership is deleted', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_membership_deleted', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organizationMembership.deleted'), + extraFields: buildClerkExtraFields('clerk_organization_membership_deleted'), + }), + + outputs: buildOrganizationMembershipDeletedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/organization_membership_updated.ts b/apps/sim/triggers/clerk/organization_membership_updated.ts new file mode 100644 index 00000000000..5e41e9e336c --- /dev/null +++ b/apps/sim/triggers/clerk/organization_membership_updated.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationMembershipOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Membership Updated Trigger. + * Triggers when a member's role within an organization changes. + */ +export const clerkOrganizationMembershipUpdatedTrigger: TriggerConfig = { + id: 'clerk_organization_membership_updated', + name: 'Clerk Organization Membership Updated', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization membership is updated', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_membership_updated', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organizationMembership.updated'), + extraFields: buildClerkExtraFields('clerk_organization_membership_updated'), + }), + + outputs: buildOrganizationMembershipOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/organization_updated.ts b/apps/sim/triggers/clerk/organization_updated.ts new file mode 100644 index 00000000000..feda7b46f92 --- /dev/null +++ b/apps/sim/triggers/clerk/organization_updated.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Updated Trigger. + * Triggers when an organization's details are updated. + */ +export const clerkOrganizationUpdatedTrigger: TriggerConfig = { + id: 'clerk_organization_updated', + name: 'Clerk Organization Updated', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization is updated', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_updated', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organization.updated'), + extraFields: buildClerkExtraFields('clerk_organization_updated'), + }), + + outputs: buildOrganizationOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/session_ended.ts b/apps/sim/triggers/clerk/session_ended.ts new file mode 100644 index 00000000000..faa59ddd245 --- /dev/null +++ b/apps/sim/triggers/clerk/session_ended.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildSessionOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Session Ended Trigger. + * Triggers when a user signs out and the session ends. + */ +export const clerkSessionEndedTrigger: TriggerConfig = { + id: 'clerk_session_ended', + name: 'Clerk Session Ended', + provider: 'clerk', + description: 'Trigger workflow when a Clerk session ends', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_session_ended', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('session.ended'), + extraFields: buildClerkExtraFields('clerk_session_ended'), + }), + + outputs: buildSessionOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/session_removed.ts b/apps/sim/triggers/clerk/session_removed.ts new file mode 100644 index 00000000000..c19c8fad1e2 --- /dev/null +++ b/apps/sim/triggers/clerk/session_removed.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildSessionOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Session Removed Trigger. + * Triggers when a session is removed, e.g. because the associated user was deleted. + */ +export const clerkSessionRemovedTrigger: TriggerConfig = { + id: 'clerk_session_removed', + name: 'Clerk Session Removed', + provider: 'clerk', + description: 'Trigger workflow when a Clerk session is removed', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_session_removed', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('session.removed'), + extraFields: buildClerkExtraFields('clerk_session_removed'), + }), + + outputs: buildSessionOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/session_revoked.ts b/apps/sim/triggers/clerk/session_revoked.ts new file mode 100644 index 00000000000..0f5d1b661ab --- /dev/null +++ b/apps/sim/triggers/clerk/session_revoked.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildSessionOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Session Revoked Trigger. + * Triggers when a session is revoked, e.g. via the Revoke Session API or Dashboard. + */ +export const clerkSessionRevokedTrigger: TriggerConfig = { + id: 'clerk_session_revoked', + name: 'Clerk Session Revoked', + provider: 'clerk', + description: 'Trigger workflow when a Clerk session is revoked', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_session_revoked', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('session.revoked'), + extraFields: buildClerkExtraFields('clerk_session_revoked'), + }), + + outputs: buildSessionOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/utils.ts b/apps/sim/triggers/clerk/utils.ts index e1a4f733288..38772722027 100644 --- a/apps/sim/triggers/clerk/utils.ts +++ b/apps/sim/triggers/clerk/utils.ts @@ -12,8 +12,15 @@ export const CLERK_TRIGGER_TO_EVENT_TYPE: Record = { clerk_user_updated: 'user.updated', clerk_user_deleted: 'user.deleted', clerk_session_created: 'session.created', + clerk_session_ended: 'session.ended', + clerk_session_removed: 'session.removed', + clerk_session_revoked: 'session.revoked', clerk_organization_created: 'organization.created', + clerk_organization_updated: 'organization.updated', + clerk_organization_deleted: 'organization.deleted', clerk_organization_membership_created: 'organizationMembership.created', + clerk_organization_membership_updated: 'organizationMembership.updated', + clerk_organization_membership_deleted: 'organizationMembership.deleted', } /** @@ -24,8 +31,15 @@ export const clerkTriggerOptions = [ { label: 'User Updated', id: 'clerk_user_updated' }, { label: 'User Deleted', id: 'clerk_user_deleted' }, { label: 'Session Created', id: 'clerk_session_created' }, + { label: 'Session Ended', id: 'clerk_session_ended' }, + { label: 'Session Removed', id: 'clerk_session_removed' }, + { label: 'Session Revoked', id: 'clerk_session_revoked' }, { label: 'Organization Created', id: 'clerk_organization_created' }, + { label: 'Organization Updated', id: 'clerk_organization_updated' }, + { label: 'Organization Deleted', id: 'clerk_organization_deleted' }, { label: 'Organization Membership Created', id: 'clerk_organization_membership_created' }, + { label: 'Organization Membership Updated', id: 'clerk_organization_membership_updated' }, + { label: 'Organization Membership Deleted', id: 'clerk_organization_membership_deleted' }, { label: 'Generic Webhook (All Events)', id: 'clerk_webhook' }, ] @@ -159,7 +173,7 @@ export function buildOrganizationOutputs(): Record { } /** - * Build outputs for `organizationMembership.created` events. + * Build outputs for `organizationMembership.created` and `.updated` events. * The `data` object is the Clerk OrganizationMembership object. */ export function buildOrganizationMembershipOutputs(): Record { @@ -179,6 +193,33 @@ export function buildOrganizationMembershipOutputs(): Record { + return { + ...commonEventOutputs, + organizationId: { type: 'string', description: 'Deleted Clerk organization ID (data.id)' }, + deleted: { + type: 'boolean', + description: 'Whether the organization was deleted (data.deleted)', + }, + } +} + +/** + * Build outputs for `organizationMembership.deleted` events. + * The `data` object is a deleted-object marker: `{ id, deleted, object }`. + */ +export function buildOrganizationMembershipDeletedOutputs(): Record { + return { + ...commonEventOutputs, + membershipId: { type: 'string', description: 'Deleted membership ID (data.id)' }, + deleted: { type: 'boolean', description: 'Whether the membership was deleted (data.deleted)' }, + } +} + /** * Build outputs for the generic webhook (all events). * Only the fields common to every Clerk event are guaranteed; use `data` diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index c13704ed89f..67416d77974 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -60,8 +60,15 @@ import { } from '@/triggers/circleback' import { clerkOrganizationCreatedTrigger, + clerkOrganizationDeletedTrigger, clerkOrganizationMembershipCreatedTrigger, + clerkOrganizationMembershipDeletedTrigger, + clerkOrganizationMembershipUpdatedTrigger, + clerkOrganizationUpdatedTrigger, clerkSessionCreatedTrigger, + clerkSessionEndedTrigger, + clerkSessionRemovedTrigger, + clerkSessionRevokedTrigger, clerkUserCreatedTrigger, clerkUserDeletedTrigger, clerkUserUpdatedTrigger, @@ -724,8 +731,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { clerk_user_updated: clerkUserUpdatedTrigger, clerk_user_deleted: clerkUserDeletedTrigger, clerk_session_created: clerkSessionCreatedTrigger, + clerk_session_ended: clerkSessionEndedTrigger, + clerk_session_removed: clerkSessionRemovedTrigger, + clerk_session_revoked: clerkSessionRevokedTrigger, clerk_organization_created: clerkOrganizationCreatedTrigger, + clerk_organization_updated: clerkOrganizationUpdatedTrigger, + clerk_organization_deleted: clerkOrganizationDeletedTrigger, clerk_organization_membership_created: clerkOrganizationMembershipCreatedTrigger, + clerk_organization_membership_updated: clerkOrganizationMembershipUpdatedTrigger, + clerk_organization_membership_deleted: clerkOrganizationMembershipDeletedTrigger, clerk_webhook: clerkWebhookTrigger, incidentio_incident_created: incidentioIncidentCreatedTrigger, incidentio_incident_updated: incidentioIncidentUpdatedTrigger, diff --git a/bun.lock b/bun.lock index cc225c5ee5c..f26ccca3066 100644 --- a/bun.lock +++ b/bun.lock @@ -208,7 +208,7 @@ "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", - "echarts": "6.0.0", + "echarts": "6.1.0", "es-toolkit": "1.45.1", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", @@ -2439,7 +2439,7 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "echarts": ["echarts@6.0.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" } }, "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ=="], + "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -4043,7 +4043,7 @@ "zod-validation-error": ["zod-validation-error@1.5.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw=="], - "zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="], + "zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="], "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0d0d94230aa..4858463d4a5 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 882, - zodRoutes: 882, + totalRoutes: 884, + zodRoutes: 884, nonZodRoutes: 0, } as const @@ -25,7 +25,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookRawFetches: 0, clientSameOriginApiFetches: 0, doubleCasts: 8, - rawJsonReads: 21, + rawJsonReads: 8, untypedResponses: 0, annotationsMissingReason: 0, } as const