Compare commits

..

2 Commits

Author SHA1 Message Date
Emir Karabeg
1e9394706f addressed comments 2026-01-28 18:08:00 -08:00
Emir Karabeg
c426fd4d4c feat(ee): access control, sso 2026-01-28 17:57:04 -08:00
69 changed files with 554 additions and 11898 deletions

View File

@@ -26,41 +26,78 @@ In Sim, the YouTube integration enables your agents to programmatically search a
## Usage Instructions ## Usage Instructions
Integrate YouTube into the workflow. Can search for videos, get trending videos, get video details, get video captions, get video categories, get channel information, get all videos from a channel, get channel playlists, get playlist items, and get video comments. Integrate YouTube into the workflow. Can search for videos, get video details, get channel information, get all videos from a channel, get channel playlists, get playlist items, find related videos, and get video comments.
## Tools ## Tools
### `youtube_captions` ### `youtube_search`
List available caption tracks (subtitles/transcripts) for a YouTube video. Returns information about each caption including language, type, and whether it is auto-generated. Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, and more.
#### Input #### Input
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID to get captions for | | `query` | string | Yes | Search query for YouTube videos |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `apiKey` | string | Yes | YouTube API Key |
| `channelId` | string | No | Filter results to a specific YouTube channel ID |
| `publishedAfter` | string | No | Only return videos published after this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `publishedBefore` | string | No | Only return videos published before this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `videoDuration` | string | No | Filter by video length: "short" \(<4 min\), "medium" \(4-20 min\), "long" \(>20 min\), "any" |
| `order` | string | No | Sort results by: "date", "rating", "relevance" \(default\), "title", "videoCount", "viewCount" |
| `videoCategoryId` | string | No | Filter by YouTube category ID \(e.g., "10" for Music, "20" for Gaming\) |
| `videoDefinition` | string | No | Filter by video quality: "high" \(HD\), "standard", "any" |
| `videoCaption` | string | No | Filter by caption availability: "closedCaption" \(has captions\), "none" \(no captions\), "any" |
| `regionCode` | string | No | Return results relevant to a specific region \(ISO 3166-1 alpha-2 country code, e.g., "US", "GB"\) |
| `relevanceLanguage` | string | No | Return results most relevant to a language \(ISO 639-1 code, e.g., "en", "es"\) |
| `safeSearch` | string | No | Content filtering level: "moderate" \(default\), "none", "strict" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of YouTube videos matching the search query |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| `totalResults` | number | Total number of search results available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_video_details`
Get detailed information about a specific YouTube video.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `apiKey` | string | Yes | YouTube API Key | | `apiKey` | string | Yes | YouTube API Key |
#### Output #### Output
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `items` | array | Array of available caption tracks for the video | | `videoId` | string | YouTube video ID |
| ↳ `captionId` | string | Caption track ID | | `title` | string | Video title |
| ↳ `language` | string | Language code of the caption \(e.g., | | `description` | string | Video description |
| ↳ `name` | string | Name/label of the caption track | | `channelId` | string | Channel ID |
| ↳ `trackKind` | string | Type of caption track: | | `channelTitle` | string | Channel name |
| ↳ `lastUpdated` | string | When the caption was last updated | | `publishedAt` | string | Published date and time |
| ↳ `isCC` | boolean | Whether this is a closed caption track | | `duration` | string | Video duration in ISO 8601 format |
| ↳ `isAutoSynced` | boolean | Whether the caption timing was automatically synced | | `viewCount` | number | Number of views |
| ↳ `audioTrackType` | string | Type of audio track this caption is for | | `likeCount` | number | Number of likes |
| `totalResults` | number | Total number of caption tracks available | | `commentCount` | number | Number of comments |
| `thumbnail` | string | Video thumbnail URL |
| `tags` | array | Video tags |
### `youtube_channel_info` ### `youtube_channel_info`
Get detailed information about a YouTube channel including statistics, branding, and content details. Get detailed information about a YouTube channel.
#### Input #### Input
@@ -77,20 +114,43 @@ Get detailed information about a YouTube channel including statistics, branding,
| `channelId` | string | YouTube channel ID | | `channelId` | string | YouTube channel ID |
| `title` | string | Channel name | | `title` | string | Channel name |
| `description` | string | Channel description | | `description` | string | Channel description |
| `subscriberCount` | number | Number of subscribers \(0 if hidden\) | | `subscriberCount` | number | Number of subscribers |
| `videoCount` | number | Number of public videos | | `videoCount` | number | Number of videos |
| `viewCount` | number | Total channel views | | `viewCount` | number | Total channel views |
| `publishedAt` | string | Channel creation date | | `publishedAt` | string | Channel creation date |
| `thumbnail` | string | Channel thumbnail/avatar URL | | `thumbnail` | string | Channel thumbnail URL |
| `customUrl` | string | Channel custom URL \(handle\) | | `customUrl` | string | Channel custom URL |
| `country` | string | Country the channel is associated with |
| `uploadsPlaylistId` | string | Playlist ID containing all channel uploads \(use with playlist_items\) | ### `youtube_channel_videos`
| `bannerImageUrl` | string | Channel banner image URL |
| `hiddenSubscriberCount` | boolean | Whether the subscriber count is hidden | Get all videos from a specific YouTube channel, with sorting options.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `channelId` | string | Yes | YouTube channel ID to get videos from |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `order` | string | No | Sort order: "date" \(newest first\), "rating", "relevance", "title", "viewCount" |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of videos from the channel |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Video publish date |
| `totalResults` | number | Total number of videos in the channel |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_channel_playlists` ### `youtube_channel_playlists`
Get all public playlists from a specific YouTube channel. Get all playlists from a specific YouTube channel.
#### Input #### Input
@@ -112,80 +172,19 @@ Get all public playlists from a specific YouTube channel.
| ↳ `thumbnail` | string | Playlist thumbnail URL | | ↳ `thumbnail` | string | Playlist thumbnail URL |
| ↳ `itemCount` | number | Number of videos in playlist | | ↳ `itemCount` | number | Number of videos in playlist |
| ↳ `publishedAt` | string | Playlist creation date | | ↳ `publishedAt` | string | Playlist creation date |
| ↳ `channelTitle` | string | Channel name |
| `totalResults` | number | Total number of playlists in the channel | | `totalResults` | number | Total number of playlists in the channel |
| `nextPageToken` | string | Token for accessing the next page of results | | `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_channel_videos`
Search for videos from a specific YouTube channel with sorting options. For complete channel video list, use channel_info to get uploadsPlaylistId, then use playlist_items.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `channelId` | string | Yes | YouTube channel ID to get videos from |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `order` | string | No | Sort order: "date" \(newest first, default\), "rating", "relevance", "title", "viewCount" |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of videos from the channel |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `channelTitle` | string | Channel name |
| `totalResults` | number | Total number of videos in the channel |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_comments`
Get top-level comments from a YouTube video with author details and engagement.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `maxResults` | number | No | Maximum number of comments to return \(1-100\) |
| `order` | string | No | Order of comments: "time" \(newest first\) or "relevance" \(most relevant first\) |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of top-level comments from the video |
| ↳ `commentId` | string | Comment ID |
| ↳ `authorDisplayName` | string | Comment author display name |
| ↳ `authorChannelUrl` | string | Comment author channel URL |
| ↳ `authorProfileImageUrl` | string | Comment author profile image URL |
| ↳ `textDisplay` | string | Comment text \(HTML formatted\) |
| ↳ `textOriginal` | string | Comment text \(plain text\) |
| ↳ `likeCount` | number | Number of likes on the comment |
| ↳ `publishedAt` | string | When the comment was posted |
| ↳ `updatedAt` | string | When the comment was last edited |
| ↳ `replyCount` | number | Number of replies to this comment |
| `totalResults` | number | Total number of comment threads available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_playlist_items` ### `youtube_playlist_items`
Get videos from a YouTube playlist. Can be used with a channel uploads playlist to get all channel videos. Get videos from a YouTube playlist.
#### Input #### Input
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `playlistId` | string | Yes | YouTube playlist ID. Use uploadsPlaylistId from channel_info to get all channel videos. | | `playlistId` | string | Yes | YouTube playlist ID |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) | | `maxResults` | number | No | Maximum number of videos to return |
| `pageToken` | string | No | Page token for pagination | | `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key | | `apiKey` | string | Yes | YouTube API Key |
@@ -199,65 +198,22 @@ Get videos from a YouTube playlist. Can be used with a channel uploads playlist
| ↳ `description` | string | Video description | | ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL | | ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Date added to playlist | | ↳ `publishedAt` | string | Date added to playlist |
| ↳ `channelTitle` | string | Playlist owner channel name | | ↳ `channelTitle` | string | Channel name |
| ↳ `position` | number | Position in playlist \(0-indexed\) | | ↳ `position` | number | Position in playlist |
| ↳ `videoOwnerChannelId` | string | Channel ID of the video owner |
| ↳ `videoOwnerChannelTitle` | string | Channel name of the video owner |
| `totalResults` | number | Total number of items in playlist | | `totalResults` | number | Total number of items in playlist |
| `nextPageToken` | string | Token for accessing the next page of results | | `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_search` ### `youtube_comments`
Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, live streams, and more. Get comments from a YouTube video.
#### Input #### Input
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `query` | string | Yes | Search query for YouTube videos | | `videoId` | string | Yes | YouTube video ID |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) | | `maxResults` | number | No | Maximum number of comments to return |
| `pageToken` | string | No | Page token for pagination \(use nextPageToken from previous response\) | | `order` | string | No | Order of comments: time or relevance |
| `apiKey` | string | Yes | YouTube API Key |
| `channelId` | string | No | Filter results to a specific YouTube channel ID |
| `publishedAfter` | string | No | Only return videos published after this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `publishedBefore` | string | No | Only return videos published before this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `videoDuration` | string | No | Filter by video length: "short" \(<4 min\), "medium" \(4-20 min\), "long" \(>20 min\), "any" |
| `order` | string | No | Sort results by: "date", "rating", "relevance" \(default\), "title", "videoCount", "viewCount" |
| `videoCategoryId` | string | No | Filter by YouTube category ID \(e.g., "10" for Music, "20" for Gaming\). Use video_categories to list IDs. |
| `videoDefinition` | string | No | Filter by video quality: "high" \(HD\), "standard", "any" |
| `videoCaption` | string | No | Filter by caption availability: "closedCaption" \(has captions\), "none" \(no captions\), "any" |
| `eventType` | string | No | Filter by live broadcast status: "live" \(currently live\), "upcoming" \(scheduled\), "completed" \(past streams\) |
| `regionCode` | string | No | Return results relevant to a specific region \(ISO 3166-1 alpha-2 country code, e.g., "US", "GB"\) |
| `relevanceLanguage` | string | No | Return results most relevant to a language \(ISO 639-1 code, e.g., "en", "es"\) |
| `safeSearch` | string | No | Content filtering level: "moderate" \(default\), "none", "strict" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of YouTube videos matching the search query |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `channelId` | string | Channel ID that uploaded the video |
| ↳ `channelTitle` | string | Channel name |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `liveBroadcastContent` | string | Live broadcast status: |
| `totalResults` | number | Total number of search results available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_trending`
Get the most popular/trending videos on YouTube. Can filter by region and video category.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code to get trending videos for \(e.g., "US", "GB", "JP"\). Defaults to US. |
| `videoCategoryId` | string | No | Filter by video category ID \(e.g., "10" for Music, "20" for Gaming, "17" for Sports\) |
| `maxResults` | number | No | Maximum number of trending videos to return \(1-50\) |
| `pageToken` | string | No | Page token for pagination | | `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key | | `apiKey` | string | Yes | YouTube API Key |
@@ -265,84 +221,17 @@ Get the most popular/trending videos on YouTube. Can filter by region and video
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `items` | array | Array of trending videos | | `items` | array | Array of comments from the video |
| ↳ `videoId` | string | YouTube video ID | | ↳ `commentId` | string | Comment ID |
| ↳ `title` | string | Video title | | ↳ `authorDisplayName` | string | Comment author name |
| ↳ `description` | string | Video description | | ↳ `authorChannelUrl` | string | Comment author channel URL |
| ↳ `thumbnail` | string | Video thumbnail URL | | ↳ `textDisplay` | string | Comment text \(HTML formatted\) |
| ↳ `channelId` | string | Channel ID | | ↳ `textOriginal` | string | Comment text \(plain text\) |
| ↳ `channelTitle` | string | Channel name |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `viewCount` | number | Number of views |
| ↳ `likeCount` | number | Number of likes | | ↳ `likeCount` | number | Number of likes |
| ↳ `commentCount` | number | Number of comments | | ↳ `publishedAt` | string | Comment publish date |
| ↳ `duration` | string | Video duration in ISO 8601 format | | ↳ `updatedAt` | string | Comment last updated date |
| `totalResults` | number | Total number of trending videos available | | ↳ `replyCount` | number | Number of replies |
| `totalResults` | number | Total number of comments |
| `nextPageToken` | string | Token for accessing the next page of results | | `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_video_categories`
Get a list of video categories available on YouTube. Use this to discover valid category IDs for filtering search and trending results.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code to get categories for \(e.g., "US", "GB", "JP"\). Defaults to US. |
| `hl` | string | No | Language for category titles \(e.g., "en", "es", "fr"\). Defaults to English. |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of video categories available in the specified region |
| ↳ `categoryId` | string | Category ID to use in search/trending filters \(e.g., |
| ↳ `title` | string | Human-readable category name |
| ↳ `assignable` | boolean | Whether videos can be tagged with this category |
| `totalResults` | number | Total number of categories available |
### `youtube_video_details`
Get detailed information about a specific YouTube video including statistics, content details, live streaming info, and metadata.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `videoId` | string | YouTube video ID |
| `title` | string | Video title |
| `description` | string | Video description |
| `channelId` | string | Channel ID |
| `channelTitle` | string | Channel name |
| `publishedAt` | string | Published date and time |
| `duration` | string | Video duration in ISO 8601 format \(e.g., |
| `viewCount` | number | Number of views |
| `likeCount` | number | Number of likes |
| `commentCount` | number | Number of comments |
| `favoriteCount` | number | Number of times added to favorites |
| `thumbnail` | string | Video thumbnail URL |
| `tags` | array | Video tags |
| `categoryId` | string | YouTube video category ID |
| `definition` | string | Video definition: |
| `caption` | string | Whether captions are available: |
| `licensedContent` | boolean | Whether the video is licensed content |
| `privacyStatus` | string | Video privacy status: |
| `liveBroadcastContent` | string | Live broadcast status: |
| `defaultLanguage` | string | Default language of the video metadata |
| `defaultAudioLanguage` | string | Default audio language of the video |
| `isLiveContent` | boolean | Whether this video is or was a live stream |
| `scheduledStartTime` | string | Scheduled start time for upcoming live streams \(ISO 8601\) |
| `actualStartTime` | string | When the live stream actually started \(ISO 8601\) |
| `actualEndTime` | string | When the live stream ended \(ISO 8601\) |
| `concurrentViewers` | number | Current number of viewers \(only for active live streams\) |
| `activeLiveChatId` | string | Live chat ID for the stream \(only for active live streams\) |

View File

@@ -56,7 +56,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
deploymentVersionName: workflowDeploymentVersion.name, deploymentVersionName: workflowDeploymentVersion.name,
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin( .leftJoin(
workflowDeploymentVersion, workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
@@ -65,7 +65,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
permissions, permissions,
and( and(
eq(permissions.entityType, 'workspace'), eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflowExecutionLogs.workspaceId), eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId) eq(permissions.userId, userId)
) )
) )
@@ -77,19 +77,17 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ error: 'Not found' }, { status: 404 })
} }
const workflowSummary = log.workflowId const workflowSummary = {
? { id: log.workflowId,
id: log.workflowId, name: log.workflowName,
name: log.workflowName, description: log.workflowDescription,
description: log.workflowDescription, color: log.workflowColor,
color: log.workflowColor, folderId: log.workflowFolderId,
folderId: log.workflowFolderId, userId: log.workflowUserId,
userId: log.workflowUserId, workspaceId: log.workflowWorkspaceId,
workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt,
createdAt: log.workflowCreatedAt, updatedAt: log.workflowUpdatedAt,
updatedAt: log.workflowUpdatedAt, }
}
: null
const response = { const response = {
id: log.id, id: log.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema' import { subscription, user, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray, lt, sql } from 'drizzle-orm' import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
@@ -40,17 +40,17 @@ export async function GET(request: NextRequest) {
const freeUserIds = freeUsers.map((u) => u.userId) const freeUserIds = freeUsers.map((u) => u.userId)
const workspacesQuery = await db const workflowsQuery = await db
.select({ id: workspace.id }) .select({ id: workflow.id })
.from(workspace) .from(workflow)
.where(inArray(workspace.billedAccountUserId, freeUserIds)) .where(inArray(workflow.userId, freeUserIds))
if (workspacesQuery.length === 0) { if (workflowsQuery.length === 0) {
logger.info('No workspaces found for free users') logger.info('No workflows found for free users')
return NextResponse.json({ message: 'No workspaces found for cleanup' }) return NextResponse.json({ message: 'No workflows found for cleanup' })
} }
const workspaceIds = workspacesQuery.map((w) => w.id) const workflowIds = workflowsQuery.map((w) => w.id)
const results = { const results = {
enhancedLogs: { enhancedLogs: {
@@ -77,7 +77,7 @@ export async function GET(request: NextRequest) {
let batchesProcessed = 0 let batchesProcessed = 0
let hasMoreLogs = true let hasMoreLogs = true
logger.info(`Starting enhanced logs cleanup for ${workspaceIds.length} workspaces`) logger.info(`Starting enhanced logs cleanup for ${workflowIds.length} workflows`)
while (hasMoreLogs && batchesProcessed < MAX_BATCHES) { while (hasMoreLogs && batchesProcessed < MAX_BATCHES) {
const oldEnhancedLogs = await db const oldEnhancedLogs = await db
@@ -99,7 +99,7 @@ export async function GET(request: NextRequest) {
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.where( .where(
and( and(
inArray(workflowExecutionLogs.workspaceId, workspaceIds), inArray(workflowExecutionLogs.workflowId, workflowIds),
lt(workflowExecutionLogs.createdAt, retentionDate) lt(workflowExecutionLogs.createdAt, retentionDate)
) )
) )
@@ -127,7 +127,7 @@ export async function GET(request: NextRequest) {
customKey: enhancedLogKey, customKey: enhancedLogKey,
metadata: { metadata: {
logId: String(log.id), logId: String(log.id),
workflowId: String(log.workflowId ?? ''), workflowId: String(log.workflowId),
executionId: String(log.executionId), executionId: String(log.executionId),
logType: 'enhanced', logType: 'enhanced',
archivedAt: new Date().toISOString(), archivedAt: new Date().toISOString(),

View File

@@ -6,11 +6,10 @@ import {
workflowExecutionSnapshots, workflowExecutionSnapshots,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
const logger = createLogger('LogsByExecutionIdAPI') const logger = createLogger('LogsByExecutionIdAPI')
@@ -49,15 +48,14 @@ export async function GET(
endedAt: workflowExecutionLogs.endedAt, endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs, totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost, cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData,
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
eq(permissions.entityType, 'workspace'), eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflowExecutionLogs.workspaceId), eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, authenticatedUserId) eq(permissions.userId, authenticatedUserId)
) )
) )
@@ -80,42 +78,10 @@ export async function GET(
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
} }
const executionData = workflowLog.executionData as WorkflowExecutionLog['executionData']
const traceSpans = (executionData?.traceSpans as TraceSpan[]) || []
const childSnapshotIds = new Set<string>()
const collectSnapshotIds = (spans: TraceSpan[]) => {
spans.forEach((span) => {
const snapshotId = span.childWorkflowSnapshotId
if (typeof snapshotId === 'string') {
childSnapshotIds.add(snapshotId)
}
if (span.children?.length) {
collectSnapshotIds(span.children)
}
})
}
if (traceSpans.length > 0) {
collectSnapshotIds(traceSpans)
}
const childWorkflowSnapshots =
childSnapshotIds.size > 0
? await db
.select()
.from(workflowExecutionSnapshots)
.where(inArray(workflowExecutionSnapshots.id, Array.from(childSnapshotIds)))
: []
const childSnapshotMap = childWorkflowSnapshots.reduce<Record<string, unknown>>((acc, snap) => {
acc[snap.id] = snap.stateData
return acc
}, {})
const response = { const response = {
executionId, executionId,
workflowId: workflowLog.workflowId, workflowId: workflowLog.workflowId,
workflowState: snapshot.stateData, workflowState: snapshot.stateData,
childWorkflowSnapshots: childSnapshotMap,
executionMetadata: { executionMetadata: {
trigger: workflowLog.trigger, trigger: workflowLog.trigger,
startedAt: workflowLog.startedAt.toISOString(), startedAt: workflowLog.startedAt.toISOString(),

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, desc, eq, sql } from 'drizzle-orm' import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
@@ -41,7 +41,7 @@ export async function GET(request: NextRequest) {
totalDurationMs: workflowExecutionLogs.totalDurationMs, totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost, cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData, executionData: workflowExecutionLogs.executionData,
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`, workflowName: workflow.name,
} }
const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId) const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
const rows = await db const rows = await db
.select(selectColumns) .select(selectColumns)
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(

View File

@@ -116,7 +116,7 @@ export async function GET(request: NextRequest) {
workflowDeploymentVersion, workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
) )
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -190,7 +190,7 @@ export async function GET(request: NextRequest) {
pausedExecutions, pausedExecutions,
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
) )
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -314,19 +314,17 @@ export async function GET(request: NextRequest) {
} catch {} } catch {}
} }
const workflowSummary = log.workflowId const workflowSummary = {
? { id: log.workflowId,
id: log.workflowId, name: log.workflowName,
name: log.workflowName, description: log.workflowDescription,
description: log.workflowDescription, color: log.workflowColor,
color: log.workflowColor, folderId: log.workflowFolderId,
folderId: log.workflowFolderId, userId: log.workflowUserId,
userId: log.workflowUserId, workspaceId: log.workflowWorkspaceId,
workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt,
createdAt: log.workflowCreatedAt, updatedAt: log.workflowUpdatedAt,
updatedAt: log.workflowUpdatedAt, }
}
: null
return { return {
id: log.id, id: log.id,

View File

@@ -72,7 +72,7 @@ export async function GET(request: NextRequest) {
maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`, maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`,
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -103,8 +103,8 @@ export async function GET(request: NextRequest) {
const statsQuery = await db const statsQuery = await db
.select({ .select({
workflowId: sql<string>`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, workflowId: workflowExecutionLogs.workflowId,
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`, workflowName: workflow.name,
segmentIndex: segmentIndex:
sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as( sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as(
'segment_index' 'segment_index'
@@ -120,7 +120,7 @@ export async function GET(request: NextRequest) {
), ),
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -130,11 +130,7 @@ export async function GET(request: NextRequest) {
) )
) )
.where(whereCondition) .where(whereCondition)
.groupBy( .groupBy(workflowExecutionLogs.workflowId, workflow.name, sql`segment_index`)
sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
sql`COALESCE(${workflow.name}, 'Deleted Workflow')`,
sql`segment_index`
)
const workflowMap = new Map< const workflowMap = new Map<
string, string,

View File

@@ -9,7 +9,7 @@ import { hasAccessControlAccess } from '@/lib/billing'
import { import {
type PermissionGroupConfig, type PermissionGroupConfig,
parsePermissionGroupConfig, parsePermissionGroupConfig,
} from '@/lib/permission-groups/types' } from '@/ee/access-control/lib/types'
const logger = createLogger('PermissionGroup') const logger = createLogger('PermissionGroup')

View File

@@ -10,7 +10,7 @@ import {
DEFAULT_PERMISSION_GROUP_CONFIG, DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig, type PermissionGroupConfig,
parsePermissionGroupConfig, parsePermissionGroupConfig,
} from '@/lib/permission-groups/types' } from '@/ee/access-control/lib/types'
const logger = createLogger('PermissionGroups') const logger = createLogger('PermissionGroups')

View File

@@ -4,7 +4,7 @@ import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { parsePermissionGroupConfig } from '@/lib/permission-groups/types' import { parsePermissionGroupConfig } from '@/ee/access-control/lib/types'
export async function GET(req: Request) { export async function GET(req: Request) {
const session = await getSession() const session = await getSession()

View File

@@ -133,7 +133,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const finalWorkflowData = { const finalWorkflowData = {
...workflowData, ...workflowData,
state: { state: {
// Default values for expected properties
deploymentStatuses: {}, deploymentStatuses: {},
// Data from normalized tables
blocks: normalizedData.blocks, blocks: normalizedData.blocks,
edges: normalizedData.edges, edges: normalizedData.edges,
loops: normalizedData.loops, loops: normalizedData.loops,
@@ -141,11 +143,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(), lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false, isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt, deployedAt: workflowData.deployedAt,
metadata: {
name: workflowData.name,
description: workflowData.description,
},
}, },
// Include workflow variables
variables: workflowData.variables || {}, variables: workflowData.variables || {},
} }
@@ -167,10 +166,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(), lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false, isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt, deployedAt: workflowData.deployedAt,
metadata: {
name: workflowData.name,
description: workflowData.description,
},
}, },
variables: workflowData.variables || {}, variables: workflowData.variables || {},
} }

View File

@@ -215,7 +215,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
} }
for (const log of logs) { for (const log of logs) {
if (!log.workflowId) continue // Skip logs for deleted workflows
const idx = Math.min( const idx = Math.min(
segments - 1, segments - 1,
Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs)) Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs))

View File

@@ -1,9 +1,5 @@
import { memo } from 'react' import { memo } from 'react'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { StatusBar, type StatusBarSegment } from '..' import { StatusBar, type StatusBarSegment } from '..'
@@ -65,32 +61,22 @@ export function WorkflowsList({
<div> <div>
{filteredExecutions.map((workflow, idx) => { {filteredExecutions.map((workflow, idx) => {
const isSelected = expandedWorkflowId === workflow.workflowId const isSelected = expandedWorkflowId === workflow.workflowId
const isDeletedWorkflow = workflow.workflowName === DELETED_WORKFLOW_LABEL
const workflowColor = isDeletedWorkflow
? DELETED_WORKFLOW_COLOR
: workflows[workflow.workflowId]?.color || '#64748b'
const canToggle = !isDeletedWorkflow
return ( return (
<div <div
key={workflow.workflowId} key={workflow.workflowId}
className={cn( className={cn(
'flex h-[44px] items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]', 'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]',
canToggle ? 'cursor-pointer' : 'cursor-default',
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]' isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)} )}
onClick={() => { onClick={() => onToggleWorkflow(workflow.workflowId)}
if (canToggle) {
onToggleWorkflow(workflow.workflowId)
}
}}
> >
{/* Workflow name with color */} {/* Workflow name with color */}
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'> <div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
<div <div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ style={{
backgroundColor: workflowColor, backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}} }}
/> />
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'> <span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>

View File

@@ -80,9 +80,6 @@ export function ExecutionSnapshot({
}, [executionId, closeMenu]) }, [executionId, closeMenu])
const workflowState = data?.workflowState as WorkflowState | undefined const workflowState = data?.workflowState as WorkflowState | undefined
const childWorkflowSnapshots = data?.childWorkflowSnapshots as
| Record<string, WorkflowState>
| undefined
const renderContent = () => { const renderContent = () => {
if (isLoading) { if (isLoading) {
@@ -151,7 +148,6 @@ export function ExecutionSnapshot({
key={executionId} key={executionId}
workflowState={workflowState} workflowState={workflowState}
traceSpans={traceSpans} traceSpans={traceSpans}
childWorkflowSnapshots={childWorkflowSnapshots}
className={className} className={className}
height={height} height={height}
width={width} width={width}

View File

@@ -26,8 +26,6 @@ import {
} from '@/app/workspace/[workspaceId]/logs/components' } from '@/app/workspace/[workspaceId]/logs/components'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import { import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate, formatDate,
getDisplayStatus, getDisplayStatus,
StatusBadge, StatusBadge,
@@ -388,25 +386,22 @@ export const LogDetails = memo(function LogDetails({
</div> </div>
{/* Workflow Card */} {/* Workflow Card */}
<div className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'> {log.workflow && (
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'> <div className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'>
Workflow <div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</div>
<div className='flex min-w-0 items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow.name}
</span>
</div>
</div> </div>
<div className='flex min-w-0 items-center gap-[8px]'> )}
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{
backgroundColor:
log.workflow?.color ||
(!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined),
}}
/>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow?.name ||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
</span>
</div>
</div>
</div> </div>
{/* Execution ID */} {/* Execution ID */}

View File

@@ -7,8 +7,6 @@ import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn' import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate, formatDate,
formatDuration, formatDuration,
getDisplayStatus, getDisplayStatus,
@@ -35,11 +33,6 @@ interface LogRowProps {
const LogRow = memo( const LogRow = memo(
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) { function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt]) const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
const workflowName = isDeletedWorkflow
? DELETED_WORKFLOW_LABEL
: log.workflow?.name || 'Unknown'
const workflowColor = isDeletedWorkflow ? DELETED_WORKFLOW_COLOR : log.workflow?.color
const handleClick = useCallback(() => onClick(log), [onClick, log]) const handleClick = useCallback(() => onClick(log), [onClick, log])
@@ -85,15 +78,10 @@ const LogRow = memo(
> >
<div <div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: workflowColor }} style={{ backgroundColor: log.workflow?.color }}
/> />
<span <span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
className={cn( {log.workflow?.name || 'Unknown'}
'min-w-0 truncate font-medium text-[12px]',
isDeletedWorkflow ? 'text-[var(--text-tertiary)]' : 'text-[var(--text-primary)]'
)}
>
{workflowName}
</span> </span>
</div> </div>

View File

@@ -27,9 +27,6 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'duration', 'duration',
] as const ] as const
export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'
export const DELETED_WORKFLOW_COLOR = 'var(--text-tertiary)'
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
/** /**

View File

@@ -23,7 +23,6 @@ interface SelectorComboboxProps {
readOnly?: boolean readOnly?: boolean
onOptionChange?: (value: string) => void onOptionChange?: (value: string) => void
allowSearch?: boolean allowSearch?: boolean
missingOptionLabel?: string
} }
export function SelectorCombobox({ export function SelectorCombobox({
@@ -38,7 +37,6 @@ export function SelectorCombobox({
readOnly, readOnly,
onOptionChange, onOptionChange,
allowSearch = true, allowSearch = true,
missingOptionLabel,
}: SelectorComboboxProps) { }: SelectorComboboxProps) {
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>( const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
blockId, blockId,
@@ -62,16 +60,7 @@ export function SelectorCombobox({
detailId: activeValue, detailId: activeValue,
}) })
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined) const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
const hasMissingOption = const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
Boolean(activeValue) &&
Boolean(missingOptionLabel) &&
!isLoading &&
!optionMap.get(activeValue!)
const selectedLabel = activeValue
? hasMissingOption
? missingOptionLabel
: (optionMap.get(activeValue)?.label ?? activeValue)
: ''
const [inputValue, setInputValue] = useState(selectedLabel) const [inputValue, setInputValue] = useState(selectedLabel)
const previousActiveValue = useRef<string | undefined>(activeValue) const previousActiveValue = useRef<string | undefined>(activeValue)

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types' import type { SelectorContext } from '@/hooks/selectors/types'
@@ -41,7 +40,6 @@ export function WorkflowSelectorInput({
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
placeholder={subBlock.placeholder || 'Select workflow...'} placeholder={subBlock.placeholder || 'Select workflow...'}
missingOptionLabel={DELETED_WORKFLOW_LABEL}
/> />
) )
} }

View File

@@ -4,14 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Check,
ChevronDown as ChevronDownIcon, ChevronDown as ChevronDownIcon,
ChevronUp, ChevronUp,
Clipboard,
ExternalLink, ExternalLink,
Maximize2, Maximize2,
RepeatIcon, RepeatIcon,
Search,
SplitIcon, SplitIcon,
X, X,
} from 'lucide-react' } from 'lucide-react'
@@ -37,7 +34,6 @@ import {
isSubBlockFeatureEnabled, isSubBlockFeatureEnabled,
isSubBlockVisibleForMode, isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility' } from '@/lib/workflows/subblocks/visibility'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu' import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
@@ -694,7 +690,6 @@ interface ExecutionData {
output?: unknown output?: unknown
status?: string status?: string
durationMs?: number durationMs?: number
childWorkflowSnapshotId?: string
} }
interface WorkflowVariable { interface WorkflowVariable {
@@ -719,8 +714,6 @@ interface PreviewEditorProps {
parallels?: Record<string, Parallel> parallels?: Record<string, Parallel>
/** When true, shows "Not Executed" badge if no executionData is provided */ /** When true, shows "Not Executed" badge if no executionData is provided */
isExecutionMode?: boolean isExecutionMode?: boolean
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
childWorkflowSnapshots?: Record<string, WorkflowState>
/** Optional close handler - if not provided, no close button is shown */ /** Optional close handler - if not provided, no close button is shown */
onClose?: () => void onClose?: () => void
/** Callback to drill down into a nested workflow block */ /** Callback to drill down into a nested workflow block */
@@ -746,7 +739,6 @@ function PreviewEditorContent({
loops, loops,
parallels, parallels,
isExecutionMode = false, isExecutionMode = false,
childWorkflowSnapshots,
onClose, onClose,
onDrillDown, onDrillDown,
}: PreviewEditorProps) { }: PreviewEditorProps) {
@@ -776,35 +768,17 @@ function PreviewEditorContent({
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState( const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
childWorkflowId ?? undefined childWorkflowId ?? undefined
) )
const childWorkflowSnapshotId = executionData?.childWorkflowSnapshotId
const childWorkflowSnapshotState = childWorkflowSnapshotId
? childWorkflowSnapshots?.[childWorkflowSnapshotId]
: undefined
const resolvedChildWorkflowState = isExecutionMode
? childWorkflowSnapshotState
: childWorkflowState
const resolvedIsLoadingChildWorkflow = isExecutionMode ? false : isLoadingChildWorkflow
const isMissingChildWorkflow =
Boolean(childWorkflowId) && !resolvedIsLoadingChildWorkflow && !resolvedChildWorkflowState
/** Drills down into the child workflow or opens it in a new tab */ /** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => { const handleExpandChildWorkflow = useCallback(() => {
if (!childWorkflowId) return if (!childWorkflowId || !childWorkflowState) return
if (isExecutionMode && onDrillDown) { if (isExecutionMode && onDrillDown) {
if (!childWorkflowSnapshotState) return onDrillDown(block.id, childWorkflowState)
onDrillDown(block.id, childWorkflowSnapshotState)
} else if (workspaceId) { } else if (workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer') window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
} }
}, [ }, [childWorkflowId, childWorkflowState, isExecutionMode, onDrillDown, block.id, workspaceId])
childWorkflowId,
childWorkflowSnapshotState,
isExecutionMode,
onDrillDown,
block.id,
workspaceId,
])
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
const subBlocksRef = useRef<HTMLDivElement>(null) const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -839,13 +813,6 @@ function PreviewEditorContent({
} = useContextMenu() } = useContextMenu()
const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false }) const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false })
const [copiedSection, setCopiedSection] = useState<'input' | 'output' | null>(null)
const handleCopySection = useCallback((content: string, section: 'input' | 'output') => {
navigator.clipboard.writeText(content)
setCopiedSection(section)
setTimeout(() => setCopiedSection(null), 1500)
}, [])
const openContextMenu = useCallback( const openContextMenu = useCallback(
(e: React.MouseEvent, content: string, copyOnly: boolean) => { (e: React.MouseEvent, content: string, copyOnly: boolean) => {
@@ -895,6 +862,9 @@ function PreviewEditorContent({
} }
}, [contextMenuData.content]) }, [contextMenuData.content])
/**
* Handles mouse down event on the resize handle to initiate resizing
*/
const handleConnectionsResizeMouseDown = useCallback( const handleConnectionsResizeMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
setIsResizing(true) setIsResizing(true)
@@ -904,12 +874,18 @@ function PreviewEditorContent({
[connectionsHeight] [connectionsHeight]
) )
/**
* Toggle connections collapsed state
*/
const toggleConnectionsCollapsed = useCallback(() => { const toggleConnectionsCollapsed = useCallback(() => {
setConnectionsHeight((prev) => setConnectionsHeight((prev) =>
prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT
) )
}, []) }, [])
/**
* Sets up resize event listeners during resize operations
*/
useEffect(() => { useEffect(() => {
if (!isResizing) return if (!isResizing) return
@@ -1229,11 +1205,7 @@ function PreviewEditorContent({
} }
emptyMessage='No input data' emptyMessage='No input data'
> >
<div <div onContextMenu={handleExecutionContextMenu} ref={contentRef}>
onContextMenu={handleExecutionContextMenu}
ref={contentRef}
className='relative'
>
<Code.Viewer <Code.Viewer
code={formatValueAsJson(executionData.input)} code={formatValueAsJson(executionData.input)}
language='json' language='json'
@@ -1243,49 +1215,6 @@ function PreviewEditorContent({
currentMatchIndex={currentMatchIndex} currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange} onMatchCountChange={handleMatchCountChange}
/> />
{/* Action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleCopySection(formatValueAsJson(executionData.input), 'input')
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
{copiedSection === 'input' ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copiedSection === 'input' ? 'Copied' : 'Copy'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div> </div>
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -1302,7 +1231,7 @@ function PreviewEditorContent({
emptyMessage='No output data' emptyMessage='No output data'
isError={executionData.status === 'error'} isError={executionData.status === 'error'}
> >
<div onContextMenu={handleExecutionContextMenu} className='relative'> <div onContextMenu={handleExecutionContextMenu}>
<Code.Viewer <Code.Viewer
code={formatValueAsJson(executionData.output)} code={formatValueAsJson(executionData.output)}
language='json' language='json'
@@ -1315,49 +1244,6 @@ function PreviewEditorContent({
currentMatchIndex={currentMatchIndex} currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange} onMatchCountChange={handleMatchCountChange}
/> />
{/* Action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleCopySection(formatValueAsJson(executionData.output), 'output')
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
{copiedSection === 'output' ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copiedSection === 'output' ? 'Copied' : 'Copy'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div> </div>
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -1370,7 +1256,7 @@ function PreviewEditorContent({
Workflow Preview Workflow Preview
</div> </div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'> <div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{resolvedIsLoadingChildWorkflow ? ( {isLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'> <div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<div <div
className='h-[18px] w-[18px] animate-spin rounded-full' className='h-[18px] w-[18px] animate-spin rounded-full'
@@ -1383,11 +1269,11 @@ function PreviewEditorContent({
}} }}
/> />
</div> </div>
) : resolvedChildWorkflowState ? ( ) : childWorkflowState ? (
<> <>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'> <div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<PreviewWorkflow <PreviewWorkflow
workflowState={resolvedChildWorkflowState} workflowState={childWorkflowState}
height={160} height={160}
width='100%' width='100%'
isPannable={true} isPannable={true}
@@ -1419,9 +1305,7 @@ function PreviewEditorContent({
) : ( ) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'> <div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'> <span className='text-[13px] text-[var(--text-tertiary)]'>
{isMissingChildWorkflow Unable to load preview
? DELETED_WORKFLOW_LABEL
: 'Unable to load preview'}
</span> </span>
</div> </div>
)} )}

View File

@@ -9,7 +9,6 @@ import {
isSubBlockFeatureEnabled, isSubBlockFeatureEnabled,
isSubBlockVisibleForMode, isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility' } from '@/lib/workflows/subblocks/visibility'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
@@ -113,7 +112,7 @@ function resolveWorkflowName(
if (!rawValue || typeof rawValue !== 'string') return null if (!rawValue || typeof rawValue !== 'string') return null
const workflowMap = useWorkflowRegistry.getState().workflows const workflowMap = useWorkflowRegistry.getState().workflows
return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL return workflowMap[rawValue]?.name ?? null
} }
/** /**

View File

@@ -19,8 +19,6 @@ interface TraceSpan {
status?: string status?: string
duration?: number duration?: number
children?: TraceSpan[] children?: TraceSpan[]
childWorkflowSnapshotId?: string
childWorkflowId?: string
} }
interface BlockExecutionData { interface BlockExecutionData {
@@ -30,7 +28,6 @@ interface BlockExecutionData {
durationMs: number durationMs: number
/** Child trace spans for nested workflow blocks */ /** Child trace spans for nested workflow blocks */
children?: TraceSpan[] children?: TraceSpan[]
childWorkflowSnapshotId?: string
} }
/** Represents a level in the workflow navigation stack */ /** Represents a level in the workflow navigation stack */
@@ -38,7 +35,6 @@ interface WorkflowStackEntry {
workflowState: WorkflowState workflowState: WorkflowState
traceSpans: TraceSpan[] traceSpans: TraceSpan[]
blockExecutions: Record<string, BlockExecutionData> blockExecutions: Record<string, BlockExecutionData>
workflowName: string
} }
/** /**
@@ -93,7 +89,6 @@ export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockEx
status: span.status || 'unknown', status: span.status || 'unknown',
durationMs: span.duration || 0, durationMs: span.duration || 0,
children: span.children, children: span.children,
childWorkflowSnapshotId: span.childWorkflowSnapshotId,
} }
} }
} }
@@ -108,8 +103,6 @@ interface PreviewProps {
traceSpans?: TraceSpan[] traceSpans?: TraceSpan[]
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */ /** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
blockExecutions?: Record<string, BlockExecutionData> blockExecutions?: Record<string, BlockExecutionData>
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
childWorkflowSnapshots?: Record<string, WorkflowState>
/** Additional CSS class names */ /** Additional CSS class names */
className?: string className?: string
/** Height of the component */ /** Height of the component */
@@ -142,7 +135,6 @@ export function Preview({
workflowState: rootWorkflowState, workflowState: rootWorkflowState,
traceSpans: rootTraceSpans, traceSpans: rootTraceSpans,
blockExecutions: providedBlockExecutions, blockExecutions: providedBlockExecutions,
childWorkflowSnapshots,
className, className,
height = '100%', height = '100%',
width = '100%', width = '100%',
@@ -152,6 +144,7 @@ export function Preview({
initialSelectedBlockId, initialSelectedBlockId,
autoSelectLeftmost = true, autoSelectLeftmost = true,
}: PreviewProps) { }: PreviewProps) {
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => { const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
if (initialSelectedBlockId) return initialSelectedBlockId if (initialSelectedBlockId) return initialSelectedBlockId
if (autoSelectLeftmost) { if (autoSelectLeftmost) {
@@ -160,14 +153,17 @@ export function Preview({
return null return null
}) })
/** Stack for nested workflow navigation. Empty means we're at the root level. */
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([]) const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
/** Block executions for the root level */
const rootBlockExecutions = useMemo(() => { const rootBlockExecutions = useMemo(() => {
if (providedBlockExecutions) return providedBlockExecutions if (providedBlockExecutions) return providedBlockExecutions
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {} if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
return buildBlockExecutions(rootTraceSpans) return buildBlockExecutions(rootTraceSpans)
}, [providedBlockExecutions, rootTraceSpans]) }, [providedBlockExecutions, rootTraceSpans])
/** Current block executions - either from stack or root */
const blockExecutions = useMemo(() => { const blockExecutions = useMemo(() => {
if (workflowStack.length > 0) { if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].blockExecutions return workflowStack[workflowStack.length - 1].blockExecutions
@@ -175,6 +171,7 @@ export function Preview({
return rootBlockExecutions return rootBlockExecutions
}, [workflowStack, rootBlockExecutions]) }, [workflowStack, rootBlockExecutions])
/** Current workflow state - either from stack or root */
const workflowState = useMemo(() => { const workflowState = useMemo(() => {
if (workflowStack.length > 0) { if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].workflowState return workflowStack[workflowStack.length - 1].workflowState
@@ -182,39 +179,41 @@ export function Preview({
return rootWorkflowState return rootWorkflowState
}, [workflowStack, rootWorkflowState]) }, [workflowStack, rootWorkflowState])
/** Whether we're in execution mode (have trace spans/block executions) */
const isExecutionMode = useMemo(() => { const isExecutionMode = useMemo(() => {
return Object.keys(blockExecutions).length > 0 return Object.keys(blockExecutions).length > 0
}, [blockExecutions]) }, [blockExecutions])
/** Handler to drill down into a nested workflow block */
const handleDrillDown = useCallback( const handleDrillDown = useCallback(
(blockId: string, childWorkflowState: WorkflowState) => { (blockId: string, childWorkflowState: WorkflowState) => {
const blockExecution = blockExecutions[blockId] const blockExecution = blockExecutions[blockId]
const childTraceSpans = extractChildTraceSpans(blockExecution) const childTraceSpans = extractChildTraceSpans(blockExecution)
const childBlockExecutions = buildBlockExecutions(childTraceSpans) const childBlockExecutions = buildBlockExecutions(childTraceSpans)
const workflowName = childWorkflowState.metadata?.name || 'Nested Workflow'
setWorkflowStack((prev) => [ setWorkflowStack((prev) => [
...prev, ...prev,
{ {
workflowState: childWorkflowState, workflowState: childWorkflowState,
traceSpans: childTraceSpans, traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions, blockExecutions: childBlockExecutions,
workflowName,
}, },
]) ])
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
const leftmostId = getLeftmostBlockId(childWorkflowState) const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId) setPinnedBlockId(leftmostId)
}, },
[blockExecutions] [blockExecutions]
) )
/** Handler to go back up the stack */
const handleGoBack = useCallback(() => { const handleGoBack = useCallback(() => {
setWorkflowStack((prev) => prev.slice(0, -1)) setWorkflowStack((prev) => prev.slice(0, -1))
setPinnedBlockId(null) setPinnedBlockId(null)
}, []) }, [])
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
const handleNodeClick = useCallback((blockId: string) => { const handleNodeClick = useCallback((blockId: string) => {
setPinnedBlockId(blockId) setPinnedBlockId(blockId)
}, []) }, [])
@@ -233,8 +232,6 @@ export function Preview({
const isNested = workflowStack.length > 0 const isNested = workflowStack.length > 0
const currentWorkflowName = isNested ? workflowStack[workflowStack.length - 1].workflowName : null
return ( return (
<div <div
style={{ height, width }} style={{ height, width }}
@@ -245,27 +242,20 @@ export function Preview({
)} )}
> >
{isNested && ( {isNested && (
<div className='absolute top-[12px] left-[12px] z-20 flex items-center gap-[6px]'> <div className='absolute top-[12px] left-[12px] z-20'>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
variant='ghost' variant='ghost'
onClick={handleGoBack} onClick={handleGoBack}
className='flex h-[28px] items-center gap-[5px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] text-[var(--text-secondary)] shadow-sm hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)]' className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]'
> >
<ArrowLeft className='h-[12px] w-[12px]' /> <ArrowLeft className='h-[13px] w-[13px]' />
<span className='font-medium text-[12px]'>Back</span> <span className='font-medium text-[13px]'>Back</span>
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content> <Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
{currentWorkflowName && (
<div className='flex h-[28px] max-w-[200px] items-center rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] shadow-sm'>
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
{currentWorkflowName}
</span>
</div>
)}
</div> </div>
)} )}
@@ -294,7 +284,6 @@ export function Preview({
loops={workflowState.loops} loops={workflowState.loops}
parallels={workflowState.parallels} parallels={workflowState.parallels}
isExecutionMode={isExecutionMode} isExecutionMode={isExecutionMode}
childWorkflowSnapshots={childWorkflowSnapshots}
onClose={handleEditorClose} onClose={handleEditorClose}
onDrillDown={handleDrillDown} onDrillDown={handleDrillDown}
/> />

View File

@@ -1,4 +1,3 @@
export { AccessControl } from './access-control/access-control'
export { ApiKeys } from './api-keys/api-keys' export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok' export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot' export { Copilot } from './copilot/copilot'
@@ -10,7 +9,6 @@ export { Files as FileUploads } from './files/files'
export { General } from './general/general' export { General } from './general/general'
export { Integrations } from './integrations/integrations' export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp' export { MCP } from './mcp/mcp'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription' export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management' export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers' export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -41,7 +41,6 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags' import { isHosted } from '@/lib/core/config/feature-flags'
import { getUserRole } from '@/lib/workspaces/organization' import { getUserRole } from '@/lib/workspaces/organization'
import { import {
AccessControl,
ApiKeys, ApiKeys,
BYOK, BYOK,
Copilot, Copilot,
@@ -53,15 +52,15 @@ import {
General, General,
Integrations, Integrations,
MCP, MCP,
SSO,
Subscription, Subscription,
TeamManagement, TeamManagement,
WorkflowMcpServers, WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components' } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile' import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
import { AccessControl } from '@/ee/access-control'
import { SSO, ssoKeys, useSSOProviders } from '@/ee/sso'
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings' import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization' import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config' import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store' import { useSettingsModalStore } from '@/stores/modals/settings/store'

View File

@@ -66,10 +66,7 @@ function generateSignature(secret: string, timestamp: number, body: string): str
async function buildPayload( async function buildPayload(
log: WorkflowExecutionLog, log: WorkflowExecutionLog,
subscription: typeof workspaceNotificationSubscription.$inferSelect subscription: typeof workspaceNotificationSubscription.$inferSelect
): Promise<NotificationPayload | null> { ): Promise<NotificationPayload> {
// Skip notifications for deleted workflows
if (!log.workflowId) return null
const workflowData = await db const workflowData = await db
.select({ name: workflowTable.name, userId: workflowTable.userId }) .select({ name: workflowTable.name, userId: workflowTable.userId })
.from(workflowTable) .from(workflowTable)
@@ -529,13 +526,6 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
const attempts = claimed[0].attempts const attempts = claimed[0].attempts
const payload = await buildPayload(log, subscription) const payload = await buildPayload(log, subscription)
// Skip delivery for deleted workflows
if (!payload) {
await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was deleted')
logger.info(`Skipping delivery ${deliveryId} - workflow was deleted`)
return
}
let result: { success: boolean; status?: number; error?: string } let result: { success: boolean; status?: number; error?: string }
switch (notificationType) { switch (notificationType) {

View File

@@ -9,7 +9,7 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
description: 'Interact with YouTube videos, channels, and playlists', description: 'Interact with YouTube videos, channels, and playlists',
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
longDescription: longDescription:
'Integrate YouTube into the workflow. Can search for videos, get trending videos, get video details, get video categories, get channel information, get all videos from a channel, get channel playlists, get playlist items, and get video comments.', 'Integrate YouTube into the workflow. Can search for videos, get video details, get channel information, get all videos from a channel, get channel playlists, get playlist items, find related videos, and get video comments.',
docsLink: 'https://docs.sim.ai/tools/youtube', docsLink: 'https://docs.sim.ai/tools/youtube',
category: 'tools', category: 'tools',
bgColor: '#FF0000', bgColor: '#FF0000',
@@ -21,9 +21,7 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
type: 'dropdown', type: 'dropdown',
options: [ options: [
{ label: 'Search Videos', id: 'youtube_search' }, { label: 'Search Videos', id: 'youtube_search' },
{ label: 'Get Trending Videos', id: 'youtube_trending' },
{ label: 'Get Video Details', id: 'youtube_video_details' }, { label: 'Get Video Details', id: 'youtube_video_details' },
{ label: 'Get Video Categories', id: 'youtube_video_categories' },
{ label: 'Get Channel Info', id: 'youtube_channel_info' }, { label: 'Get Channel Info', id: 'youtube_channel_info' },
{ label: 'Get Channel Videos', id: 'youtube_channel_videos' }, { label: 'Get Channel Videos', id: 'youtube_channel_videos' },
{ label: 'Get Channel Playlists', id: 'youtube_channel_playlists' }, { label: 'Get Channel Playlists', id: 'youtube_channel_playlists' },
@@ -51,13 +49,6 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
integer: true, integer: true,
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_search' },
},
{ {
id: 'channelId', id: 'channelId',
title: 'Filter by Channel ID', title: 'Filter by Channel ID',
@@ -65,19 +56,6 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
placeholder: 'Filter results to a specific channel', placeholder: 'Filter results to a specific channel',
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
{
id: 'eventType',
title: 'Live Stream Filter',
type: 'dropdown',
options: [
{ label: 'All Videos', id: '' },
{ label: 'Currently Live', id: 'live' },
{ label: 'Upcoming Streams', id: 'upcoming' },
{ label: 'Past Streams', id: 'completed' },
],
value: () => '',
condition: { field: 'operation', value: 'youtube_search' },
},
{ {
id: 'publishedAfter', id: 'publishedAfter',
title: 'Published After', title: 'Published After',
@@ -153,7 +131,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
id: 'videoCategoryId', id: 'videoCategoryId',
title: 'Category ID', title: 'Category ID',
type: 'short-input', type: 'short-input',
placeholder: 'Use Get Video Categories to find IDs', placeholder: '10 for Music, 20 for Gaming',
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
{ {
@@ -185,10 +163,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
title: 'Region Code', title: 'Region Code',
type: 'short-input', type: 'short-input',
placeholder: 'US, GB, JP', placeholder: 'US, GB, JP',
condition: { condition: { field: 'operation', value: 'youtube_search' },
field: 'operation',
value: ['youtube_search', 'youtube_trending', 'youtube_video_categories'],
},
}, },
{ {
id: 'relevanceLanguage', id: 'relevanceLanguage',
@@ -209,31 +184,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'moderate', value: () => 'moderate',
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
// Get Trending Videos operation inputs
{
id: 'maxResults',
title: 'Max Results',
type: 'slider',
min: 1,
max: 50,
step: 1,
integer: true,
condition: { field: 'operation', value: 'youtube_trending' },
},
{
id: 'videoCategoryId',
title: 'Category ID',
type: 'short-input',
placeholder: 'Use Get Video Categories to find IDs',
condition: { field: 'operation', value: 'youtube_trending' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_trending' },
},
// Get Video Details operation inputs // Get Video Details operation inputs
{ {
id: 'videoId', id: 'videoId',
@@ -243,14 +193,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
required: true, required: true,
condition: { field: 'operation', value: 'youtube_video_details' }, condition: { field: 'operation', value: 'youtube_video_details' },
}, },
// Get Video Categories operation inputs
{
id: 'hl',
title: 'Language',
type: 'short-input',
placeholder: 'en, es, fr (for category names)',
condition: { field: 'operation', value: 'youtube_video_categories' },
},
// Get Channel Info operation inputs // Get Channel Info operation inputs
{ {
id: 'channelId', id: 'channelId',
@@ -299,13 +241,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'date', value: () => 'date',
condition: { field: 'operation', value: 'youtube_channel_videos' }, condition: { field: 'operation', value: 'youtube_channel_videos' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_channel_videos' },
},
// Get Channel Playlists operation inputs // Get Channel Playlists operation inputs
{ {
id: 'channelId', id: 'channelId',
@@ -325,13 +260,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
integer: true, integer: true,
condition: { field: 'operation', value: 'youtube_channel_playlists' }, condition: { field: 'operation', value: 'youtube_channel_playlists' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_channel_playlists' },
},
// Get Playlist Items operation inputs // Get Playlist Items operation inputs
{ {
id: 'playlistId', id: 'playlistId',
@@ -351,13 +279,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
integer: true, integer: true,
condition: { field: 'operation', value: 'youtube_playlist_items' }, condition: { field: 'operation', value: 'youtube_playlist_items' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_playlist_items' },
},
// Get Video Comments operation inputs // Get Video Comments operation inputs
{ {
id: 'videoId', id: 'videoId',
@@ -388,13 +309,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'relevance', value: () => 'relevance',
condition: { field: 'operation', value: 'youtube_comments' }, condition: { field: 'operation', value: 'youtube_comments' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_comments' },
},
// API Key (common to all operations) // API Key (common to all operations)
{ {
id: 'apiKey', id: 'apiKey',
@@ -407,15 +321,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
], ],
tools: { tools: {
access: [ access: [
'youtube_channel_info',
'youtube_channel_playlists',
'youtube_channel_videos',
'youtube_comments',
'youtube_playlist_items',
'youtube_search', 'youtube_search',
'youtube_trending',
'youtube_video_categories',
'youtube_video_details', 'youtube_video_details',
'youtube_channel_info',
'youtube_channel_videos',
'youtube_channel_playlists',
'youtube_playlist_items',
'youtube_comments',
], ],
config: { config: {
tool: (params) => { tool: (params) => {
@@ -427,12 +339,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
switch (params.operation) { switch (params.operation) {
case 'youtube_search': case 'youtube_search':
return 'youtube_search' return 'youtube_search'
case 'youtube_trending':
return 'youtube_trending'
case 'youtube_video_details': case 'youtube_video_details':
return 'youtube_video_details' return 'youtube_video_details'
case 'youtube_video_categories':
return 'youtube_video_categories'
case 'youtube_channel_info': case 'youtube_channel_info':
return 'youtube_channel_info' return 'youtube_channel_info'
case 'youtube_channel_videos': case 'youtube_channel_videos':
@@ -455,7 +363,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
// Search Videos // Search Videos
query: { type: 'string', description: 'Search query' }, query: { type: 'string', description: 'Search query' },
maxResults: { type: 'number', description: 'Maximum number of results' }, maxResults: { type: 'number', description: 'Maximum number of results' },
pageToken: { type: 'string', description: 'Page token for pagination' },
// Search Filters // Search Filters
publishedAfter: { type: 'string', description: 'Published after date (RFC 3339)' }, publishedAfter: { type: 'string', description: 'Published after date (RFC 3339)' },
publishedBefore: { type: 'string', description: 'Published before date (RFC 3339)' }, publishedBefore: { type: 'string', description: 'Published before date (RFC 3339)' },
@@ -463,11 +370,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
videoCategoryId: { type: 'string', description: 'YouTube category ID' }, videoCategoryId: { type: 'string', description: 'YouTube category ID' },
videoDefinition: { type: 'string', description: 'Video quality filter' }, videoDefinition: { type: 'string', description: 'Video quality filter' },
videoCaption: { type: 'string', description: 'Caption availability filter' }, videoCaption: { type: 'string', description: 'Caption availability filter' },
eventType: { type: 'string', description: 'Live stream filter (live/upcoming/completed)' },
regionCode: { type: 'string', description: 'Region code (ISO 3166-1)' }, regionCode: { type: 'string', description: 'Region code (ISO 3166-1)' },
relevanceLanguage: { type: 'string', description: 'Language code (ISO 639-1)' }, relevanceLanguage: { type: 'string', description: 'Language code (ISO 639-1)' },
safeSearch: { type: 'string', description: 'Safe search level' }, safeSearch: { type: 'string', description: 'Safe search level' },
hl: { type: 'string', description: 'Language for category names' },
// Video Details & Comments // Video Details & Comments
videoId: { type: 'string', description: 'YouTube video ID' }, videoId: { type: 'string', description: 'YouTube video ID' },
// Channel Info // Channel Info
@@ -479,7 +384,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
order: { type: 'string', description: 'Sort order' }, order: { type: 'string', description: 'Sort order' },
}, },
outputs: { outputs: {
// Search Videos, Trending, Playlist Items, Captions, Categories // Search Videos & Playlist Items
items: { type: 'json', description: 'List of items returned' }, items: { type: 'json', description: 'List of items returned' },
totalResults: { type: 'number', description: 'Total number of results' }, totalResults: { type: 'number', description: 'Total number of results' },
nextPageToken: { type: 'string', description: 'Token for next page' }, nextPageToken: { type: 'string', description: 'Token for next page' },
@@ -494,33 +399,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
viewCount: { type: 'number', description: 'View count' }, viewCount: { type: 'number', description: 'View count' },
likeCount: { type: 'number', description: 'Like count' }, likeCount: { type: 'number', description: 'Like count' },
commentCount: { type: 'number', description: 'Comment count' }, commentCount: { type: 'number', description: 'Comment count' },
favoriteCount: { type: 'number', description: 'Favorite count' },
thumbnail: { type: 'string', description: 'Thumbnail URL' }, thumbnail: { type: 'string', description: 'Thumbnail URL' },
tags: { type: 'json', description: 'Video tags' }, tags: { type: 'json', description: 'Video tags' },
categoryId: { type: 'string', description: 'Video category ID' },
definition: { type: 'string', description: 'Video definition (hd/sd)' },
caption: { type: 'string', description: 'Has captions (true/false)' },
licensedContent: { type: 'boolean', description: 'Is licensed content' },
privacyStatus: { type: 'string', description: 'Privacy status' },
liveBroadcastContent: { type: 'string', description: 'Live broadcast status' },
defaultLanguage: { type: 'string', description: 'Default language' },
defaultAudioLanguage: { type: 'string', description: 'Default audio language' },
// Live Streaming Details
isLiveContent: { type: 'boolean', description: 'Whether video is/was a live stream' },
scheduledStartTime: { type: 'string', description: 'Scheduled start time for live streams' },
actualStartTime: { type: 'string', description: 'Actual start time of live stream' },
actualEndTime: { type: 'string', description: 'End time of live stream' },
concurrentViewers: { type: 'number', description: 'Current viewers (live only)' },
activeLiveChatId: { type: 'string', description: 'Live chat ID' },
// Channel Info // Channel Info
subscriberCount: { type: 'number', description: 'Subscriber count' }, subscriberCount: { type: 'number', description: 'Subscriber count' },
videoCount: { type: 'number', description: 'Total video count' }, videoCount: { type: 'number', description: 'Total video count' },
customUrl: { type: 'string', description: 'Channel custom URL' }, customUrl: { type: 'string', description: 'Channel custom URL' },
country: { type: 'string', description: 'Channel country' },
uploadsPlaylistId: { type: 'string', description: 'Uploads playlist ID' },
bannerImageUrl: { type: 'string', description: 'Channel banner URL' },
hiddenSubscriberCount: { type: 'boolean', description: 'Is subscriber count hidden' },
// Video Categories
assignable: { type: 'boolean', description: 'Whether category can be assigned' },
}, },
} }

46
apps/sim/ee/LICENSE Normal file
View File

@@ -0,0 +1,46 @@
The Sim Enterprise License (the "Enterprise License")
Copyright (c) 2026-present Sim Studio, Inc.
With regard to the Sim Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Sim Terms of Service available at
https://sim.ai/terms (or other agreement governing the use of the Software,
as mutually agreed by you and Sim Studio, Inc. ("Sim")), and otherwise
have a valid Sim Enterprise subscription ("Enterprise Subscription")
for the correct number of seats as defined in your agreement.
Subject to the foregoing sentence, you are free to modify this Software and
publish patches to the Software. You agree that Sim and/or its licensors
(as applicable) retain all right, title and interest in and to all such
modifications and/or patches, and all such modifications and/or patches may
only be used, copied, modified, displayed, distributed, or otherwise exploited
with a valid Enterprise Subscription.
Notwithstanding the foregoing, you may copy and modify the Software for
development and testing purposes, without requiring a subscription. You agree
that Sim and/or its licensors (as applicable) retain all right, title
and interest in and to all such modifications.
You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute,
sublicense, and/or sell the Software.
This Enterprise License applies only to the part of this Software that is not
distributed under the Apache License 2.0. Any part of this Software distributed
under the Apache License 2.0 is copyrighted under that license. The full text
of this Enterprise License shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Sim Software, those
components are licensed under the original license provided by the owner of the
applicable component.

69
apps/sim/ee/README.md Normal file
View File

@@ -0,0 +1,69 @@
# Sim Enterprise Edition
This directory contains enterprise features that require a valid Sim Enterprise license for production use.
## Features
- **SSO**: SAML and OIDC single sign-on authentication
- **Access Control**: Permission groups and role-based access control
## Structure
```
ee/
├── LICENSE
├── README.md
├── index.ts # Main barrel export
├── sso/
│ ├── index.ts
│ ├── components/ # SSO settings UI
│ ├── hooks/ # React Query hooks
│ └── lib/ # Utilities and constants
└── access-control/
├── index.ts
├── components/ # Access control settings UI
├── hooks/ # React Query hooks
└── lib/ # Types and utilities
```
**Note:** API routes remain in `app/api/` as required by Next.js routing conventions:
- SSO API: `app/api/auth/sso/`
- Permission Groups API: `app/api/permission-groups/`
## Licensing
Code in this directory is **NOT** covered by the Apache 2.0 license. See [LICENSE](./LICENSE) for the Sim Enterprise License terms.
The rest of the Sim codebase outside this directory is licensed under Apache 2.0.
## For Open Source Users
You may delete this directory to use Sim under the Apache 2.0 license only. The application will continue to function without enterprise features.
## Development & Testing
You may copy and modify this software for development and testing purposes without requiring an Enterprise subscription. Production use requires a valid license.
## Enabling Enterprise Features
Enterprise features are controlled by environment variables and subscription status:
- `NEXT_PUBLIC_SSO_ENABLED` - Enable SSO for self-hosted instances
- `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` - Enable access control for self-hosted instances
On the hosted platform (sim.ai), these features are automatically available with an Enterprise subscription.
## Usage
```typescript
// Import enterprise components
import { SSO, AccessControl } from '@/ee'
// Or import specific features
import { SSO, useSSOProviders } from '@/ee/sso'
import { AccessControl, usePermissionGroups } from '@/ee/access-control'
```
## Contact
For Enterprise licensing inquiries, contact [sales@sim.ai](mailto:sales@sim.ai).

View File

@@ -25,11 +25,9 @@ import {
import { Input as BaseInput, Skeleton } from '@/components/ui' import { Input as BaseInput, Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client' import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client' import { getSubscriptionStatus } from '@/lib/billing/client'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors' import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization' import { getUserRole } from '@/lib/workspaces/organization'
import { getAllBlocks } from '@/blocks' import { getAllBlocks } from '@/blocks'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import { import {
type PermissionGroup, type PermissionGroup,
useBulkAddPermissionGroupMembers, useBulkAddPermissionGroupMembers,
@@ -39,7 +37,9 @@ import {
usePermissionGroups, usePermissionGroups,
useRemovePermissionGroupMember, useRemovePermissionGroupMember,
useUpdatePermissionGroup, useUpdatePermissionGroup,
} from '@/hooks/queries/permission-groups' } from '@/ee/access-control/hooks/permission-groups'
import type { PermissionGroupConfig } from '@/ee/access-control/lib/types'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
import { PROVIDER_DEFINITIONS } from '@/providers/models' import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { getAllProviderIds } from '@/providers/utils' import { getAllProviderIds } from '@/providers/utils'

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import type { PermissionGroupConfig } from '@/ee/access-control/lib/types'
import { fetchJson } from '@/hooks/selectors/helpers' import { fetchJson } from '@/hooks/selectors/helpers'
export interface PermissionGroup { export interface PermissionGroup {

View File

@@ -0,0 +1,26 @@
export { AccessControl } from './components/access-control'
export {
type BulkAddMembersData,
type CreatePermissionGroupData,
type DeletePermissionGroupParams,
type PermissionGroup,
type PermissionGroupMember,
permissionGroupKeys,
type UpdatePermissionGroupData,
type UserPermissionConfig,
useAddPermissionGroupMember,
useBulkAddPermissionGroupMembers,
useCreatePermissionGroup,
useDeletePermissionGroup,
usePermissionGroup,
usePermissionGroupMembers,
usePermissionGroups,
useRemovePermissionGroupMember,
useUpdatePermissionGroup,
useUserPermissionConfig,
} from './hooks/permission-groups'
export type { PermissionGroupConfig } from './lib/types'
export {
DEFAULT_PERMISSION_GROUP_CONFIG,
parsePermissionGroupConfig,
} from './lib/types'

34
apps/sim/ee/index.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Sim Enterprise Edition
*
* This module contains enterprise features that require a valid
* Sim Enterprise license for production use.
*
* See LICENSE in this directory for terms.
*/
export type { PermissionGroupConfig } from './access-control'
// Access Control (Permission Groups)
export {
AccessControl,
type BulkAddMembersData,
type CreatePermissionGroupData,
type DeletePermissionGroupParams,
type PermissionGroup,
type PermissionGroupMember,
permissionGroupKeys,
type UpdatePermissionGroupData,
type UserPermissionConfig,
useAddPermissionGroupMember,
useBulkAddPermissionGroupMembers,
useCreatePermissionGroup,
useDeletePermissionGroup,
usePermissionGroup,
usePermissionGroupMembers,
usePermissionGroups,
useRemovePermissionGroupMember,
useUpdatePermissionGroup,
useUserPermissionConfig,
} from './access-control'
// SSO (Single Sign-On)
export { SSO, ssoKeys, useConfigureSSO, useDeleteSSO, useSSOProviders } from './sso'

View File

@@ -11,55 +11,13 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { getUserRole } from '@/lib/workspaces/organization/utils' import { getUserRole } from '@/lib/workspaces/organization/utils'
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/lib/constants'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('SSO') const logger = createLogger('SSO')
const TRUSTED_SSO_PROVIDERS = [
'okta',
'okta-saml',
'okta-prod',
'okta-dev',
'okta-staging',
'okta-test',
'azure-ad',
'azure-active-directory',
'azure-corp',
'azure-enterprise',
'adfs',
'adfs-company',
'adfs-corp',
'adfs-enterprise',
'auth0',
'auth0-prod',
'auth0-dev',
'auth0-staging',
'onelogin',
'onelogin-prod',
'onelogin-corp',
'jumpcloud',
'jumpcloud-prod',
'jumpcloud-corp',
'ping-identity',
'ping-federate',
'pingone',
'shibboleth',
'shibboleth-idp',
'google-workspace',
'google-sso',
'saml',
'saml2',
'saml-sso',
'oidc',
'oidc-sso',
'openid-connect',
'custom-sso',
'enterprise-sso',
'company-sso',
]
interface SSOProvider { interface SSOProvider {
id: string id: string
providerId: string providerId: string
@@ -565,7 +523,7 @@ export function SSO() {
<Combobox <Combobox
value={formData.providerId} value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)} onChange={(value: string) => handleInputChange('providerId', value)}
options={TRUSTED_SSO_PROVIDERS.map((id) => ({ options={SSO_TRUSTED_PROVIDERS.map((id) => ({
label: id, label: id,
value: id, value: id,
}))} }))}

8
apps/sim/ee/sso/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export { SSO } from './components/sso'
export {
ssoKeys,
useConfigureSSO,
useDeleteSSO,
useSSOProviders,
} from './hooks/sso'
export * from './lib/constants'

View File

@@ -6,7 +6,6 @@ interface ChildWorkflowErrorOptions {
childWorkflowName: string childWorkflowName: string
childTraceSpans?: TraceSpan[] childTraceSpans?: TraceSpan[]
executionResult?: ExecutionResult executionResult?: ExecutionResult
childWorkflowSnapshotId?: string
cause?: Error cause?: Error
} }
@@ -17,7 +16,6 @@ export class ChildWorkflowError extends Error {
readonly childTraceSpans: TraceSpan[] readonly childTraceSpans: TraceSpan[]
readonly childWorkflowName: string readonly childWorkflowName: string
readonly executionResult?: ExecutionResult readonly executionResult?: ExecutionResult
readonly childWorkflowSnapshotId?: string
constructor(options: ChildWorkflowErrorOptions) { constructor(options: ChildWorkflowErrorOptions) {
super(options.message, { cause: options.cause }) super(options.message, { cause: options.cause })
@@ -25,7 +23,6 @@ export class ChildWorkflowError extends Error {
this.childWorkflowName = options.childWorkflowName this.childWorkflowName = options.childWorkflowName
this.childTraceSpans = options.childTraceSpans ?? [] this.childTraceSpans = options.childTraceSpans ?? []
this.executionResult = options.executionResult this.executionResult = options.executionResult
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
} }
static isChildWorkflowError(error: unknown): error is ChildWorkflowError { static isChildWorkflowError(error: unknown): error is ChildWorkflowError {

View File

@@ -237,9 +237,6 @@ export class BlockExecutor {
if (ChildWorkflowError.isChildWorkflowError(error)) { if (ChildWorkflowError.isChildWorkflowError(error)) {
errorOutput.childTraceSpans = error.childTraceSpans errorOutput.childTraceSpans = error.childTraceSpans
errorOutput.childWorkflowName = error.childWorkflowName errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
}
} }
this.state.setBlockOutput(node.id, errorOutput, duration) this.state.setBlockOutput(node.id, errorOutput, duration)

View File

@@ -198,7 +198,6 @@ describe('WorkflowBlockHandler', () => {
expect(result).toEqual({ expect(result).toEqual({
success: true, success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow', childWorkflowName: 'Child Workflow',
result: { data: 'test result' }, result: { data: 'test result' },
childTraceSpans: [], childTraceSpans: [],
@@ -236,7 +235,6 @@ describe('WorkflowBlockHandler', () => {
expect(result).toEqual({ expect(result).toEqual({
success: true, success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow', childWorkflowName: 'Child Workflow',
result: { nested: 'data' }, result: { nested: 'data' },
childTraceSpans: [], childTraceSpans: [],

View File

@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types' import type { TraceSpan } from '@/lib/logs/types'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
@@ -58,7 +57,6 @@ export class WorkflowBlockHandler implements BlockHandler {
const workflowMetadata = workflows[workflowId] const workflowMetadata = workflows[workflowId]
let childWorkflowName = workflowMetadata?.name || workflowId let childWorkflowName = workflowMetadata?.name || workflowId
let childWorkflowSnapshotId: string | undefined
try { try {
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1 const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) { if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
@@ -109,12 +107,6 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowInput = inputs.input childWorkflowInput = inputs.input
} }
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
)
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
const subExecutor = new Executor({ const subExecutor = new Executor({
workflow: childWorkflow.serializedState, workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput, workflowInput: childWorkflowInput,
@@ -147,8 +139,7 @@ export class WorkflowBlockHandler implements BlockHandler {
workflowId, workflowId,
childWorkflowName, childWorkflowName,
duration, duration,
childTraceSpans, childTraceSpans
childWorkflowSnapshotId
) )
return mappedResult return mappedResult
@@ -181,7 +172,6 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowName, childWorkflowName,
childTraceSpans, childTraceSpans,
executionResult, executionResult,
childWorkflowSnapshotId,
cause: error instanceof Error ? error : undefined, cause: error instanceof Error ? error : undefined,
}) })
} }
@@ -289,10 +279,6 @@ export class WorkflowBlockHandler implements BlockHandler {
) )
const workflowVariables = (workflowData.variables as Record<string, any>) || {} const workflowVariables = (workflowData.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...workflowState,
variables: workflowVariables,
}
if (Object.keys(workflowVariables).length > 0) { if (Object.keys(workflowVariables).length > 0) {
logger.info( logger.info(
@@ -304,7 +290,6 @@ export class WorkflowBlockHandler implements BlockHandler {
name: workflowData.name, name: workflowData.name,
serializedState: serializedWorkflow, serializedState: serializedWorkflow,
variables: workflowVariables, variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: workflowState.blocks, rawBlocks: workflowState.blocks,
} }
} }
@@ -373,16 +358,11 @@ export class WorkflowBlockHandler implements BlockHandler {
) )
const workflowVariables = (wfData?.variables as Record<string, any>) || {} const workflowVariables = (wfData?.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...deployedState,
variables: workflowVariables,
}
return { return {
name: wfData?.name || DEFAULTS.WORKFLOW_NAME, name: wfData?.name || DEFAULTS.WORKFLOW_NAME,
serializedState: serializedWorkflow, serializedState: serializedWorkflow,
variables: workflowVariables, variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: deployedState.blocks, rawBlocks: deployedState.blocks,
} }
} }
@@ -524,8 +504,7 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowId: string, childWorkflowId: string,
childWorkflowName: string, childWorkflowName: string,
duration: number, duration: number,
childTraceSpans?: WorkflowTraceSpan[], childTraceSpans?: WorkflowTraceSpan[]
childWorkflowSnapshotId?: string
): BlockOutput { ): BlockOutput {
const success = childResult.success !== false const success = childResult.success !== false
const result = childResult.output || {} const result = childResult.output || {}
@@ -536,15 +515,12 @@ export class WorkflowBlockHandler implements BlockHandler {
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`, message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
childWorkflowName, childWorkflowName,
childTraceSpans: childTraceSpans || [], childTraceSpans: childTraceSpans || [],
childWorkflowSnapshotId,
}) })
} }
return { return {
success: true, success: true,
childWorkflowName, childWorkflowName,
childWorkflowId,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
result, result,
childTraceSpans: childTraceSpans || [], childTraceSpans: childTraceSpans || [],
} as Record<string, any> } as Record<string, any>

View File

@@ -1,6 +1,6 @@
import type { TraceSpan } from '@/lib/logs/types' import type { TraceSpan } from '@/lib/logs/types'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import type { PermissionGroupConfig } from '@/ee/access-control/lib/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block' import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'

View File

@@ -7,7 +7,7 @@ import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flag
import { import {
type PermissionGroupConfig, type PermissionGroupConfig,
parsePermissionGroupConfig, parsePermissionGroupConfig,
} from '@/lib/permission-groups/types' } from '@/ee/access-control/lib/types'
import type { ExecutionContext } from '@/executor/types' import type { ExecutionContext } from '@/executor/types'
import { getProviderFromModel } from '@/providers/utils' import { getProviderFromModel } from '@/providers/utils'

View File

@@ -210,7 +210,6 @@ export interface ExecutionSnapshotData {
executionId: string executionId: string
workflowId: string workflowId: string
workflowState: Record<string, unknown> workflowState: Record<string, unknown>
childWorkflowSnapshots?: Record<string, Record<string, unknown>>
executionMetadata: { executionMetadata: {
trigger: string trigger: string
startedAt: string startedAt: string

View File

@@ -1,12 +1,12 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { import {
DEFAULT_PERMISSION_GROUP_CONFIG, DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig, type PermissionGroupConfig,
} from '@/lib/permission-groups/types' } from '@/ee/access-control/lib/types'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
export interface PermissionConfigResult { export interface PermissionConfigResult {
config: PermissionGroupConfig config: PermissionGroupConfig

View File

@@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/lib/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth') const logger = createLogger('Auth')

View File

@@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -15,6 +14,7 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry' import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type { PermissionGroupConfig } from '@/ee/access-control/lib/types'
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'

View File

@@ -50,8 +50,6 @@ function prepareLogData(
export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): Promise<void> { export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): Promise<void> {
try { try {
if (!log.workflowId) return
const workflowData = await db const workflowData = await db
.select({ workspaceId: workflow.workspaceId }) .select({ workspaceId: workflow.workspaceId })
.from(workflow) .from(workflow)

View File

@@ -293,10 +293,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
} }
try { try {
// Skip workflow lookup if workflow was deleted const [wf] = await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId))
const wf = updatedLog.workflowId
? (await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId)))[0]
: undefined
if (wf) { if (wf) {
const [usr] = await db const [usr] = await db
.select({ id: userTable.id, email: userTable.email, name: userTable.name }) .select({ id: userTable.id, email: userTable.email, name: userTable.name })
@@ -464,7 +461,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
* Maintains same logic as original execution logger for billing consistency * Maintains same logic as original execution logger for billing consistency
*/ */
private async updateUserStats( private async updateUserStats(
workflowId: string | null, workflowId: string,
costSummary: { costSummary: {
totalCost: number totalCost: number
totalInputCost: number totalInputCost: number
@@ -497,11 +494,6 @@ export class ExecutionLogger implements IExecutionLoggerService {
return return
} }
if (!workflowId) {
logger.debug('Workflow was deleted, skipping user stats update')
return
}
try { try {
// Get the workflow record to get the userId // Get the workflow record to get the userId
const [workflowRecord] = await db const [workflowRecord] = await db

View File

@@ -1,8 +1,8 @@
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { workflowExecutionSnapshots } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, lt, notExists } from 'drizzle-orm' import { and, eq, lt } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import type { import type {
SnapshotService as ISnapshotService, SnapshotService as ISnapshotService,
@@ -121,17 +121,7 @@ export class SnapshotService implements ISnapshotService {
const deletedSnapshots = await db const deletedSnapshots = await db
.delete(workflowExecutionSnapshots) .delete(workflowExecutionSnapshots)
.where( .where(lt(workflowExecutionSnapshots.createdAt, cutoffDate))
and(
lt(workflowExecutionSnapshots.createdAt, cutoffDate),
notExists(
db
.select({ id: workflowExecutionLogs.id })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id))
)
)
)
.returning({ id: workflowExecutionSnapshots.id }) .returning({ id: workflowExecutionSnapshots.id })
const deletedCount = deletedSnapshots.length const deletedCount = deletedSnapshots.length

View File

@@ -112,26 +112,6 @@ export function buildTraceSpans(result: ExecutionResult): {
const duration = log.durationMs || 0 const duration = log.durationMs || 0
let output = log.output || {} let output = log.output || {}
let childWorkflowSnapshotId: string | undefined
let childWorkflowId: string | undefined
if (output && typeof output === 'object') {
const outputRecord = output as Record<string, unknown>
childWorkflowSnapshotId =
typeof outputRecord.childWorkflowSnapshotId === 'string'
? outputRecord.childWorkflowSnapshotId
: undefined
childWorkflowId =
typeof outputRecord.childWorkflowId === 'string' ? outputRecord.childWorkflowId : undefined
if (childWorkflowSnapshotId || childWorkflowId) {
const {
childWorkflowSnapshotId: _childSnapshotId,
childWorkflowId: _childWorkflowId,
...outputRest
} = outputRecord
output = outputRest
}
}
if (log.error) { if (log.error) {
output = { output = {
@@ -154,8 +134,6 @@ export function buildTraceSpans(result: ExecutionResult): {
blockId: log.blockId, blockId: log.blockId,
input: log.input || {}, input: log.input || {},
output: output, output: output,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
...(childWorkflowId ? { childWorkflowId } : {}),
...(log.loopId && { loopId: log.loopId }), ...(log.loopId && { loopId: log.loopId }),
...(log.parallelId && { parallelId: log.parallelId }), ...(log.parallelId && { parallelId: log.parallelId }),
...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }), ...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }),

View File

@@ -69,7 +69,7 @@ export interface ExecutionStatus {
export interface WorkflowExecutionSnapshot { export interface WorkflowExecutionSnapshot {
id: string id: string
workflowId: string | null workflowId: string
stateHash: string stateHash: string
stateData: WorkflowState stateData: WorkflowState
createdAt: string createdAt: string
@@ -80,7 +80,7 @@ export type WorkflowExecutionSnapshotSelect = WorkflowExecutionSnapshot
export interface WorkflowExecutionLog { export interface WorkflowExecutionLog {
id: string id: string
workflowId: string | null workflowId: string
executionId: string executionId: string
stateSnapshotId: string stateSnapshotId: string
level: 'info' | 'error' level: 'info' | 'error'
@@ -178,8 +178,6 @@ export interface TraceSpan {
blockId?: string blockId?: string
input?: Record<string, unknown> input?: Record<string, unknown>
output?: Record<string, unknown> output?: Record<string, unknown>
childWorkflowSnapshotId?: string
childWorkflowId?: string
model?: string model?: string
cost?: { cost?: {
input?: number input?: number

View File

@@ -102,7 +102,7 @@ export interface TraceSpan {
export interface WorkflowLog { export interface WorkflowLog {
id: string id: string
workflowId: string | null workflowId: string
executionId?: string | null executionId?: string | null
deploymentVersion?: number | null deploymentVersion?: number | null
deploymentVersionName?: string | null deploymentVersionName?: string | null

View File

@@ -1648,8 +1648,6 @@ import {
youtubeCommentsTool, youtubeCommentsTool,
youtubePlaylistItemsTool, youtubePlaylistItemsTool,
youtubeSearchTool, youtubeSearchTool,
youtubeTrendingTool,
youtubeVideoCategoriesTool,
youtubeVideoDetailsTool, youtubeVideoDetailsTool,
} from '@/tools/youtube' } from '@/tools/youtube'
import { import {
@@ -1984,15 +1982,13 @@ export const tools: Record<string, ToolConfig> = {
typeform_create_form: typeformCreateFormTool, typeform_create_form: typeformCreateFormTool,
typeform_update_form: typeformUpdateFormTool, typeform_update_form: typeformUpdateFormTool,
typeform_delete_form: typeformDeleteFormTool, typeform_delete_form: typeformDeleteFormTool,
youtube_channel_info: youtubeChannelInfoTool,
youtube_channel_playlists: youtubeChannelPlaylistsTool,
youtube_channel_videos: youtubeChannelVideosTool,
youtube_comments: youtubeCommentsTool,
youtube_playlist_items: youtubePlaylistItemsTool,
youtube_search: youtubeSearchTool, youtube_search: youtubeSearchTool,
youtube_trending: youtubeTrendingTool,
youtube_video_categories: youtubeVideoCategoriesTool,
youtube_video_details: youtubeVideoDetailsTool, youtube_video_details: youtubeVideoDetailsTool,
youtube_channel_info: youtubeChannelInfoTool,
youtube_playlist_items: youtubePlaylistItemsTool,
youtube_comments: youtubeCommentsTool,
youtube_channel_videos: youtubeChannelVideosTool,
youtube_channel_playlists: youtubeChannelPlaylistsTool,
notion_read: notionReadTool, notion_read: notionReadTool,
notion_read_database: notionReadDatabaseTool, notion_read_database: notionReadDatabaseTool,
notion_write: notionWriteTool, notion_write: notionWriteTool,

View File

@@ -7,9 +7,8 @@ export const youtubeChannelInfoTool: ToolConfig<
> = { > = {
id: 'youtube_channel_info', id: 'youtube_channel_info',
name: 'YouTube Channel Info', name: 'YouTube Channel Info',
description: description: 'Get detailed information about a YouTube channel.',
'Get detailed information about a YouTube channel including statistics, branding, and content details.', version: '1.0.0',
version: '1.1.0',
params: { params: {
channelId: { channelId: {
type: 'string', type: 'string',
@@ -34,11 +33,11 @@ export const youtubeChannelInfoTool: ToolConfig<
request: { request: {
url: (params: YouTubeChannelInfoParams) => { url: (params: YouTubeChannelInfoParams) => {
let url = let url =
'https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics,contentDetails,brandingSettings' 'https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics,contentDetails'
if (params.channelId) { if (params.channelId) {
url += `&id=${encodeURIComponent(params.channelId)}` url += `&id=${params.channelId}`
} else if (params.username) { } else if (params.username) {
url += `&forUsername=${encodeURIComponent(params.username)}` url += `&forUsername=${params.username}`
} }
url += `&key=${params.apiKey}` url += `&key=${params.apiKey}`
return url return url
@@ -64,11 +63,6 @@ export const youtubeChannelInfoTool: ToolConfig<
viewCount: 0, viewCount: 0,
publishedAt: '', publishedAt: '',
thumbnail: '', thumbnail: '',
customUrl: null,
country: null,
uploadsPlaylistId: null,
bannerImageUrl: null,
hiddenSubscriberCount: false,
}, },
error: 'Channel not found', error: 'Channel not found',
} }
@@ -78,23 +72,19 @@ export const youtubeChannelInfoTool: ToolConfig<
return { return {
success: true, success: true,
output: { output: {
channelId: item.id ?? '', channelId: item.id,
title: item.snippet?.title ?? '', title: item.snippet?.title || '',
description: item.snippet?.description ?? '', description: item.snippet?.description || '',
subscriberCount: Number(item.statistics?.subscriberCount || 0), subscriberCount: Number(item.statistics?.subscriberCount || 0),
videoCount: Number(item.statistics?.videoCount || 0), videoCount: Number(item.statistics?.videoCount || 0),
viewCount: Number(item.statistics?.viewCount || 0), viewCount: Number(item.statistics?.viewCount || 0),
publishedAt: item.snippet?.publishedAt ?? '', publishedAt: item.snippet?.publishedAt || '',
thumbnail: thumbnail:
item.snippet?.thumbnails?.high?.url || item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url || item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url || item.snippet?.thumbnails?.default?.url ||
'', '',
customUrl: item.snippet?.customUrl ?? null, customUrl: item.snippet?.customUrl,
country: item.snippet?.country ?? null,
uploadsPlaylistId: item.contentDetails?.relatedPlaylists?.uploads ?? null,
bannerImageUrl: item.brandingSettings?.image?.bannerExternalUrl ?? null,
hiddenSubscriberCount: item.statistics?.hiddenSubscriberCount ?? false,
}, },
} }
}, },
@@ -114,11 +104,11 @@ export const youtubeChannelInfoTool: ToolConfig<
}, },
subscriberCount: { subscriberCount: {
type: 'number', type: 'number',
description: 'Number of subscribers (0 if hidden)', description: 'Number of subscribers',
}, },
videoCount: { videoCount: {
type: 'number', type: 'number',
description: 'Number of public videos', description: 'Number of videos',
}, },
viewCount: { viewCount: {
type: 'number', type: 'number',
@@ -130,31 +120,12 @@ export const youtubeChannelInfoTool: ToolConfig<
}, },
thumbnail: { thumbnail: {
type: 'string', type: 'string',
description: 'Channel thumbnail/avatar URL', description: 'Channel thumbnail URL',
}, },
customUrl: { customUrl: {
type: 'string', type: 'string',
description: 'Channel custom URL (handle)', description: 'Channel custom URL',
optional: true, optional: true,
}, },
country: {
type: 'string',
description: 'Country the channel is associated with',
optional: true,
},
uploadsPlaylistId: {
type: 'string',
description: 'Playlist ID containing all channel uploads (use with playlist_items)',
optional: true,
},
bannerImageUrl: {
type: 'string',
description: 'Channel banner image URL',
optional: true,
},
hiddenSubscriberCount: {
type: 'boolean',
description: 'Whether the subscriber count is hidden',
},
}, },
} }

View File

@@ -10,8 +10,8 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
> = { > = {
id: 'youtube_channel_playlists', id: 'youtube_channel_playlists',
name: 'YouTube Channel Playlists', name: 'YouTube Channel Playlists',
description: 'Get all public playlists from a specific YouTube channel.', description: 'Get all playlists from a specific YouTube channel.',
version: '1.1.0', version: '1.0.0',
params: { params: {
channelId: { channelId: {
type: 'string', type: 'string',
@@ -47,7 +47,7 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
)}&key=${params.apiKey}` )}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}` url += `&maxResults=${Number(params.maxResults || 10)}`
if (params.pageToken) { if (params.pageToken) {
url += `&pageToken=${encodeURIComponent(params.pageToken)}` url += `&pageToken=${params.pageToken}`
} }
return url return url
}, },
@@ -60,49 +60,36 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
transformResponse: async (response: Response): Promise<YouTubeChannelPlaylistsResponse> => { transformResponse: async (response: Response): Promise<YouTubeChannelPlaylistsResponse> => {
const data = await response.json() const data = await response.json()
if (data.error) { if (!data.items) {
return { return {
success: false, success: false,
output: { output: {
items: [], items: [],
totalResults: 0, totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch channel playlists',
}
}
if (!data.items || data.items.length === 0) {
return {
success: true,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
}, },
error: 'No playlists found',
} }
} }
const items = (data.items || []).map((item: any) => ({ const items = (data.items || []).map((item: any) => ({
playlistId: item.id ?? '', playlistId: item.id,
title: item.snippet?.title ?? '', title: item.snippet?.title || '',
description: item.snippet?.description ?? '', description: item.snippet?.description || '',
thumbnail: thumbnail:
item.snippet?.thumbnails?.medium?.url || item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url || item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.high?.url || item.snippet?.thumbnails?.high?.url ||
'', '',
itemCount: Number(item.contentDetails?.itemCount || 0), itemCount: item.contentDetails?.itemCount || 0,
publishedAt: item.snippet?.publishedAt ?? '', publishedAt: item.snippet?.publishedAt || '',
channelTitle: item.snippet?.channelTitle ?? '',
})) }))
return { return {
success: true, success: true,
output: { output: {
items, items,
totalResults: data.pageInfo?.totalResults || items.length, totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken ?? null, nextPageToken: data.nextPageToken,
}, },
} }
}, },
@@ -120,7 +107,6 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
thumbnail: { type: 'string', description: 'Playlist thumbnail URL' }, thumbnail: { type: 'string', description: 'Playlist thumbnail URL' },
itemCount: { type: 'number', description: 'Number of videos in playlist' }, itemCount: { type: 'number', description: 'Number of videos in playlist' },
publishedAt: { type: 'string', description: 'Playlist creation date' }, publishedAt: { type: 'string', description: 'Playlist creation date' },
channelTitle: { type: 'string', description: 'Channel name' },
}, },
}, },
}, },

View File

@@ -10,9 +10,8 @@ export const youtubeChannelVideosTool: ToolConfig<
> = { > = {
id: 'youtube_channel_videos', id: 'youtube_channel_videos',
name: 'YouTube Channel Videos', name: 'YouTube Channel Videos',
description: description: 'Get all videos from a specific YouTube channel, with sorting options.',
'Search for videos from a specific YouTube channel with sorting options. For complete channel video list, use channel_info to get uploadsPlaylistId, then use playlist_items.', version: '1.0.0',
version: '1.1.0',
params: { params: {
channelId: { channelId: {
type: 'string', type: 'string',
@@ -31,8 +30,7 @@ export const youtubeChannelVideosTool: ToolConfig<
type: 'string', type: 'string',
required: false, required: false,
visibility: 'user-or-llm', visibility: 'user-or-llm',
description: description: 'Sort order: "date" (newest first), "rating", "relevance", "title", "viewCount"',
'Sort order: "date" (newest first, default), "rating", "relevance", "title", "viewCount"',
}, },
pageToken: { pageToken: {
type: 'string', type: 'string',
@@ -54,9 +52,11 @@ export const youtubeChannelVideosTool: ToolConfig<
params.channelId params.channelId
)}&key=${params.apiKey}` )}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}` url += `&maxResults=${Number(params.maxResults || 10)}`
url += `&order=${params.order || 'date'}` if (params.order) {
url += `&order=${params.order}`
}
if (params.pageToken) { if (params.pageToken) {
url += `&pageToken=${encodeURIComponent(params.pageToken)}` url += `&pageToken=${params.pageToken}`
} }
return url return url
}, },
@@ -68,38 +68,23 @@ export const youtubeChannelVideosTool: ToolConfig<
transformResponse: async (response: Response): Promise<YouTubeChannelVideosResponse> => { transformResponse: async (response: Response): Promise<YouTubeChannelVideosResponse> => {
const data = await response.json() const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch channel videos',
}
}
const items = (data.items || []).map((item: any) => ({ const items = (data.items || []).map((item: any) => ({
videoId: item.id?.videoId ?? '', videoId: item.id?.videoId,
title: item.snippet?.title ?? '', title: item.snippet?.title,
description: item.snippet?.description ?? '', description: item.snippet?.description,
thumbnail: thumbnail:
item.snippet?.thumbnails?.medium?.url || item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url || item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.high?.url || item.snippet?.thumbnails?.high?.url ||
'', '',
publishedAt: item.snippet?.publishedAt ?? '', publishedAt: item.snippet?.publishedAt || '',
channelTitle: item.snippet?.channelTitle ?? '',
})) }))
return { return {
success: true, success: true,
output: { output: {
items, items,
totalResults: data.pageInfo?.totalResults || items.length, totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken ?? null, nextPageToken: data.nextPageToken,
}, },
} }
}, },
@@ -116,7 +101,6 @@ export const youtubeChannelVideosTool: ToolConfig<
description: { type: 'string', description: 'Video description' }, description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' }, thumbnail: { type: 'string', description: 'Video thumbnail URL' },
publishedAt: { type: 'string', description: 'Video publish date' }, publishedAt: { type: 'string', description: 'Video publish date' },
channelTitle: { type: 'string', description: 'Channel name' },
}, },
}, },
}, },

View File

@@ -4,8 +4,8 @@ import type { YouTubeCommentsParams, YouTubeCommentsResponse } from '@/tools/you
export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeCommentsResponse> = { export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeCommentsResponse> = {
id: 'youtube_comments', id: 'youtube_comments',
name: 'YouTube Video Comments', name: 'YouTube Video Comments',
description: 'Get top-level comments from a YouTube video with author details and engagement.', description: 'Get comments from a YouTube video.',
version: '1.1.0', version: '1.0.0',
params: { params: {
videoId: { videoId: {
type: 'string', type: 'string',
@@ -18,14 +18,14 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
required: false, required: false,
visibility: 'user-only', visibility: 'user-only',
default: 20, default: 20,
description: 'Maximum number of comments to return (1-100)', description: 'Maximum number of comments to return',
}, },
order: { order: {
type: 'string', type: 'string',
required: false, required: false,
visibility: 'user-or-llm', visibility: 'user-only',
default: 'relevance', default: 'relevance',
description: 'Order of comments: "time" (newest first) or "relevance" (most relevant first)', description: 'Order of comments: time or relevance',
}, },
pageToken: { pageToken: {
type: 'string', type: 'string',
@@ -43,11 +43,11 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
request: { request: {
url: (params: YouTubeCommentsParams) => { url: (params: YouTubeCommentsParams) => {
let url = `https://www.googleapis.com/youtube/v3/commentThreads?part=snippet,replies&videoId=${encodeURIComponent(params.videoId)}&key=${params.apiKey}` let url = `https://www.googleapis.com/youtube/v3/commentThreads?part=snippet,replies&videoId=${params.videoId}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 20)}` url += `&maxResults=${Number(params.maxResults || 20)}`
url += `&order=${params.order || 'relevance'}` url += `&order=${params.order || 'relevance'}`
if (params.pageToken) { if (params.pageToken) {
url += `&pageToken=${encodeURIComponent(params.pageToken)}` url += `&pageToken=${params.pageToken}`
} }
return url return url
}, },
@@ -60,31 +60,18 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
transformResponse: async (response: Response): Promise<YouTubeCommentsResponse> => { transformResponse: async (response: Response): Promise<YouTubeCommentsResponse> => {
const data = await response.json() const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch comments',
}
}
const items = (data.items || []).map((item: any) => { const items = (data.items || []).map((item: any) => {
const topLevelComment = item.snippet?.topLevelComment?.snippet const topLevelComment = item.snippet?.topLevelComment?.snippet
return { return {
commentId: item.snippet?.topLevelComment?.id ?? item.id ?? '', commentId: item.snippet?.topLevelComment?.id || item.id,
authorDisplayName: topLevelComment?.authorDisplayName ?? '', authorDisplayName: topLevelComment?.authorDisplayName || '',
authorChannelUrl: topLevelComment?.authorChannelUrl ?? '', authorChannelUrl: topLevelComment?.authorChannelUrl || '',
authorProfileImageUrl: topLevelComment?.authorProfileImageUrl ?? '', textDisplay: topLevelComment?.textDisplay || '',
textDisplay: topLevelComment?.textDisplay ?? '', textOriginal: topLevelComment?.textOriginal || '',
textOriginal: topLevelComment?.textOriginal ?? '', likeCount: topLevelComment?.likeCount || 0,
likeCount: Number(topLevelComment?.likeCount || 0), publishedAt: topLevelComment?.publishedAt || '',
publishedAt: topLevelComment?.publishedAt ?? '', updatedAt: topLevelComment?.updatedAt || '',
updatedAt: topLevelComment?.updatedAt ?? '', replyCount: item.snippet?.totalReplyCount || 0,
replyCount: Number(item.snippet?.totalReplyCount || 0),
} }
}) })
@@ -92,8 +79,8 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
success: true, success: true,
output: { output: {
items, items,
totalResults: data.pageInfo?.totalResults || items.length, totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken ?? null, nextPageToken: data.nextPageToken,
}, },
} }
}, },
@@ -101,29 +88,25 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
outputs: { outputs: {
items: { items: {
type: 'array', type: 'array',
description: 'Array of top-level comments from the video', description: 'Array of comments from the video',
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
commentId: { type: 'string', description: 'Comment ID' }, commentId: { type: 'string', description: 'Comment ID' },
authorDisplayName: { type: 'string', description: 'Comment author display name' }, authorDisplayName: { type: 'string', description: 'Comment author name' },
authorChannelUrl: { type: 'string', description: 'Comment author channel URL' }, authorChannelUrl: { type: 'string', description: 'Comment author channel URL' },
authorProfileImageUrl: {
type: 'string',
description: 'Comment author profile image URL',
},
textDisplay: { type: 'string', description: 'Comment text (HTML formatted)' }, textDisplay: { type: 'string', description: 'Comment text (HTML formatted)' },
textOriginal: { type: 'string', description: 'Comment text (plain text)' }, textOriginal: { type: 'string', description: 'Comment text (plain text)' },
likeCount: { type: 'number', description: 'Number of likes on the comment' }, likeCount: { type: 'number', description: 'Number of likes' },
publishedAt: { type: 'string', description: 'When the comment was posted' }, publishedAt: { type: 'string', description: 'Comment publish date' },
updatedAt: { type: 'string', description: 'When the comment was last edited' }, updatedAt: { type: 'string', description: 'Comment last updated date' },
replyCount: { type: 'number', description: 'Number of replies to this comment' }, replyCount: { type: 'number', description: 'Number of replies', optional: true },
}, },
}, },
}, },
totalResults: { totalResults: {
type: 'number', type: 'number',
description: 'Total number of comment threads available', description: 'Total number of comments',
}, },
nextPageToken: { nextPageToken: {
type: 'string', type: 'string',

View File

@@ -4,8 +4,6 @@ import { youtubeChannelVideosTool } from '@/tools/youtube/channel_videos'
import { youtubeCommentsTool } from '@/tools/youtube/comments' import { youtubeCommentsTool } from '@/tools/youtube/comments'
import { youtubePlaylistItemsTool } from '@/tools/youtube/playlist_items' import { youtubePlaylistItemsTool } from '@/tools/youtube/playlist_items'
import { youtubeSearchTool } from '@/tools/youtube/search' import { youtubeSearchTool } from '@/tools/youtube/search'
import { youtubeTrendingTool } from '@/tools/youtube/trending'
import { youtubeVideoCategoriesTool } from '@/tools/youtube/video_categories'
import { youtubeVideoDetailsTool } from '@/tools/youtube/video_details' import { youtubeVideoDetailsTool } from '@/tools/youtube/video_details'
export { youtubeSearchTool } export { youtubeSearchTool }
@@ -15,5 +13,3 @@ export { youtubePlaylistItemsTool }
export { youtubeCommentsTool } export { youtubeCommentsTool }
export { youtubeChannelVideosTool } export { youtubeChannelVideosTool }
export { youtubeChannelPlaylistsTool } export { youtubeChannelPlaylistsTool }
export { youtubeTrendingTool }
export { youtubeVideoCategoriesTool }

View File

@@ -10,23 +10,21 @@ export const youtubePlaylistItemsTool: ToolConfig<
> = { > = {
id: 'youtube_playlist_items', id: 'youtube_playlist_items',
name: 'YouTube Playlist Items', name: 'YouTube Playlist Items',
description: description: 'Get videos from a YouTube playlist.',
'Get videos from a YouTube playlist. Can be used with a channel uploads playlist to get all channel videos.', version: '1.0.0',
version: '1.1.0',
params: { params: {
playlistId: { playlistId: {
type: 'string', type: 'string',
required: true, required: true,
visibility: 'user-or-llm', visibility: 'user-or-llm',
description: description: 'YouTube playlist ID',
'YouTube playlist ID. Use uploadsPlaylistId from channel_info to get all channel videos.',
}, },
maxResults: { maxResults: {
type: 'number', type: 'number',
required: false, required: false,
visibility: 'user-only', visibility: 'user-only',
default: 10, default: 10,
description: 'Maximum number of videos to return (1-50)', description: 'Maximum number of videos to return',
}, },
pageToken: { pageToken: {
type: 'string', type: 'string',
@@ -44,10 +42,10 @@ export const youtubePlaylistItemsTool: ToolConfig<
request: { request: {
url: (params: YouTubePlaylistItemsParams) => { url: (params: YouTubePlaylistItemsParams) => {
let url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=${encodeURIComponent(params.playlistId)}&key=${params.apiKey}` let url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=${params.playlistId}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}` url += `&maxResults=${Number(params.maxResults || 10)}`
if (params.pageToken) { if (params.pageToken) {
url += `&pageToken=${encodeURIComponent(params.pageToken)}` url += `&pageToken=${params.pageToken}`
} }
return url return url
}, },
@@ -60,40 +58,26 @@ export const youtubePlaylistItemsTool: ToolConfig<
transformResponse: async (response: Response): Promise<YouTubePlaylistItemsResponse> => { transformResponse: async (response: Response): Promise<YouTubePlaylistItemsResponse> => {
const data = await response.json() const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch playlist items',
}
}
const items = (data.items || []).map((item: any, index: number) => ({ const items = (data.items || []).map((item: any, index: number) => ({
videoId: item.contentDetails?.videoId ?? item.snippet?.resourceId?.videoId ?? '', videoId: item.contentDetails?.videoId || item.snippet?.resourceId?.videoId,
title: item.snippet?.title ?? '', title: item.snippet?.title || '',
description: item.snippet?.description ?? '', description: item.snippet?.description || '',
thumbnail: thumbnail:
item.snippet?.thumbnails?.medium?.url || item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url || item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.high?.url || item.snippet?.thumbnails?.high?.url ||
'', '',
publishedAt: item.snippet?.publishedAt ?? '', publishedAt: item.snippet?.publishedAt || '',
channelTitle: item.snippet?.channelTitle ?? '', channelTitle: item.snippet?.channelTitle || '',
position: item.snippet?.position ?? index, position: item.snippet?.position ?? index,
videoOwnerChannelId: item.snippet?.videoOwnerChannelId ?? null,
videoOwnerChannelTitle: item.snippet?.videoOwnerChannelTitle ?? null,
})) }))
return { return {
success: true, success: true,
output: { output: {
items, items,
totalResults: data.pageInfo?.totalResults || items.length, totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken ?? null, nextPageToken: data.nextPageToken,
}, },
} }
}, },
@@ -110,18 +94,8 @@ export const youtubePlaylistItemsTool: ToolConfig<
description: { type: 'string', description: 'Video description' }, description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' }, thumbnail: { type: 'string', description: 'Video thumbnail URL' },
publishedAt: { type: 'string', description: 'Date added to playlist' }, publishedAt: { type: 'string', description: 'Date added to playlist' },
channelTitle: { type: 'string', description: 'Playlist owner channel name' }, channelTitle: { type: 'string', description: 'Channel name' },
position: { type: 'number', description: 'Position in playlist (0-indexed)' }, position: { type: 'number', description: 'Position in playlist' },
videoOwnerChannelId: {
type: 'string',
description: 'Channel ID of the video owner',
optional: true,
},
videoOwnerChannelTitle: {
type: 'string',
description: 'Channel name of the video owner',
optional: true,
},
}, },
}, },
}, },

View File

@@ -5,8 +5,8 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
id: 'youtube_search', id: 'youtube_search',
name: 'YouTube Search', name: 'YouTube Search',
description: description:
'Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, live streams, and more.', 'Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, and more.',
version: '1.2.0', version: '1.0.0',
params: { params: {
query: { query: {
type: 'string', type: 'string',
@@ -21,18 +21,13 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
default: 5, default: 5,
description: 'Maximum number of videos to return (1-50)', description: 'Maximum number of videos to return (1-50)',
}, },
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination (use nextPageToken from previous response)',
},
apiKey: { apiKey: {
type: 'string', type: 'string',
required: true, required: true,
visibility: 'user-only', visibility: 'user-only',
description: 'YouTube API Key', description: 'YouTube API Key',
}, },
// Priority 1: Essential filters
channelId: { channelId: {
type: 'string', type: 'string',
required: false, required: false,
@@ -71,9 +66,9 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
type: 'string', type: 'string',
required: false, required: false,
visibility: 'user-or-llm', visibility: 'user-or-llm',
description: description: 'Filter by YouTube category ID (e.g., "10" for Music, "20" for Gaming)',
'Filter by YouTube category ID (e.g., "10" for Music, "20" for Gaming). Use video_categories to list IDs.',
}, },
// Priority 2: Very useful filters
videoDefinition: { videoDefinition: {
type: 'string', type: 'string',
required: false, required: false,
@@ -87,13 +82,6 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
description: description:
'Filter by caption availability: "closedCaption" (has captions), "none" (no captions), "any"', 'Filter by caption availability: "closedCaption" (has captions), "none" (no captions), "any"',
}, },
eventType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by live broadcast status: "live" (currently live), "upcoming" (scheduled), "completed" (past streams)',
},
regionCode: { regionCode: {
type: 'string', type: 'string',
required: false, required: false,
@@ -122,9 +110,7 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
)}` )}`
url += `&maxResults=${Number(params.maxResults || 5)}` url += `&maxResults=${Number(params.maxResults || 5)}`
if (params.pageToken) { // Add Priority 1 filters if provided
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
if (params.channelId) { if (params.channelId) {
url += `&channelId=${encodeURIComponent(params.channelId)}` url += `&channelId=${encodeURIComponent(params.channelId)}`
} }
@@ -143,15 +129,14 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
if (params.videoCategoryId) { if (params.videoCategoryId) {
url += `&videoCategoryId=${params.videoCategoryId}` url += `&videoCategoryId=${params.videoCategoryId}`
} }
// Add Priority 2 filters if provided
if (params.videoDefinition) { if (params.videoDefinition) {
url += `&videoDefinition=${params.videoDefinition}` url += `&videoDefinition=${params.videoDefinition}`
} }
if (params.videoCaption) { if (params.videoCaption) {
url += `&videoCaption=${params.videoCaption}` url += `&videoCaption=${params.videoCaption}`
} }
if (params.eventType) {
url += `&eventType=${params.eventType}`
}
if (params.regionCode) { if (params.regionCode) {
url += `&regionCode=${params.regionCode}` url += `&regionCode=${params.regionCode}`
} }
@@ -172,39 +157,22 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
transformResponse: async (response: Response): Promise<YouTubeSearchResponse> => { transformResponse: async (response: Response): Promise<YouTubeSearchResponse> => {
const data = await response.json() const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Search failed',
}
}
const items = (data.items || []).map((item: any) => ({ const items = (data.items || []).map((item: any) => ({
videoId: item.id?.videoId ?? '', videoId: item.id?.videoId,
title: item.snippet?.title ?? '', title: item.snippet?.title,
description: item.snippet?.description ?? '', description: item.snippet?.description,
thumbnail: thumbnail:
item.snippet?.thumbnails?.default?.url || item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.medium?.url || item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.high?.url || item.snippet?.thumbnails?.high?.url ||
'', '',
channelId: item.snippet?.channelId ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
publishedAt: item.snippet?.publishedAt ?? '',
liveBroadcastContent: item.snippet?.liveBroadcastContent ?? 'none',
})) }))
return { return {
success: true, success: true,
output: { output: {
items, items,
totalResults: data.pageInfo?.totalResults || 0, totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken ?? null, nextPageToken: data.nextPageToken,
}, },
} }
}, },
@@ -220,13 +188,6 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
title: { type: 'string', description: 'Video title' }, title: { type: 'string', description: 'Video title' },
description: { type: 'string', description: 'Video description' }, description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' }, thumbnail: { type: 'string', description: 'Video thumbnail URL' },
channelId: { type: 'string', description: 'Channel ID that uploaded the video' },
channelTitle: { type: 'string', description: 'Channel name' },
publishedAt: { type: 'string', description: 'Video publish date' },
liveBroadcastContent: {
type: 'string',
description: 'Live broadcast status: "none", "live", or "upcoming"',
},
}, },
}, },
}, },

View File

@@ -1,139 +0,0 @@
import type { ToolConfig } from '@/tools/types'
import type { YouTubeTrendingParams, YouTubeTrendingResponse } from '@/tools/youtube/types'
export const youtubeTrendingTool: ToolConfig<YouTubeTrendingParams, YouTubeTrendingResponse> = {
id: 'youtube_trending',
name: 'YouTube Trending Videos',
description:
'Get the most popular/trending videos on YouTube. Can filter by region and video category.',
version: '1.0.0',
params: {
regionCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'ISO 3166-1 alpha-2 country code to get trending videos for (e.g., "US", "GB", "JP"). Defaults to US.',
},
videoCategoryId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by video category ID (e.g., "10" for Music, "20" for Gaming, "17" for Sports)',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-only',
default: 10,
description: 'Maximum number of trending videos to return (1-50)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'YouTube API Key',
},
},
request: {
url: (params: YouTubeTrendingParams) => {
let url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics,contentDetails&chart=mostPopular&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}`
url += `&regionCode=${params.regionCode || 'US'}`
if (params.videoCategoryId) {
url += `&videoCategoryId=${params.videoCategoryId}`
}
if (params.pageToken) {
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
return url
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response): Promise<YouTubeTrendingResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch trending videos',
}
}
const items = (data.items || []).map((item: any) => ({
videoId: item.id ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
thumbnail:
item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url ||
'',
channelId: item.snippet?.channelId ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
publishedAt: item.snippet?.publishedAt ?? '',
viewCount: Number(item.statistics?.viewCount || 0),
likeCount: Number(item.statistics?.likeCount || 0),
commentCount: Number(item.statistics?.commentCount || 0),
duration: item.contentDetails?.duration ?? '',
}))
return {
success: true,
output: {
items,
totalResults: data.pageInfo?.totalResults || items.length,
nextPageToken: data.nextPageToken ?? null,
},
}
},
outputs: {
items: {
type: 'array',
description: 'Array of trending videos',
items: {
type: 'object',
properties: {
videoId: { type: 'string', description: 'YouTube video ID' },
title: { type: 'string', description: 'Video title' },
description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' },
channelId: { type: 'string', description: 'Channel ID' },
channelTitle: { type: 'string', description: 'Channel name' },
publishedAt: { type: 'string', description: 'Video publish date' },
viewCount: { type: 'number', description: 'Number of views' },
likeCount: { type: 'number', description: 'Number of likes' },
commentCount: { type: 'number', description: 'Number of comments' },
duration: { type: 'string', description: 'Video duration in ISO 8601 format' },
},
},
},
totalResults: {
type: 'number',
description: 'Total number of trending videos available',
},
nextPageToken: {
type: 'string',
description: 'Token for accessing the next page of results',
optional: true,
},
},
}

View File

@@ -16,7 +16,6 @@ export interface YouTubeSearchParams {
regionCode?: string regionCode?: string
relevanceLanguage?: string relevanceLanguage?: string
safeSearch?: 'moderate' | 'none' | 'strict' safeSearch?: 'moderate' | 'none' | 'strict'
eventType?: 'completed' | 'live' | 'upcoming'
} }
export interface YouTubeSearchResponse extends ToolResponse { export interface YouTubeSearchResponse extends ToolResponse {
@@ -26,13 +25,9 @@ export interface YouTubeSearchResponse extends ToolResponse {
title: string title: string
description: string description: string
thumbnail: string thumbnail: string
channelId: string
channelTitle: string
publishedAt: string
liveBroadcastContent: string
}> }>
totalResults: number totalResults: number
nextPageToken?: string | null nextPageToken?: string
} }
} }
@@ -53,24 +48,8 @@ export interface YouTubeVideoDetailsResponse extends ToolResponse {
viewCount: number viewCount: number
likeCount: number likeCount: number
commentCount: number commentCount: number
favoriteCount: number
thumbnail: string thumbnail: string
tags: string[] tags?: string[]
categoryId: string | null
definition: string | null
caption: string | null
licensedContent: boolean | null
privacyStatus: string | null
liveBroadcastContent: string | null
defaultLanguage: string | null
defaultAudioLanguage: string | null
// Live streaming details
isLiveContent: boolean
scheduledStartTime: string | null
actualStartTime: string | null
actualEndTime: string | null
concurrentViewers: number | null
activeLiveChatId: string | null
} }
} }
@@ -90,11 +69,7 @@ export interface YouTubeChannelInfoResponse extends ToolResponse {
viewCount: number viewCount: number
publishedAt: string publishedAt: string
thumbnail: string thumbnail: string
customUrl: string | null customUrl?: string
country: string | null
uploadsPlaylistId: string | null
bannerImageUrl: string | null
hiddenSubscriberCount: boolean
} }
} }
@@ -115,11 +90,9 @@ export interface YouTubePlaylistItemsResponse extends ToolResponse {
publishedAt: string publishedAt: string
channelTitle: string channelTitle: string
position: number position: number
videoOwnerChannelId: string | null
videoOwnerChannelTitle: string | null
}> }>
totalResults: number totalResults: number
nextPageToken?: string | null nextPageToken?: string
} }
} }
@@ -137,16 +110,15 @@ export interface YouTubeCommentsResponse extends ToolResponse {
commentId: string commentId: string
authorDisplayName: string authorDisplayName: string
authorChannelUrl: string authorChannelUrl: string
authorProfileImageUrl: string
textDisplay: string textDisplay: string
textOriginal: string textOriginal: string
likeCount: number likeCount: number
publishedAt: string publishedAt: string
updatedAt: string updatedAt: string
replyCount: number replyCount?: number
}> }>
totalResults: number totalResults: number
nextPageToken?: string | null nextPageToken?: string
} }
} }
@@ -166,10 +138,9 @@ export interface YouTubeChannelVideosResponse extends ToolResponse {
description: string description: string
thumbnail: string thumbnail: string
publishedAt: string publishedAt: string
channelTitle: string
}> }>
totalResults: number totalResults: number
nextPageToken?: string | null nextPageToken?: string
} }
} }
@@ -189,55 +160,9 @@ export interface YouTubeChannelPlaylistsResponse extends ToolResponse {
thumbnail: string thumbnail: string
itemCount: number itemCount: number
publishedAt: string publishedAt: string
channelTitle: string
}>
totalResults: number
nextPageToken?: string | null
}
}
export interface YouTubeTrendingParams {
apiKey: string
regionCode?: string
videoCategoryId?: string
maxResults?: number
pageToken?: string
}
export interface YouTubeTrendingResponse extends ToolResponse {
output: {
items: Array<{
videoId: string
title: string
description: string
thumbnail: string
channelId: string
channelTitle: string
publishedAt: string
viewCount: number
likeCount: number
commentCount: number
duration: string
}>
totalResults: number
nextPageToken?: string | null
}
}
export interface YouTubeVideoCategoriesParams {
apiKey: string
regionCode?: string
hl?: string
}
export interface YouTubeVideoCategoriesResponse extends ToolResponse {
output: {
items: Array<{
categoryId: string
title: string
assignable: boolean
}> }>
totalResults: number totalResults: number
nextPageToken?: string
} }
} }
@@ -249,5 +174,3 @@ export type YouTubeResponse =
| YouTubeCommentsResponse | YouTubeCommentsResponse
| YouTubeChannelVideosResponse | YouTubeChannelVideosResponse
| YouTubeChannelPlaylistsResponse | YouTubeChannelPlaylistsResponse
| YouTubeTrendingResponse
| YouTubeVideoCategoriesResponse

View File

@@ -1,108 +0,0 @@
import type { ToolConfig } from '@/tools/types'
import type {
YouTubeVideoCategoriesParams,
YouTubeVideoCategoriesResponse,
} from '@/tools/youtube/types'
export const youtubeVideoCategoriesTool: ToolConfig<
YouTubeVideoCategoriesParams,
YouTubeVideoCategoriesResponse
> = {
id: 'youtube_video_categories',
name: 'YouTube Video Categories',
description:
'Get a list of video categories available on YouTube. Use this to discover valid category IDs for filtering search and trending results.',
version: '1.0.0',
params: {
regionCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'ISO 3166-1 alpha-2 country code to get categories for (e.g., "US", "GB", "JP"). Defaults to US.',
},
hl: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Language for category titles (e.g., "en", "es", "fr"). Defaults to English.',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'YouTube API Key',
},
},
request: {
url: (params: YouTubeVideoCategoriesParams) => {
let url = `https://www.googleapis.com/youtube/v3/videoCategories?part=snippet&key=${params.apiKey}`
url += `&regionCode=${params.regionCode || 'US'}`
if (params.hl) {
url += `&hl=${params.hl}`
}
return url
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response): Promise<YouTubeVideoCategoriesResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
},
error: data.error.message || 'Failed to fetch video categories',
}
}
const items = (data.items || [])
.filter((item: any) => item.snippet?.assignable !== false)
.map((item: any) => ({
categoryId: item.id ?? '',
title: item.snippet?.title ?? '',
assignable: item.snippet?.assignable ?? false,
}))
return {
success: true,
output: {
items,
totalResults: items.length,
},
}
},
outputs: {
items: {
type: 'array',
description: 'Array of video categories available in the specified region',
items: {
type: 'object',
properties: {
categoryId: {
type: 'string',
description: 'Category ID to use in search/trending filters (e.g., "10" for Music)',
},
title: { type: 'string', description: 'Human-readable category name' },
assignable: {
type: 'boolean',
description: 'Whether videos can be tagged with this category',
},
},
},
},
totalResults: {
type: 'number',
description: 'Total number of categories available',
},
},
}

View File

@@ -7,9 +7,8 @@ export const youtubeVideoDetailsTool: ToolConfig<
> = { > = {
id: 'youtube_video_details', id: 'youtube_video_details',
name: 'YouTube Video Details', name: 'YouTube Video Details',
description: description: 'Get detailed information about a specific YouTube video.',
'Get detailed information about a specific YouTube video including statistics, content details, live streaming info, and metadata.', version: '1.0.0',
version: '1.2.0',
params: { params: {
videoId: { videoId: {
type: 'string', type: 'string',
@@ -27,7 +26,7 @@ export const youtubeVideoDetailsTool: ToolConfig<
request: { request: {
url: (params: YouTubeVideoDetailsParams) => { url: (params: YouTubeVideoDetailsParams) => {
return `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics,contentDetails,status,liveStreamingDetails&id=${encodeURIComponent(params.videoId)}&key=${params.apiKey}` return `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics,contentDetails&id=${params.videoId}&key=${params.apiKey}`
}, },
method: 'GET', method: 'GET',
headers: () => ({ headers: () => ({
@@ -52,68 +51,32 @@ export const youtubeVideoDetailsTool: ToolConfig<
viewCount: 0, viewCount: 0,
likeCount: 0, likeCount: 0,
commentCount: 0, commentCount: 0,
favoriteCount: 0,
thumbnail: '', thumbnail: '',
tags: [],
categoryId: null,
definition: null,
caption: null,
licensedContent: null,
privacyStatus: null,
liveBroadcastContent: null,
defaultLanguage: null,
defaultAudioLanguage: null,
isLiveContent: false,
scheduledStartTime: null,
actualStartTime: null,
actualEndTime: null,
concurrentViewers: null,
activeLiveChatId: null,
}, },
error: 'Video not found', error: 'Video not found',
} }
} }
const item = data.items[0] const item = data.items[0]
const liveDetails = item.liveStreamingDetails
return { return {
success: true, success: true,
output: { output: {
videoId: item.id ?? '', videoId: item.id,
title: item.snippet?.title ?? '', title: item.snippet?.title || '',
description: item.snippet?.description ?? '', description: item.snippet?.description || '',
channelId: item.snippet?.channelId ?? '', channelId: item.snippet?.channelId || '',
channelTitle: item.snippet?.channelTitle ?? '', channelTitle: item.snippet?.channelTitle || '',
publishedAt: item.snippet?.publishedAt ?? '', publishedAt: item.snippet?.publishedAt || '',
duration: item.contentDetails?.duration ?? '', duration: item.contentDetails?.duration || '',
viewCount: Number(item.statistics?.viewCount || 0), viewCount: Number(item.statistics?.viewCount || 0),
likeCount: Number(item.statistics?.likeCount || 0), likeCount: Number(item.statistics?.likeCount || 0),
commentCount: Number(item.statistics?.commentCount || 0), commentCount: Number(item.statistics?.commentCount || 0),
favoriteCount: Number(item.statistics?.favoriteCount || 0),
thumbnail: thumbnail:
item.snippet?.thumbnails?.high?.url || item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url || item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url || item.snippet?.thumbnails?.default?.url ||
'', '',
tags: item.snippet?.tags ?? [], tags: item.snippet?.tags || [],
categoryId: item.snippet?.categoryId ?? null,
definition: item.contentDetails?.definition ?? null,
caption: item.contentDetails?.caption ?? null,
licensedContent: item.contentDetails?.licensedContent ?? null,
privacyStatus: item.status?.privacyStatus ?? null,
liveBroadcastContent: item.snippet?.liveBroadcastContent ?? null,
defaultLanguage: item.snippet?.defaultLanguage ?? null,
defaultAudioLanguage: item.snippet?.defaultAudioLanguage ?? null,
// Live streaming details
isLiveContent: liveDetails !== undefined,
scheduledStartTime: liveDetails?.scheduledStartTime ?? null,
actualStartTime: liveDetails?.actualStartTime ?? null,
actualEndTime: liveDetails?.actualEndTime ?? null,
concurrentViewers: liveDetails?.concurrentViewers
? Number(liveDetails.concurrentViewers)
: null,
activeLiveChatId: liveDetails?.activeLiveChatId ?? null,
}, },
} }
}, },
@@ -145,7 +108,7 @@ export const youtubeVideoDetailsTool: ToolConfig<
}, },
duration: { duration: {
type: 'string', type: 'string',
description: 'Video duration in ISO 8601 format (e.g., "PT4M13S" for 4 min 13 sec)', description: 'Video duration in ISO 8601 format',
}, },
viewCount: { viewCount: {
type: 'number', type: 'number',
@@ -159,10 +122,6 @@ export const youtubeVideoDetailsTool: ToolConfig<
type: 'number', type: 'number',
description: 'Number of comments', description: 'Number of comments',
}, },
favoriteCount: {
type: 'number',
description: 'Number of times added to favorites',
},
thumbnail: { thumbnail: {
type: 'string', type: 'string',
description: 'Video thumbnail URL', description: 'Video thumbnail URL',
@@ -173,74 +132,6 @@ export const youtubeVideoDetailsTool: ToolConfig<
items: { items: {
type: 'string', type: 'string',
}, },
},
categoryId: {
type: 'string',
description: 'YouTube video category ID',
optional: true,
},
definition: {
type: 'string',
description: 'Video definition: "hd" or "sd"',
optional: true,
},
caption: {
type: 'string',
description: 'Whether captions are available: "true" or "false"',
optional: true,
},
licensedContent: {
type: 'boolean',
description: 'Whether the video is licensed content',
optional: true,
},
privacyStatus: {
type: 'string',
description: 'Video privacy status: "public", "private", or "unlisted"',
optional: true,
},
liveBroadcastContent: {
type: 'string',
description: 'Live broadcast status: "live", "upcoming", or "none"',
optional: true,
},
defaultLanguage: {
type: 'string',
description: 'Default language of the video metadata',
optional: true,
},
defaultAudioLanguage: {
type: 'string',
description: 'Default audio language of the video',
optional: true,
},
isLiveContent: {
type: 'boolean',
description: 'Whether this video is or was a live stream',
},
scheduledStartTime: {
type: 'string',
description: 'Scheduled start time for upcoming live streams (ISO 8601)',
optional: true,
},
actualStartTime: {
type: 'string',
description: 'When the live stream actually started (ISO 8601)',
optional: true,
},
actualEndTime: {
type: 'string',
description: 'When the live stream ended (ISO 8601)',
optional: true,
},
concurrentViewers: {
type: 'number',
description: 'Current number of viewers (only for active live streams)',
optional: true,
},
activeLiveChatId: {
type: 'string',
description: 'Live chat ID for the stream (only for active live streams)',
optional: true, optional: true,
}, },
}, },

View File

@@ -1,8 +0,0 @@
ALTER TABLE "workflow_execution_logs" DROP CONSTRAINT "workflow_execution_logs_workflow_id_workflow_id_fk";
--> statement-breakpoint
ALTER TABLE "workflow_execution_snapshots" DROP CONSTRAINT "workflow_execution_snapshots_workflow_id_workflow_id_fk";
--> statement-breakpoint
ALTER TABLE "workflow_execution_logs" ALTER COLUMN "workflow_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "workflow_execution_snapshots" ALTER COLUMN "workflow_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "workflow_execution_logs" ADD CONSTRAINT "workflow_execution_logs_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workflow_execution_snapshots" ADD CONSTRAINT "workflow_execution_snapshots_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1037,13 +1037,6 @@
"when": 1769626313827, "when": 1769626313827,
"tag": "0148_aberrant_venom", "tag": "0148_aberrant_venom",
"breakpoints": true "breakpoints": true
},
{
"idx": 149,
"version": "7",
"when": 1769656977701,
"tag": "0149_next_cerise",
"breakpoints": true
} }
] ]
} }

View File

@@ -268,7 +268,9 @@ export const workflowExecutionSnapshots = pgTable(
'workflow_execution_snapshots', 'workflow_execution_snapshots',
{ {
id: text('id').primaryKey(), id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }), workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
stateHash: text('state_hash').notNull(), stateHash: text('state_hash').notNull(),
stateData: jsonb('state_data').notNull(), stateData: jsonb('state_data').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
@@ -288,7 +290,9 @@ export const workflowExecutionLogs = pgTable(
'workflow_execution_logs', 'workflow_execution_logs',
{ {
id: text('id').primaryKey(), id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }), workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id') workspaceId: text('workspace_id')
.notNull() .notNull()
.references(() => workspace.id, { onDelete: 'cascade' }), .references(() => workspace.id, { onDelete: 'cascade' }),