Compare commits
59 Commits
feat/copil
...
v0.5.75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6bf5cd58c | ||
|
|
0d8d9fb238 | ||
|
|
e0f1e66f4f | ||
|
|
20bb7cdec6 | ||
|
|
1469e9c66c | ||
|
|
06d7ce7667 | ||
|
|
1bc476f10b | ||
|
|
9e40342af8 | ||
|
|
0c0f19c717 | ||
|
|
12d529d045 | ||
|
|
57f0837da7 | ||
|
|
5c02d46d55 | ||
|
|
8b2404752b | ||
|
|
c00f05c346 | ||
|
|
78410eef84 | ||
|
|
655fe4f3b7 | ||
|
|
72a2f79701 | ||
|
|
2c2b485f81 | ||
|
|
11dc18a80d | ||
|
|
ab4e9dc72f | ||
|
|
1c58c35bd8 | ||
|
|
d63a5cb504 | ||
|
|
8bd5d41723 | ||
|
|
c12931bc50 | ||
|
|
e9c4251c1c | ||
|
|
cc2be33d6b | ||
|
|
45371e521e | ||
|
|
0ce0f98aa5 | ||
|
|
dff1c9d083 | ||
|
|
b09f683072 | ||
|
|
a8bb0db660 | ||
|
|
af82820a28 | ||
|
|
4372841797 | ||
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -280,14 +280,24 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<td>Click clear button in Chat panel</td>
|
||||
<td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Run from block</td>
|
||||
<td>Hover block → Click play button, or right-click → **Run from block**</td>
|
||||
<td><ActionImage src="/static/quick-reference/run-from-block.png" alt="Run from block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Run until block</td>
|
||||
<td>Right-click block → **Run until block**</td>
|
||||
<td><ActionImage src="/static/quick-reference/run-until-block.png" alt="Run until block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>View execution logs</td>
|
||||
<td>Open terminal panel at bottom, or `Mod+L`</td>
|
||||
<td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Filter logs by block or status</td>
|
||||
<td>Click block filter in terminal or right-click log entry → **Filter by Block** or **Filter by Status**</td>
|
||||
<td>Filter logs</td>
|
||||
<td>Click filter icon in terminal → Filter by block or status</td>
|
||||
<td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -335,6 +345,11 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
||||
<td>Access previous versions in Deploy tab → **Promote to live**</td>
|
||||
<td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add version description</td>
|
||||
<td>Deploy tab → Click description icon → Add or generate description</td>
|
||||
<td><ActionVideo src="quick-reference/deployment-description.mp4" alt="Add deployment version description" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy API endpoint</td>
|
||||
<td>Deploy tab → API → Copy API cURL</td>
|
||||
|
||||
@@ -26,78 +26,41 @@ In Sim, the YouTube integration enables your agents to programmatically search a
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `youtube_search`
|
||||
### `youtube_captions`
|
||||
|
||||
Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, and more.
|
||||
List available caption tracks (subtitles/transcripts) for a YouTube video. Returns information about each caption including language, type, and whether it is auto-generated.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `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 |
|
||||
| `videoId` | string | Yes | YouTube video ID to get captions for |
|
||||
| `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 |
|
||||
| `viewCount` | number | Number of views |
|
||||
| `likeCount` | number | Number of likes |
|
||||
| `commentCount` | number | Number of comments |
|
||||
| `thumbnail` | string | Video thumbnail URL |
|
||||
| `tags` | array | Video tags |
|
||||
| `items` | array | Array of available caption tracks for the video |
|
||||
| ↳ `captionId` | string | Caption track ID |
|
||||
| ↳ `language` | string | Language code of the caption \(e.g., |
|
||||
| ↳ `name` | string | Name/label of the caption track |
|
||||
| ↳ `trackKind` | string | Type of caption track: |
|
||||
| ↳ `lastUpdated` | string | When the caption was last updated |
|
||||
| ↳ `isCC` | boolean | Whether this is a closed caption track |
|
||||
| ↳ `isAutoSynced` | boolean | Whether the caption timing was automatically synced |
|
||||
| ↳ `audioTrackType` | string | Type of audio track this caption is for |
|
||||
| `totalResults` | number | Total number of caption tracks available |
|
||||
|
||||
### `youtube_channel_info`
|
||||
|
||||
Get detailed information about a YouTube channel.
|
||||
Get detailed information about a YouTube channel including statistics, branding, and content details.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -114,43 +77,20 @@ Get detailed information about a YouTube channel.
|
||||
| `channelId` | string | YouTube channel ID |
|
||||
| `title` | string | Channel name |
|
||||
| `description` | string | Channel description |
|
||||
| `subscriberCount` | number | Number of subscribers |
|
||||
| `videoCount` | number | Number of videos |
|
||||
| `subscriberCount` | number | Number of subscribers \(0 if hidden\) |
|
||||
| `videoCount` | number | Number of public videos |
|
||||
| `viewCount` | number | Total channel views |
|
||||
| `publishedAt` | string | Channel creation date |
|
||||
| `thumbnail` | string | Channel thumbnail URL |
|
||||
| `customUrl` | string | Channel custom URL |
|
||||
|
||||
### `youtube_channel_videos`
|
||||
|
||||
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 |
|
||||
| `thumbnail` | string | Channel thumbnail/avatar URL |
|
||||
| `customUrl` | string | Channel custom URL \(handle\) |
|
||||
| `country` | string | Country the channel is associated with |
|
||||
| `uploadsPlaylistId` | string | Playlist ID containing all channel uploads \(use with playlist_items\) |
|
||||
| `bannerImageUrl` | string | Channel banner image URL |
|
||||
| `hiddenSubscriberCount` | boolean | Whether the subscriber count is hidden |
|
||||
|
||||
### `youtube_channel_playlists`
|
||||
|
||||
Get all playlists from a specific YouTube channel.
|
||||
Get all public playlists from a specific YouTube channel.
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -172,19 +112,80 @@ Get all playlists from a specific YouTube channel.
|
||||
| ↳ `thumbnail` | string | Playlist thumbnail URL |
|
||||
| ↳ `itemCount` | number | Number of videos in playlist |
|
||||
| ↳ `publishedAt` | string | Playlist creation date |
|
||||
| ↳ `channelTitle` | string | Channel name |
|
||||
| `totalResults` | number | Total number of playlists in the channel |
|
||||
| `nextPageToken` | string | Token for accessing the next page of results |
|
||||
|
||||
### `youtube_playlist_items`
|
||||
### `youtube_channel_videos`
|
||||
|
||||
Get videos from a YouTube playlist.
|
||||
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 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `playlistId` | string | Yes | YouTube playlist ID |
|
||||
| `maxResults` | number | No | Maximum number of videos to return |
|
||||
| `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`
|
||||
|
||||
Get videos from a YouTube playlist. Can be used with a channel uploads playlist to get all channel videos.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `playlistId` | string | Yes | YouTube playlist ID. Use uploadsPlaylistId from channel_info to get all channel videos. |
|
||||
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
|
||||
| `pageToken` | string | No | Page token for pagination |
|
||||
| `apiKey` | string | Yes | YouTube API Key |
|
||||
|
||||
@@ -198,22 +199,65 @@ Get videos from a YouTube playlist.
|
||||
| ↳ `description` | string | Video description |
|
||||
| ↳ `thumbnail` | string | Video thumbnail URL |
|
||||
| ↳ `publishedAt` | string | Date added to playlist |
|
||||
| ↳ `channelTitle` | string | Channel name |
|
||||
| ↳ `position` | number | Position in playlist |
|
||||
| ↳ `channelTitle` | string | Playlist owner channel name |
|
||||
| ↳ `position` | number | Position in playlist \(0-indexed\) |
|
||||
| ↳ `videoOwnerChannelId` | string | Channel ID of the video owner |
|
||||
| ↳ `videoOwnerChannelTitle` | string | Channel name of the video owner |
|
||||
| `totalResults` | number | Total number of items in playlist |
|
||||
| `nextPageToken` | string | Token for accessing the next page of results |
|
||||
|
||||
### `youtube_comments`
|
||||
### `youtube_search`
|
||||
|
||||
Get comments from a YouTube video.
|
||||
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.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `videoId` | string | Yes | YouTube video ID |
|
||||
| `maxResults` | number | No | Maximum number of comments to return |
|
||||
| `order` | string | No | Order of comments: time or relevance |
|
||||
| `query` | string | Yes | Search query for YouTube videos |
|
||||
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
|
||||
| `pageToken` | string | No | Page token for pagination \(use nextPageToken from previous response\) |
|
||||
| `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 |
|
||||
| `apiKey` | string | Yes | YouTube API Key |
|
||||
|
||||
@@ -221,17 +265,84 @@ Get comments from a YouTube video.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `items` | array | Array of comments from the video |
|
||||
| ↳ `commentId` | string | Comment ID |
|
||||
| ↳ `authorDisplayName` | string | Comment author name |
|
||||
| ↳ `authorChannelUrl` | string | Comment author channel URL |
|
||||
| ↳ `textDisplay` | string | Comment text \(HTML formatted\) |
|
||||
| ↳ `textOriginal` | string | Comment text \(plain text\) |
|
||||
| `items` | array | Array of trending videos |
|
||||
| ↳ `videoId` | string | YouTube video ID |
|
||||
| ↳ `title` | string | Video title |
|
||||
| ↳ `description` | string | Video description |
|
||||
| ↳ `thumbnail` | string | Video thumbnail URL |
|
||||
| ↳ `channelId` | string | Channel ID |
|
||||
| ↳ `channelTitle` | string | Channel name |
|
||||
| ↳ `publishedAt` | string | Video publish date |
|
||||
| ↳ `viewCount` | number | Number of views |
|
||||
| ↳ `likeCount` | number | Number of likes |
|
||||
| ↳ `publishedAt` | string | Comment publish date |
|
||||
| ↳ `updatedAt` | string | Comment last updated date |
|
||||
| ↳ `replyCount` | number | Number of replies |
|
||||
| `totalResults` | number | Total number of comments |
|
||||
| ↳ `commentCount` | number | Number of comments |
|
||||
| ↳ `duration` | string | Video duration in ISO 8601 format |
|
||||
| `totalResults` | number | Total number of trending videos available |
|
||||
| `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\) |
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 7.2 KiB |
BIN
apps/docs/public/static/quick-reference/run-from-block.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/docs/public/static/quick-reference/run-until-block.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 29 KiB |
@@ -14,7 +14,7 @@
|
||||
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
|
||||
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
|
||||
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
|
||||
--terminal-height: 155px; /* TERMINAL_HEIGHT.DEFAULT */
|
||||
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
deploymentVersionName: workflowDeploymentVersion.name,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(
|
||||
workflowDeploymentVersion,
|
||||
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
|
||||
@@ -65,7 +65,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
@@ -77,17 +77,19 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
}
|
||||
const workflowSummary = log.workflowId
|
||||
? {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
}
|
||||
: null
|
||||
|
||||
const response = {
|
||||
id: log.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { subscription, user, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
|
||||
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 workflowsQuery = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(inArray(workflow.userId, freeUserIds))
|
||||
const workspacesQuery = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(inArray(workspace.billedAccountUserId, freeUserIds))
|
||||
|
||||
if (workflowsQuery.length === 0) {
|
||||
logger.info('No workflows found for free users')
|
||||
return NextResponse.json({ message: 'No workflows found for cleanup' })
|
||||
if (workspacesQuery.length === 0) {
|
||||
logger.info('No workspaces found for free users')
|
||||
return NextResponse.json({ message: 'No workspaces found for cleanup' })
|
||||
}
|
||||
|
||||
const workflowIds = workflowsQuery.map((w) => w.id)
|
||||
const workspaceIds = workspacesQuery.map((w) => w.id)
|
||||
|
||||
const results = {
|
||||
enhancedLogs: {
|
||||
@@ -77,7 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
let batchesProcessed = 0
|
||||
let hasMoreLogs = true
|
||||
|
||||
logger.info(`Starting enhanced logs cleanup for ${workflowIds.length} workflows`)
|
||||
logger.info(`Starting enhanced logs cleanup for ${workspaceIds.length} workspaces`)
|
||||
|
||||
while (hasMoreLogs && batchesProcessed < MAX_BATCHES) {
|
||||
const oldEnhancedLogs = await db
|
||||
@@ -99,7 +99,7 @@ export async function GET(request: NextRequest) {
|
||||
.from(workflowExecutionLogs)
|
||||
.where(
|
||||
and(
|
||||
inArray(workflowExecutionLogs.workflowId, workflowIds),
|
||||
inArray(workflowExecutionLogs.workspaceId, workspaceIds),
|
||||
lt(workflowExecutionLogs.createdAt, retentionDate)
|
||||
)
|
||||
)
|
||||
@@ -127,7 +127,7 @@ export async function GET(request: NextRequest) {
|
||||
customKey: enhancedLogKey,
|
||||
metadata: {
|
||||
logId: String(log.id),
|
||||
workflowId: String(log.workflowId),
|
||||
workflowId: String(log.workflowId ?? ''),
|
||||
executionId: String(log.executionId),
|
||||
logType: 'enhanced',
|
||||
archivedAt: new Date().toISOString(),
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
workflowExecutionSnapshots,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
|
||||
const logger = createLogger('LogsByExecutionIdAPI')
|
||||
|
||||
@@ -48,14 +49,15 @@ export async function GET(
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
|
||||
eq(permissions.userId, authenticatedUserId)
|
||||
)
|
||||
)
|
||||
@@ -78,10 +80,42 @@ export async function GET(
|
||||
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 = {
|
||||
executionId,
|
||||
workflowId: workflowLog.workflowId,
|
||||
workflowState: snapshot.stateData,
|
||||
childWorkflowSnapshots: childSnapshotMap,
|
||||
executionMetadata: {
|
||||
trigger: workflowLog.trigger,
|
||||
startedAt: workflowLog.startedAt.toISOString(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
|
||||
@@ -41,7 +41,7 @@ export async function GET(request: NextRequest) {
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
workflowName: workflow.name,
|
||||
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
|
||||
}
|
||||
|
||||
const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
|
||||
@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
|
||||
const rows = await db
|
||||
.select(selectColumns)
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function GET(request: NextRequest) {
|
||||
workflowDeploymentVersion,
|
||||
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
@@ -190,7 +190,7 @@ export async function GET(request: NextRequest) {
|
||||
pausedExecutions,
|
||||
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||
)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
@@ -314,17 +314,19 @@ export async function GET(request: NextRequest) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const workflowSummary = {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
}
|
||||
const workflowSummary = log.workflowId
|
||||
? {
|
||||
id: log.workflowId,
|
||||
name: log.workflowName,
|
||||
description: log.workflowDescription,
|
||||
color: log.workflowColor,
|
||||
folderId: log.workflowFolderId,
|
||||
userId: log.workflowUserId,
|
||||
workspaceId: log.workflowWorkspaceId,
|
||||
createdAt: log.workflowCreatedAt,
|
||||
updatedAt: log.workflowUpdatedAt,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function GET(request: NextRequest) {
|
||||
maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`,
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
@@ -103,8 +103,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const statsQuery = await db
|
||||
.select({
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
workflowName: workflow.name,
|
||||
workflowId: sql<string>`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
|
||||
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
|
||||
segmentIndex:
|
||||
sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as(
|
||||
'segment_index'
|
||||
@@ -120,7 +120,7 @@ export async function GET(request: NextRequest) {
|
||||
),
|
||||
})
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
@@ -130,7 +130,11 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
)
|
||||
.where(whereCondition)
|
||||
.groupBy(workflowExecutionLogs.workflowId, workflow.name, sql`segment_index`)
|
||||
.groupBy(
|
||||
sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
|
||||
sql`COALESCE(${workflow.name}, 'Deleted Workflow')`,
|
||||
sql`segment_index`
|
||||
)
|
||||
|
||||
const workflowMap = new Map<
|
||||
string,
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
expect(nextRunAt).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt).toBeLessThanOrEqual(afterCall + 5 * 60 * 1000 + 1000)
|
||||
// Should align with 5-minute intervals (minute divisible by 5)
|
||||
expect(new Date(nextRunAt).getMinutes() % 5).toBe(0)
|
||||
expect(new Date(nextRunAt).getUTCMinutes() % 5).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates nextRunAt from daily cron expression', async () => {
|
||||
@@ -572,7 +572,7 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getTime()).toBeLessThanOrEqual(beforeCall + 10 * 60 * 1000 + 1000)
|
||||
// Should align with 10-minute intervals
|
||||
expect(nextRunAt.getMinutes() % 10).toBe(0)
|
||||
expect(nextRunAt.getUTCMinutes() % 10).toBe(0)
|
||||
})
|
||||
|
||||
it('handles hourly schedules with timezone correctly', async () => {
|
||||
@@ -598,8 +598,8 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
|
||||
// Should be a future date at minute 15
|
||||
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
|
||||
expect(nextRunAt.getMinutes()).toBe(15)
|
||||
expect(nextRunAt.getSeconds()).toBe(0)
|
||||
expect(nextRunAt.getUTCMinutes()).toBe(15)
|
||||
expect(nextRunAt.getUTCSeconds()).toBe(0)
|
||||
})
|
||||
|
||||
it('handles custom cron expressions with complex patterns and timezone', async () => {
|
||||
|
||||
@@ -9,13 +9,24 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
|
||||
|
||||
const logger = createLogger('WorkflowDeploymentVersionAPI')
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(100, 'Name must be 100 characters or less'),
|
||||
})
|
||||
const patchBodySchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(100, 'Name must be 100 characters or less')
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500, 'Description must be 500 characters or less')
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => data.name !== undefined || data.description !== undefined, {
|
||||
message: 'At least one of name or description must be provided',
|
||||
})
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@@ -88,33 +99,46 @@ export async function PATCH(
|
||||
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
|
||||
}
|
||||
|
||||
const { name } = validation.data
|
||||
const { name, description } = validation.data
|
||||
|
||||
const updateData: { name?: string; description?: string | null } = {}
|
||||
if (name !== undefined) {
|
||||
updateData.name = name
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updateData.description = description
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ name })
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.returning({ id: workflowDeploymentVersion.id, name: workflowDeploymentVersion.name })
|
||||
.returning({
|
||||
id: workflowDeploymentVersion.id,
|
||||
name: workflowDeploymentVersion.name,
|
||||
description: workflowDeploymentVersion.description,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return createErrorResponse('Deployment version not found', 404)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"`
|
||||
)
|
||||
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
|
||||
name: updateData.name,
|
||||
description: updateData.description,
|
||||
})
|
||||
|
||||
return createSuccessResponse({ name: updated.name })
|
||||
return createSuccessResponse({ name: updated.name, description: updated.description })
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`[${requestId}] Error renaming deployment version ${version} for workflow ${id}`,
|
||||
`[${requestId}] Error updating deployment version ${version} for workflow ${id}`,
|
||||
error
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to rename deployment version', 500)
|
||||
return createErrorResponse(error.message || 'Failed to update deployment version', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
id: workflowDeploymentVersion.id,
|
||||
version: workflowDeploymentVersion.version,
|
||||
name: workflowDeploymentVersion.name,
|
||||
description: workflowDeploymentVersion.description,
|
||||
isActive: workflowDeploymentVersion.isActive,
|
||||
createdAt: workflowDeploymentVersion.createdAt,
|
||||
createdBy: workflowDeploymentVersion.createdBy,
|
||||
|
||||
216
apps/sim/app/api/workflows/[id]/execute-from-block/route.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { db, workflow as workflowTable } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
|
||||
const logger = createLogger('ExecuteFromBlockAPI')
|
||||
|
||||
const ExecuteFromBlockSchema = z.object({
|
||||
startBlockId: z.string().min(1, 'Start block ID is required'),
|
||||
sourceSnapshot: z.object({
|
||||
blockStates: z.record(z.any()),
|
||||
executedBlocks: z.array(z.string()),
|
||||
blockLogs: z.array(z.any()),
|
||||
decisions: z.object({
|
||||
router: z.record(z.string()),
|
||||
condition: z.record(z.string()),
|
||||
}),
|
||||
completedLoops: z.array(z.string()),
|
||||
loopExecutions: z.record(z.any()).optional(),
|
||||
parallelExecutions: z.record(z.any()).optional(),
|
||||
parallelBlockMapping: z.record(z.any()).optional(),
|
||||
activeExecutionPath: z.array(z.string()),
|
||||
}),
|
||||
input: z.any().optional(),
|
||||
})
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validation = ExecuteFromBlockSchema.safeParse(body)
|
||||
if (!validation.success) {
|
||||
logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid request body',
|
||||
details: validation.error.errors.map((e) => ({
|
||||
path: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { startBlockId, sourceSnapshot, input } = validation.data
|
||||
const executionId = uuidv4()
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord?.workspaceId) {
|
||||
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceId = workflowRecord.workspaceId
|
||||
const workflowUserId = workflowRecord.userId
|
||||
|
||||
logger.info(`[${requestId}] Starting run-from-block execution`, {
|
||||
workflowId,
|
||||
startBlockId,
|
||||
executedBlocksCount: sourceSnapshot.executedBlocks.length,
|
||||
})
|
||||
|
||||
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
|
||||
const abortController = new AbortController()
|
||||
let isStreamClosed = false
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const { sendEvent, onBlockStart, onBlockComplete, onStream } = createSSECallbacks({
|
||||
executionId,
|
||||
workflowId,
|
||||
controller,
|
||||
isStreamClosed: () => isStreamClosed,
|
||||
setStreamClosed: () => {
|
||||
isStreamClosed = true
|
||||
},
|
||||
})
|
||||
|
||||
const metadata: ExecutionMetadata = {
|
||||
requestId,
|
||||
workflowId,
|
||||
userId,
|
||||
executionId,
|
||||
triggerType: 'manual',
|
||||
workspaceId,
|
||||
workflowUserId,
|
||||
useDraftState: true,
|
||||
isClientSession: true,
|
||||
startTime: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const snapshot = new ExecutionSnapshot(metadata, {}, input || {}, {})
|
||||
|
||||
try {
|
||||
const startTime = new Date()
|
||||
|
||||
sendEvent({
|
||||
type: 'execution:started',
|
||||
timestamp: startTime.toISOString(),
|
||||
executionId,
|
||||
workflowId,
|
||||
data: { startTime: startTime.toISOString() },
|
||||
})
|
||||
|
||||
const result = await executeWorkflowCore({
|
||||
snapshot,
|
||||
loggingSession,
|
||||
abortSignal: abortController.signal,
|
||||
runFromBlock: {
|
||||
startBlockId,
|
||||
sourceSnapshot: sourceSnapshot as SerializableExecutionState,
|
||||
},
|
||||
callbacks: { onBlockStart, onBlockComplete, onStream },
|
||||
})
|
||||
|
||||
if (result.status === 'cancelled') {
|
||||
sendEvent({
|
||||
type: 'execution:cancelled',
|
||||
timestamp: new Date().toISOString(),
|
||||
executionId,
|
||||
workflowId,
|
||||
data: { duration: result.metadata?.duration || 0 },
|
||||
})
|
||||
} else {
|
||||
sendEvent({
|
||||
type: 'execution:completed',
|
||||
timestamp: new Date().toISOString(),
|
||||
executionId,
|
||||
workflowId,
|
||||
data: {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
duration: result.metadata?.duration || 0,
|
||||
startTime: result.metadata?.startTime || startTime.toISOString(),
|
||||
endTime: result.metadata?.endTime || new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`)
|
||||
|
||||
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
|
||||
|
||||
sendEvent({
|
||||
type: 'execution:error',
|
||||
timestamp: new Date().toISOString(),
|
||||
executionId,
|
||||
workflowId,
|
||||
data: {
|
||||
error: executionResult?.error || errorMessage,
|
||||
duration: executionResult?.metadata?.duration || 0,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (!isStreamClosed) {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
isStreamClosed = true
|
||||
abortController.abort()
|
||||
markExecutionCancelled(executionId).catch(() => {})
|
||||
},
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: { ...SSE_HEADERS, 'X-Execution-Id': executionId },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Failed to start run-from-block execution:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage || 'Failed to start execution' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ const ExecuteWorkflowSchema = z.object({
|
||||
parallels: z.record(z.any()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
stopAfterBlockId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
@@ -222,6 +223,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
workflowStateOverride,
|
||||
stopAfterBlockId,
|
||||
} = validation.data
|
||||
|
||||
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
|
||||
@@ -237,6 +239,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
workflowStateOverride,
|
||||
stopAfterBlockId: _stopAfterBlockId,
|
||||
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
|
||||
...rest
|
||||
} = body
|
||||
@@ -434,6 +437,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
loggingSession,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
stopAfterBlockId,
|
||||
})
|
||||
|
||||
const outputWithBase64 = includeFileBase64
|
||||
@@ -722,6 +726,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
abortSignal: abortController.signal,
|
||||
includeFileBase64,
|
||||
base64MaxBytes,
|
||||
stopAfterBlockId,
|
||||
})
|
||||
|
||||
if (result.status === 'paused') {
|
||||
|
||||
@@ -133,9 +133,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
// Default values for expected properties
|
||||
deploymentStatuses: {},
|
||||
// Data from normalized tables
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
@@ -143,8 +141,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
metadata: {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
},
|
||||
},
|
||||
// Include workflow variables
|
||||
variables: workflowData.variables || {},
|
||||
}
|
||||
|
||||
@@ -166,6 +167,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
metadata: {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description,
|
||||
},
|
||||
},
|
||||
variables: workflowData.variables || {},
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
for (const log of logs) {
|
||||
if (!log.workflowId) continue // Skip logs for deleted workflows
|
||||
const idx = Math.min(
|
||||
segments - 1,
|
||||
Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs))
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
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 { StatusBar, type StatusBarSegment } from '..'
|
||||
|
||||
@@ -61,22 +65,32 @@ export function WorkflowsList({
|
||||
<div>
|
||||
{filteredExecutions.map((workflow, idx) => {
|
||||
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 (
|
||||
<div
|
||||
key={workflow.workflowId}
|
||||
className={cn(
|
||||
'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]',
|
||||
'flex h-[44px] 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)]'
|
||||
)}
|
||||
onClick={() => onToggleWorkflow(workflow.workflowId)}
|
||||
onClick={() => {
|
||||
if (canToggle) {
|
||||
onToggleWorkflow(workflow.workflowId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Workflow name with color */}
|
||||
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{
|
||||
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
|
||||
backgroundColor: workflowColor,
|
||||
}}
|
||||
/>
|
||||
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
|
||||
@@ -80,6 +80,9 @@ export function ExecutionSnapshot({
|
||||
}, [executionId, closeMenu])
|
||||
|
||||
const workflowState = data?.workflowState as WorkflowState | undefined
|
||||
const childWorkflowSnapshots = data?.childWorkflowSnapshots as
|
||||
| Record<string, WorkflowState>
|
||||
| undefined
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
@@ -148,6 +151,7 @@ export function ExecutionSnapshot({
|
||||
key={executionId}
|
||||
workflowState={workflowState}
|
||||
traceSpans={traceSpans}
|
||||
childWorkflowSnapshots={childWorkflowSnapshots}
|
||||
className={className}
|
||||
height={height}
|
||||
width={width}
|
||||
|
||||
@@ -57,40 +57,6 @@ function useSetToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique key for a trace span
|
||||
*/
|
||||
function getSpanKey(span: TraceSpan): string {
|
||||
if (span.id) {
|
||||
return span.id
|
||||
}
|
||||
const name = span.name || 'span'
|
||||
const start = span.startTime || 'unknown-start'
|
||||
const end = span.endTime || 'unknown-end'
|
||||
return `${name}|${start}|${end}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple arrays of trace span children, deduplicating by span key
|
||||
*/
|
||||
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
|
||||
const merged: TraceSpan[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.forEach((child) => {
|
||||
const key = getSpanKey(child)
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
merged.push(child)
|
||||
})
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a time value to milliseconds
|
||||
*/
|
||||
@@ -116,34 +82,16 @@ function hasErrorInTree(span: TraceSpan): boolean {
|
||||
|
||||
/**
|
||||
* Normalizes and sorts trace spans recursively.
|
||||
* Merges children from both span.children and span.output.childTraceSpans,
|
||||
* deduplicates them, and sorts by start time.
|
||||
* Deduplicates children and sorts by start time.
|
||||
*/
|
||||
function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
|
||||
return spans
|
||||
.map((span) => {
|
||||
const enrichedSpan: TraceSpan = { ...span }
|
||||
|
||||
// Clean output by removing childTraceSpans after extracting
|
||||
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
|
||||
enrichedSpan.output = { ...enrichedSpan.output }
|
||||
if ('childTraceSpans' in enrichedSpan.output) {
|
||||
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
|
||||
childTraceSpans?: TraceSpan[]
|
||||
} & Record<string, unknown>
|
||||
enrichedSpan.output = cleanOutput
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and deduplicate children from both sources
|
||||
const directChildren = Array.isArray(span.children) ? span.children : []
|
||||
const outputChildren = Array.isArray(span.output?.childTraceSpans)
|
||||
? (span.output!.childTraceSpans as TraceSpan[])
|
||||
: []
|
||||
|
||||
const mergedChildren = mergeTraceSpanChildren(directChildren, outputChildren)
|
||||
enrichedSpan.children =
|
||||
mergedChildren.length > 0 ? normalizeAndSortSpans(mergedChildren) : undefined
|
||||
// Process and deduplicate children
|
||||
const children = Array.isArray(span.children) ? span.children : []
|
||||
enrichedSpan.children = children.length > 0 ? normalizeAndSortSpans(children) : undefined
|
||||
|
||||
return enrichedSpan
|
||||
})
|
||||
@@ -573,7 +521,19 @@ const TraceSpanNode = memo(function TraceSpanNode({
|
||||
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
|
||||
}, [span, spanId, spanStartTime])
|
||||
|
||||
const hasChildren = allChildren.length > 0
|
||||
// Hide empty model timing segments for agents without tool calls
|
||||
const filteredChildren = useMemo(() => {
|
||||
const isAgent = span.type?.toLowerCase() === 'agent'
|
||||
const hasToolCalls =
|
||||
(span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool')
|
||||
|
||||
if (isAgent && !hasToolCalls) {
|
||||
return allChildren.filter((c) => c.type?.toLowerCase() !== 'model')
|
||||
}
|
||||
return allChildren
|
||||
}, [allChildren, span.type, span.toolCalls])
|
||||
|
||||
const hasChildren = filteredChildren.length > 0
|
||||
const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
|
||||
const isToggleable = !isRootWorkflow
|
||||
|
||||
@@ -685,7 +645,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
|
||||
{/* Nested Children */}
|
||||
{hasChildren && (
|
||||
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
|
||||
{allChildren.map((child, index) => (
|
||||
{filteredChildren.map((child, index) => (
|
||||
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
|
||||
<TraceSpanNode
|
||||
span={child}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import {
|
||||
ExecutionSnapshot,
|
||||
FileCards,
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/logs/components'
|
||||
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
formatDate,
|
||||
getDisplayStatus,
|
||||
StatusBadge,
|
||||
@@ -274,16 +277,13 @@ export const LogDetails = memo(function LogDetails({
|
||||
return isWorkflowExecutionLog && log?.cost
|
||||
}, [log, isWorkflowExecutionLog])
|
||||
|
||||
// Extract and clean the workflow final output (remove childTraceSpans for cleaner display)
|
||||
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
|
||||
const workflowOutput = useMemo(() => {
|
||||
const executionData = log?.executionData as
|
||||
| { finalOutput?: Record<string, unknown> }
|
||||
| undefined
|
||||
if (!executionData?.finalOutput) return null
|
||||
const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as {
|
||||
childTraceSpans?: unknown
|
||||
} & Record<string, unknown>
|
||||
return cleanOutput
|
||||
return filterHiddenOutputKeys(executionData.finalOutput) as Record<string, unknown>
|
||||
}, [log?.executionData])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -388,22 +388,25 @@ export const LogDetails = memo(function LogDetails({
|
||||
</div>
|
||||
|
||||
{/* Workflow Card */}
|
||||
{log.workflow && (
|
||||
<div className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'>
|
||||
<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 className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'>
|
||||
<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 ||
|
||||
(!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>
|
||||
|
||||
{/* Execution ID */}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { List, type RowComponentProps, useListRef } from 'react-window'
|
||||
import { Badge, buttonVariants } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
DELETED_WORKFLOW_COLOR,
|
||||
DELETED_WORKFLOW_LABEL,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
getDisplayStatus,
|
||||
@@ -33,6 +35,11 @@ interface LogRowProps {
|
||||
const LogRow = memo(
|
||||
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
|
||||
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])
|
||||
|
||||
@@ -78,10 +85,15 @@ const LogRow = memo(
|
||||
>
|
||||
<div
|
||||
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: log.workflow?.color }}
|
||||
style={{ backgroundColor: workflowColor }}
|
||||
/>
|
||||
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
{log.workflow?.name || 'Unknown'}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate font-medium text-[12px]',
|
||||
isDeletedWorkflow ? 'text-[var(--text-tertiary)]' : 'text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{workflowName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function Logs() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [isLive, setIsLive] = useState(true)
|
||||
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const isSearchOpenRef = useRef<boolean>(false)
|
||||
|
||||
@@ -27,6 +27,9 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
|
||||
'duration',
|
||||
] 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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
||||
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -49,6 +51,7 @@ export const ActionBar = memo(
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { setPendingSelection } = useWorkflowRegistry()
|
||||
const { handleRunFromBlock } = useWorkflowExecution()
|
||||
|
||||
const addNotification = useNotificationStore((s) => s.addNotification)
|
||||
|
||||
@@ -97,12 +100,39 @@ export const ActionBar = memo(
|
||||
)
|
||||
)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { isExecuting, getLastExecutionSnapshot } = useExecutionStore()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const edges = useWorkflowStore((state) => state.edges)
|
||||
|
||||
const isStartBlock = isInputDefinitionTrigger(blockType)
|
||||
const isResponseBlock = blockType === 'response'
|
||||
const isNoteBlock = blockType === 'note'
|
||||
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
|
||||
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
|
||||
|
||||
const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null
|
||||
const incomingEdges = edges.filter((edge) => edge.target === blockId)
|
||||
const isTriggerBlock = incomingEdges.length === 0
|
||||
|
||||
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
|
||||
const isSourceSatisfied = (sourceId: string) => {
|
||||
if (snapshot?.executedBlocks.includes(sourceId)) return true
|
||||
// Check if source is a trigger (has no incoming edges itself)
|
||||
const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId)
|
||||
return sourceIncomingEdges.length === 0
|
||||
}
|
||||
|
||||
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
|
||||
const dependenciesSatisfied =
|
||||
isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
|
||||
const canRunFromBlock =
|
||||
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
|
||||
|
||||
const handleRunFromBlockClick = useCallback(() => {
|
||||
if (!activeWorkflowId || !canRunFromBlock) return
|
||||
handleRunFromBlock(blockId, activeWorkflowId)
|
||||
}, [blockId, activeWorkflowId, canRunFromBlock, handleRunFromBlock])
|
||||
|
||||
/**
|
||||
* Get appropriate tooltip message based on disabled state
|
||||
@@ -128,30 +158,35 @@ export const ActionBar = memo(
|
||||
'dark:border-transparent dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
>
|
||||
{!isNoteBlock && (
|
||||
{!isNoteBlock && !isInsideSubflow && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeBatchToggleBlockEnabled([blockId])
|
||||
if (canRunFromBlock && !disabled) {
|
||||
handleRunFromBlockClick()
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={disabled || !canRunFromBlock}
|
||||
>
|
||||
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
||||
<PlayOutline className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||
{(() => {
|
||||
if (disabled) return getTooltipMessage('Run from block')
|
||||
if (isExecuting) return 'Execution in progress'
|
||||
if (!dependenciesSatisfied) return 'Run upstream blocks first'
|
||||
return 'Run from block'
|
||||
})()}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{isSubflowBlock && (
|
||||
{!isNoteBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -40,9 +40,16 @@ export interface BlockMenuProps {
|
||||
onRemoveFromSubflow: () => void
|
||||
onOpenEditor: () => void
|
||||
onRename: () => void
|
||||
onRunFromBlock?: () => void
|
||||
onRunUntilBlock?: () => void
|
||||
hasClipboard?: boolean
|
||||
showRemoveFromSubflow?: boolean
|
||||
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
|
||||
canRunFromBlock?: boolean
|
||||
disableEdit?: boolean
|
||||
isExecuting?: boolean
|
||||
/** Whether the selected block is a trigger (has no incoming edges) */
|
||||
isPositionalTrigger?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,9 +72,14 @@ export function BlockMenu({
|
||||
onRemoveFromSubflow,
|
||||
onOpenEditor,
|
||||
onRename,
|
||||
onRunFromBlock,
|
||||
onRunUntilBlock,
|
||||
hasClipboard = false,
|
||||
showRemoveFromSubflow = false,
|
||||
canRunFromBlock = false,
|
||||
disableEdit = false,
|
||||
isExecuting = false,
|
||||
isPositionalTrigger = false,
|
||||
}: BlockMenuProps) {
|
||||
const isSingleBlock = selectedBlocks.length === 1
|
||||
|
||||
@@ -78,10 +90,15 @@ export function BlockMenu({
|
||||
(b) =>
|
||||
TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type)
|
||||
)
|
||||
const hasTriggerBlock = selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b))
|
||||
// A block is a trigger if it's explicitly a trigger type OR has no incoming edges (positional trigger)
|
||||
const hasTriggerBlock =
|
||||
selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b)) || isPositionalTrigger
|
||||
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
|
||||
const isSubflow =
|
||||
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
|
||||
const isInsideSubflow =
|
||||
isSingleBlock &&
|
||||
(selectedBlocks[0]?.parentType === 'loop' || selectedBlocks[0]?.parentType === 'parallel')
|
||||
|
||||
const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock
|
||||
|
||||
@@ -203,6 +220,38 @@ export function BlockMenu({
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Run from/until block - only for single non-note block, not inside subflows */}
|
||||
{isSingleBlock && !allNoteBlocks && !isInsideSubflow && (
|
||||
<>
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
disabled={!canRunFromBlock || isExecuting}
|
||||
onClick={() => {
|
||||
if (canRunFromBlock && !isExecuting) {
|
||||
onRunFromBlock?.()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Run from block
|
||||
</PopoverItem>
|
||||
{/* Hide "Run until" for triggers - they're always at the start */}
|
||||
{!hasTriggerBlock && (
|
||||
<PopoverItem
|
||||
disabled={isExecuting}
|
||||
onClick={() => {
|
||||
if (!isExecuting) {
|
||||
onRunUntilBlock?.()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Run until block
|
||||
</PopoverItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
useGenerateVersionDescription,
|
||||
useUpdateDeploymentVersion,
|
||||
} from '@/hooks/queries/deployments'
|
||||
|
||||
interface VersionDescriptionModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId: string
|
||||
version: number
|
||||
versionName: string
|
||||
currentDescription: string | null | undefined
|
||||
}
|
||||
|
||||
export function VersionDescriptionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowId,
|
||||
version,
|
||||
versionName,
|
||||
currentDescription,
|
||||
}: VersionDescriptionModalProps) {
|
||||
const initialDescriptionRef = useRef(currentDescription || '')
|
||||
const [description, setDescription] = useState(initialDescriptionRef.current)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
|
||||
const updateMutation = useUpdateDeploymentVersion()
|
||||
const generateMutation = useGenerateVersionDescription()
|
||||
|
||||
const hasChanges = description.trim() !== initialDescriptionRef.current.trim()
|
||||
const isGenerating = generateMutation.isPending
|
||||
|
||||
const handleCloseAttempt = useCallback(() => {
|
||||
if (updateMutation.isPending || isGenerating) {
|
||||
return
|
||||
}
|
||||
if (hasChanges) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}, [hasChanges, updateMutation.isPending, isGenerating, onOpenChange])
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setDescription(initialDescriptionRef.current)
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleGenerateDescription = useCallback(() => {
|
||||
generateMutation.mutate({
|
||||
workflowId,
|
||||
version,
|
||||
onStreamChunk: (accumulated) => {
|
||||
setDescription(accumulated)
|
||||
},
|
||||
})
|
||||
}, [workflowId, version, generateMutation])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!workflowId) return
|
||||
|
||||
updateMutation.mutate(
|
||||
{
|
||||
workflowId,
|
||||
version,
|
||||
description: description.trim() || null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [workflowId, version, description, updateMutation, onOpenChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
|
||||
<ModalContent className='max-w-[480px]'>
|
||||
<ModalHeader>
|
||||
<span>Version Description</span>
|
||||
</ModalHeader>
|
||||
<ModalBody className='space-y-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{currentDescription ? 'Edit the' : 'Add a'} description for{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
|
||||
</p>
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={isGenerating || updateMutation.isPending}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder='Describe the changes in this deployment version...'
|
||||
className='min-h-[120px] resize-none'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<div className='flex items-center justify-between'>
|
||||
{(updateMutation.error || generateMutation.error) && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{updateMutation.error?.message || generateMutation.error?.message}
|
||||
</p>
|
||||
)}
|
||||
{!updateMutation.error && !generateMutation.error && <div />}
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleCloseAttempt}
|
||||
disabled={updateMutation.isPending || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending || isGenerating || !hasChanges}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||
<ModalContent className='max-w-[400px]'>
|
||||
<ModalHeader>
|
||||
<span>Unsaved Changes</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>
|
||||
You have unsaved changes. Are you sure you want to discard them?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
|
||||
Keep Editing
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDiscardChanges}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/core/utils/formatting'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
|
||||
import { VersionDescriptionModal } from './version-description-modal'
|
||||
|
||||
const logger = createLogger('Versions')
|
||||
|
||||
/** Shared styling constants aligned with terminal component */
|
||||
const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
|
||||
const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]'
|
||||
const COLUMN_BASE_CLASS = 'flex-shrink-0'
|
||||
|
||||
/** Column width configuration */
|
||||
const COLUMN_WIDTHS = {
|
||||
VERSION: 'w-[180px]',
|
||||
DEPLOYED_BY: 'w-[140px]',
|
||||
TIMESTAMP: 'flex-1',
|
||||
ACTIONS: 'w-[32px]',
|
||||
ACTIONS: 'w-[56px]',
|
||||
} as const
|
||||
|
||||
interface VersionsProps {
|
||||
@@ -31,34 +36,6 @@ interface VersionsProps {
|
||||
onSelectVersion: (version: number | null) => void
|
||||
onPromoteToLive: (version: number) => void
|
||||
onLoadDeployment: (version: number) => void
|
||||
fetchVersions: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp into a readable string.
|
||||
* @param value - The date string or Date object to format
|
||||
* @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
|
||||
*/
|
||||
const formatDate = (value: string | Date): string => {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const timePart = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
|
||||
const datePart = date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
return `${timePart} on ${datePart}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,14 +50,15 @@ export function Versions({
|
||||
onSelectVersion,
|
||||
onPromoteToLive,
|
||||
onLoadDeployment,
|
||||
fetchVersions,
|
||||
}: VersionsProps) {
|
||||
const [editingVersion, setEditingVersion] = useState<number | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [openDropdown, setOpenDropdown] = useState<number | null>(null)
|
||||
const [descriptionModalVersion, setDescriptionModalVersion] = useState<number | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const renameMutation = useUpdateDeploymentVersion()
|
||||
|
||||
useEffect(() => {
|
||||
if (editingVersion !== null && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
@@ -94,7 +72,8 @@ export function Versions({
|
||||
setEditValue(currentName || `v${version}`)
|
||||
}
|
||||
|
||||
const handleSaveRename = async (version: number) => {
|
||||
const handleSaveRename = (version: number) => {
|
||||
if (renameMutation.isPending) return
|
||||
if (!workflowId || !editValue.trim()) {
|
||||
setEditingVersion(null)
|
||||
return
|
||||
@@ -108,25 +87,21 @@ export function Versions({
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: editValue.trim() }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
await fetchVersions()
|
||||
setEditingVersion(null)
|
||||
} else {
|
||||
logger.error('Failed to rename version')
|
||||
renameMutation.mutate(
|
||||
{
|
||||
workflowId,
|
||||
version,
|
||||
name: editValue.trim(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditingVersion(null)
|
||||
},
|
||||
onError: () => {
|
||||
// Keep editing state open on error so user can retry
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error renaming version:', error)
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
@@ -149,6 +124,16 @@ export function Versions({
|
||||
onLoadDeployment(version)
|
||||
}
|
||||
|
||||
const handleOpenDescriptionModal = (version: number) => {
|
||||
setOpenDropdown(null)
|
||||
setDescriptionModalVersion(version)
|
||||
}
|
||||
|
||||
const descriptionModalVersionData =
|
||||
descriptionModalVersion !== null
|
||||
? versions.find((v) => v.version === descriptionModalVersion)
|
||||
: null
|
||||
|
||||
if (versionsLoading && versions.length === 0) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||
@@ -179,7 +164,14 @@ export function Versions({
|
||||
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
|
||||
<Skeleton className='h-[12px] w-[160px]' />
|
||||
</div>
|
||||
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}>
|
||||
<div
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.ACTIONS,
|
||||
COLUMN_BASE_CLASS,
|
||||
'flex justify-end gap-[2px]'
|
||||
)}
|
||||
>
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,7 +249,7 @@ export function Versions({
|
||||
'text-[var(--text-primary)] focus:outline-none focus:ring-0'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
disabled={renameMutation.isPending}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
@@ -289,14 +281,40 @@ export function Versions({
|
||||
<span
|
||||
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
|
||||
>
|
||||
{formatDate(v.createdAt)}
|
||||
{formatDateTime(new Date(v.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.ACTIONS,
|
||||
COLUMN_BASE_CLASS,
|
||||
'flex items-center justify-end gap-[2px]'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'!p-1',
|
||||
!v.description &&
|
||||
'text-[var(--text-quaternary)] hover:text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={() => handleOpenDescriptionModal(v.version)}
|
||||
>
|
||||
<FileText className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[240px]'>
|
||||
{v.description ? (
|
||||
<p className='line-clamp-3 text-[12px]'>{v.description}</p>
|
||||
) : (
|
||||
<p className='text-[12px]'>Add description</p>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Popover
|
||||
open={openDropdown === v.version}
|
||||
onOpenChange={(open) => setOpenDropdown(open ? v.version : null)}
|
||||
@@ -311,6 +329,10 @@ export function Versions({
|
||||
<Pencil className='h-3 w-3' />
|
||||
<span>Rename</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem onClick={() => handleOpenDescriptionModal(v.version)}>
|
||||
<FileText className='h-3 w-3' />
|
||||
<span>{v.description ? 'Edit description' : 'Add description'}</span>
|
||||
</PopoverItem>
|
||||
{!v.isActive && (
|
||||
<PopoverItem onClick={() => handlePromote(v.version)}>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
@@ -328,6 +350,20 @@ export function Versions({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{workflowId && descriptionModalVersionData && (
|
||||
<VersionDescriptionModal
|
||||
key={descriptionModalVersionData.version}
|
||||
open={descriptionModalVersion !== null}
|
||||
onOpenChange={(open) => !open && setDescriptionModalVersion(null)}
|
||||
workflowId={workflowId}
|
||||
version={descriptionModalVersionData.version}
|
||||
versionName={
|
||||
descriptionModalVersionData.name || `v${descriptionModalVersionData.version}`
|
||||
}
|
||||
currentDescription={descriptionModalVersionData.description}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ interface GeneralDeployProps {
|
||||
versionsLoading: boolean
|
||||
onPromoteToLive: (version: number) => Promise<void>
|
||||
onLoadDeploymentComplete: () => void
|
||||
fetchVersions: () => Promise<void>
|
||||
}
|
||||
|
||||
type PreviewMode = 'active' | 'selected'
|
||||
@@ -48,7 +47,6 @@ export function GeneralDeploy({
|
||||
versionsLoading,
|
||||
onPromoteToLive,
|
||||
onLoadDeploymentComplete,
|
||||
fetchVersions,
|
||||
}: GeneralDeployProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
|
||||
@@ -229,7 +227,6 @@ export function GeneralDeploy({
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeployment={handleLoadDeployment}
|
||||
fetchVersions={fetchVersions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,11 +135,9 @@ export function DeployModal({
|
||||
refetch: refetchDeploymentInfo,
|
||||
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
|
||||
|
||||
const {
|
||||
data: versionsData,
|
||||
isLoading: versionsLoading,
|
||||
refetch: refetchVersions,
|
||||
} = useDeploymentVersions(workflowId, { enabled: open })
|
||||
const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, {
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const {
|
||||
isLoading: isLoadingChat,
|
||||
@@ -450,10 +448,6 @@ export function DeployModal({
|
||||
deleteTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleFetchVersions = useCallback(async () => {
|
||||
await refetchVersions()
|
||||
}, [refetchVersions])
|
||||
|
||||
const isSubmitting = deployMutation.isPending
|
||||
const isUndeploying = undeployMutation.isPending
|
||||
|
||||
@@ -512,7 +506,6 @@ export function DeployModal({
|
||||
versionsLoading={versionsLoading}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
fetchVersions={handleFetchVersions}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDown, RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import {
|
||||
FieldItem,
|
||||
type SchemaField,
|
||||
@@ -115,9 +116,8 @@ function ConnectionItem({
|
||||
{hasFields && (
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
|
||||
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]',
|
||||
isExpanded && 'rotate-180'
|
||||
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
|
||||
!isExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface SelectorComboboxProps {
|
||||
readOnly?: boolean
|
||||
onOptionChange?: (value: string) => void
|
||||
allowSearch?: boolean
|
||||
missingOptionLabel?: string
|
||||
}
|
||||
|
||||
export function SelectorCombobox({
|
||||
@@ -37,6 +38,7 @@ export function SelectorCombobox({
|
||||
readOnly,
|
||||
onOptionChange,
|
||||
allowSearch = true,
|
||||
missingOptionLabel,
|
||||
}: SelectorComboboxProps) {
|
||||
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
|
||||
blockId,
|
||||
@@ -60,7 +62,16 @@ export function SelectorCombobox({
|
||||
detailId: activeValue,
|
||||
})
|
||||
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
|
||||
const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
|
||||
const hasMissingOption =
|
||||
Boolean(activeValue) &&
|
||||
Boolean(missingOptionLabel) &&
|
||||
!isLoading &&
|
||||
!optionMap.get(activeValue!)
|
||||
const selectedLabel = activeValue
|
||||
? hasMissingOption
|
||||
? missingOptionLabel
|
||||
: (optionMap.get(activeValue)?.label ?? activeValue)
|
||||
: ''
|
||||
const [inputValue, setInputValue] = useState(selectedLabel)
|
||||
const previousActiveValue = useRef<string | undefined>(activeValue)
|
||||
|
||||
|
||||
@@ -180,20 +180,6 @@ function resolveCustomToolFromReference(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a stored custom tool uses the reference-only format.
|
||||
*
|
||||
* @remarks
|
||||
* Reference-only format means the tool has a customToolId but no inline code/schema,
|
||||
* requiring resolution from the database at runtime.
|
||||
*
|
||||
* @param storedTool - The stored tool to check
|
||||
* @returns `true` if the tool is a reference-only custom tool, `false` otherwise
|
||||
*/
|
||||
function isCustomToolReference(storedTool: StoredTool): boolean {
|
||||
return storedTool.type === 'custom-tool' && !!storedTool.customToolId && !storedTool.code
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic sync wrapper that synchronizes store values with local component state.
|
||||
*
|
||||
@@ -1155,21 +1141,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
return filterBlocks(allToolBlocks)
|
||||
}, [filterBlocks])
|
||||
|
||||
const customFilter = useCallback((value: string, search: string) => {
|
||||
if (!search.trim()) return 1
|
||||
|
||||
const normalizedValue = value.toLowerCase()
|
||||
const normalizedSearch = search.toLowerCase()
|
||||
|
||||
if (normalizedValue === normalizedSearch) return 1
|
||||
|
||||
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
|
||||
|
||||
if (normalizedValue.includes(normalizedSearch)) return 0.6
|
||||
|
||||
return 0
|
||||
}, [])
|
||||
|
||||
const hasBackfilledRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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 type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
@@ -40,6 +41,7 @@ export function WorkflowSelectorInput({
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
placeholder={subBlock.placeholder || 'Select workflow...'}
|
||||
missingOptionLabel={DELETED_WORKFLOW_LABEL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Filter } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import type {
|
||||
BlockInfo,
|
||||
TerminalFilters,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
|
||||
|
||||
/**
|
||||
* Props for the FilterPopover component
|
||||
*/
|
||||
export interface FilterPopoverProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
filters: TerminalFilters
|
||||
toggleStatus: (status: 'error' | 'info') => void
|
||||
toggleBlock: (blockId: string) => void
|
||||
uniqueBlocks: BlockInfo[]
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter popover component used in terminal header and output panel
|
||||
*/
|
||||
export const FilterPopover = memo(function FilterPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
toggleStatus,
|
||||
toggleBlock,
|
||||
uniqueBlocks,
|
||||
hasActiveFilters,
|
||||
}: FilterPopoverProps) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} size='sm'>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1.5 -m-1.5'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='Filters'
|
||||
>
|
||||
<Filter
|
||||
className={clsx('h-3 w-3', hasActiveFilters && 'text-[var(--brand-secondary)]')}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='top'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
minWidth={160}
|
||||
maxWidth={220}
|
||||
maxHeight={300}
|
||||
>
|
||||
<PopoverSection>Status</PopoverSection>
|
||||
<PopoverItem
|
||||
active={filters.statuses.has('error')}
|
||||
showCheck={filters.statuses.has('error')}
|
||||
onClick={() => toggleStatus('error')}
|
||||
>
|
||||
<div
|
||||
className='h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{ backgroundColor: 'var(--text-error)' }}
|
||||
/>
|
||||
<span className='flex-1'>Error</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={filters.statuses.has('info')}
|
||||
showCheck={filters.statuses.has('info')}
|
||||
onClick={() => toggleStatus('info')}
|
||||
>
|
||||
<div
|
||||
className='h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
|
||||
/>
|
||||
<span className='flex-1'>Info</span>
|
||||
</PopoverItem>
|
||||
|
||||
{uniqueBlocks.length > 0 && (
|
||||
<>
|
||||
<PopoverDivider className='my-[4px]' />
|
||||
<PopoverSection className='!mt-0'>Blocks</PopoverSection>
|
||||
<PopoverScrollArea className='max-h-[100px]'>
|
||||
{uniqueBlocks.map((block) => {
|
||||
const BlockIcon = getBlockIcon(block.blockType)
|
||||
const isSelected = filters.blockIds.has(block.blockId)
|
||||
|
||||
return (
|
||||
<PopoverItem
|
||||
key={block.blockId}
|
||||
active={isSelected}
|
||||
showCheck={isSelected}
|
||||
onClick={() => toggleBlock(block.blockId)}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className='h-3 w-3' />}
|
||||
<span className='flex-1'>{block.blockName}</span>
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
</PopoverScrollArea>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { FilterPopover, type FilterPopoverProps } from './filter-popover'
|
||||
@@ -1,2 +1,5 @@
|
||||
export { LogRowContextMenu } from './log-row-context-menu'
|
||||
export { OutputContextMenu } from './output-context-menu'
|
||||
export { FilterPopover, type FilterPopoverProps } from './filter-popover'
|
||||
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
|
||||
export { OutputPanel, type OutputPanelProps } from './output-panel'
|
||||
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'
|
||||
export { ToggleButton, type ToggleButtonProps } from './toggle-button'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { memo, type RefObject } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -8,20 +8,13 @@ import {
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type {
|
||||
ContextMenuPosition,
|
||||
TerminalFilters,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
|
||||
interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface TerminalFilters {
|
||||
blockIds: Set<string>
|
||||
statuses: Set<'error' | 'info'>
|
||||
runIds: Set<string>
|
||||
}
|
||||
|
||||
interface LogRowContextMenuProps {
|
||||
export interface LogRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: ContextMenuPosition
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
@@ -30,19 +23,16 @@ interface LogRowContextMenuProps {
|
||||
filters: TerminalFilters
|
||||
onFilterByBlock: (blockId: string) => void
|
||||
onFilterByStatus: (status: 'error' | 'info') => void
|
||||
onFilterByRunId: (runId: string) => void
|
||||
onCopyRunId: (runId: string) => void
|
||||
onClearFilters: () => void
|
||||
onClearConsole: () => void
|
||||
onFixInCopilot: (entry: ConsoleEntry) => void
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for terminal log rows (left side).
|
||||
* Displays filtering options based on the selected row's properties.
|
||||
*/
|
||||
export function LogRowContextMenu({
|
||||
export const LogRowContextMenu = memo(function LogRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
@@ -51,19 +41,15 @@ export function LogRowContextMenu({
|
||||
filters,
|
||||
onFilterByBlock,
|
||||
onFilterByStatus,
|
||||
onFilterByRunId,
|
||||
onCopyRunId,
|
||||
onClearFilters,
|
||||
onClearConsole,
|
||||
onFixInCopilot,
|
||||
hasActiveFilters,
|
||||
}: LogRowContextMenuProps) {
|
||||
const hasRunId = entry?.executionId != null
|
||||
|
||||
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
|
||||
const entryStatus = entry?.success ? 'info' : 'error'
|
||||
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
|
||||
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -134,34 +120,11 @@ export function LogRowContextMenu({
|
||||
>
|
||||
Filter by Status
|
||||
</PopoverItem>
|
||||
{hasRunId && (
|
||||
<PopoverItem
|
||||
showCheck={isRunIdFiltered}
|
||||
onClick={() => {
|
||||
onFilterByRunId(entry.executionId!)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Filter by Run ID
|
||||
</PopoverItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearFilters()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
{(entry || hasActiveFilters) && <PopoverDivider />}
|
||||
{entry && <PopoverDivider />}
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearConsole()
|
||||
@@ -173,4 +136,4 @@ export function LogRowContextMenu({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { memo, type RefObject } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -8,13 +8,9 @@ import {
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
|
||||
interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface OutputContextMenuProps {
|
||||
export interface OutputContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: ContextMenuPosition
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
@@ -22,6 +18,8 @@ interface OutputContextMenuProps {
|
||||
onCopySelection: () => void
|
||||
onCopyAll: () => void
|
||||
onSearch: () => void
|
||||
structuredView: boolean
|
||||
onToggleStructuredView: () => void
|
||||
wrapText: boolean
|
||||
onToggleWrap: () => void
|
||||
openOnRun: boolean
|
||||
@@ -34,7 +32,7 @@ interface OutputContextMenuProps {
|
||||
* Context menu for terminal output panel (right side).
|
||||
* Displays copy, search, and display options for the code viewer.
|
||||
*/
|
||||
export function OutputContextMenu({
|
||||
export const OutputContextMenu = memo(function OutputContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
@@ -42,6 +40,8 @@ export function OutputContextMenu({
|
||||
onCopySelection,
|
||||
onCopyAll,
|
||||
onSearch,
|
||||
structuredView,
|
||||
onToggleStructuredView,
|
||||
wrapText,
|
||||
onToggleWrap,
|
||||
openOnRun,
|
||||
@@ -96,6 +96,9 @@ export function OutputContextMenu({
|
||||
|
||||
{/* Display settings - toggles don't close menu */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem showCheck={structuredView} onClick={onToggleStructuredView}>
|
||||
Structured View
|
||||
</PopoverItem>
|
||||
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
|
||||
Wrap Text
|
||||
</PopoverItem>
|
||||
@@ -116,4 +119,4 @@ export function OutputContextMenu({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,913 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { List, type RowComponentProps, useListRef } from 'react-window'
|
||||
import { Badge, ChevronDown } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object'
|
||||
type BadgeVariant = 'green' | 'blue' | 'orange' | 'purple' | 'gray' | 'red'
|
||||
|
||||
interface NodeEntry {
|
||||
key: string
|
||||
value: unknown
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Search context for structured output tree.
|
||||
*/
|
||||
interface SearchContextValue {
|
||||
query: string
|
||||
pathToMatchIndices: Map<string, number[]>
|
||||
}
|
||||
|
||||
const SearchContext = createContext<SearchContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Configuration for virtualized rendering.
|
||||
*/
|
||||
const CONFIG = {
|
||||
ROW_HEIGHT: 22,
|
||||
INDENT_PER_LEVEL: 12,
|
||||
BASE_PADDING: 20,
|
||||
MAX_SEARCH_DEPTH: 100,
|
||||
OVERSCAN_COUNT: 10,
|
||||
VIRTUALIZATION_THRESHOLD: 200,
|
||||
} as const
|
||||
|
||||
const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
|
||||
string: 'green',
|
||||
number: 'blue',
|
||||
boolean: 'orange',
|
||||
array: 'purple',
|
||||
null: 'gray',
|
||||
undefined: 'gray',
|
||||
object: 'gray',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Styling constants matching the original non-virtualized implementation.
|
||||
*/
|
||||
const STYLES = {
|
||||
row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
|
||||
chevron:
|
||||
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
|
||||
keyName:
|
||||
'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]',
|
||||
badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
|
||||
summary: 'text-[12px] text-[var(--text-tertiary)]',
|
||||
indent:
|
||||
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
|
||||
value: 'min-w-0 py-[2px] text-[13px] text-[var(--text-primary)]',
|
||||
emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]',
|
||||
matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40',
|
||||
currentMatchHighlight: 'bg-orange-400',
|
||||
} as const
|
||||
|
||||
const EMPTY_MATCH_INDICES: number[] = []
|
||||
|
||||
function getTypeLabel(value: unknown): ValueType {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
return typeof value as ValueType
|
||||
}
|
||||
|
||||
function formatPrimitive(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function isPrimitive(value: unknown): value is null | undefined | string | number | boolean {
|
||||
return value === null || value === undefined || typeof value !== 'object'
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0
|
||||
return false
|
||||
}
|
||||
|
||||
function extractErrorMessage(data: unknown): string {
|
||||
if (typeof data === 'string') return data
|
||||
if (data instanceof Error) return data.message
|
||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||
return String((data as { message: unknown }).message)
|
||||
}
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
function buildEntries(value: unknown, basePath: string): NodeEntry[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, i) => ({ key: String(i), value: item, path: `${basePath}[${i}]` }))
|
||||
}
|
||||
return Object.entries(value as Record<string, unknown>).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: v,
|
||||
path: `${basePath}.${k}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function getCollapsedSummary(value: unknown): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
const len = value.length
|
||||
return `${len} item${len !== 1 ? 's' : ''}`
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const count = Object.keys(value).length
|
||||
return `${count} key${count !== 1 ? 's' : ''}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function computeInitialPaths(data: unknown, isError: boolean): Set<string> {
|
||||
if (isError) return new Set(['root.error'])
|
||||
if (!data || typeof data !== 'object') return new Set()
|
||||
const entries = Array.isArray(data)
|
||||
? data.map((_, i) => `root[${i}]`)
|
||||
: Object.keys(data).map((k) => `root.${k}`)
|
||||
return new Set(entries)
|
||||
}
|
||||
|
||||
function getAncestorPaths(path: string): string[] {
|
||||
const ancestors: string[] = []
|
||||
let current = path
|
||||
|
||||
while (current.includes('.') || current.includes('[')) {
|
||||
const splitPoint = Math.max(current.lastIndexOf('.'), current.lastIndexOf('['))
|
||||
if (splitPoint <= 0) break
|
||||
current = current.slice(0, splitPoint)
|
||||
if (current !== 'root') ancestors.push(current)
|
||||
}
|
||||
|
||||
return ancestors
|
||||
}
|
||||
|
||||
function findTextMatches(text: string, query: string): Array<[number, number]> {
|
||||
if (!query) return []
|
||||
|
||||
const matches: Array<[number, number]> = []
|
||||
const lowerText = text.toLowerCase()
|
||||
const lowerQuery = query.toLowerCase()
|
||||
let pos = 0
|
||||
|
||||
while (pos < lowerText.length) {
|
||||
const idx = lowerText.indexOf(lowerQuery, pos)
|
||||
if (idx === -1) break
|
||||
matches.push([idx, idx + query.length])
|
||||
pos = idx + 1
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void {
|
||||
const text = formatPrimitive(value)
|
||||
const count = findTextMatches(text, query).length
|
||||
for (let i = 0; i < count; i++) {
|
||||
matches.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
function collectAllMatchPaths(data: unknown, query: string, basePath: string, depth = 0): string[] {
|
||||
if (!query || depth > CONFIG.MAX_SEARCH_DEPTH) return []
|
||||
|
||||
const matches: string[] = []
|
||||
|
||||
if (isPrimitive(data)) {
|
||||
addPrimitiveMatches(data, `${basePath}.value`, query, matches)
|
||||
return matches
|
||||
}
|
||||
|
||||
for (const entry of buildEntries(data, basePath)) {
|
||||
if (isPrimitive(entry.value)) {
|
||||
addPrimitiveMatches(entry.value, entry.path, query, matches)
|
||||
} else {
|
||||
matches.push(...collectAllMatchPaths(entry.value, query, entry.path, depth + 1))
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function buildPathToIndicesMap(matchPaths: string[]): Map<string, number[]> {
|
||||
const map = new Map<string, number[]>()
|
||||
matchPaths.forEach((path, globalIndex) => {
|
||||
const existing = map.get(path)
|
||||
if (existing) {
|
||||
existing.push(globalIndex)
|
||||
} else {
|
||||
map.set(path, [globalIndex])
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text with search highlights using segments.
|
||||
*/
|
||||
function renderHighlightedSegments(
|
||||
text: string,
|
||||
query: string,
|
||||
matchIndices: number[],
|
||||
currentMatchIndex: number,
|
||||
path: string
|
||||
): React.ReactNode {
|
||||
if (!query || matchIndices.length === 0) return text
|
||||
|
||||
const textMatches = findTextMatches(text, query)
|
||||
if (textMatches.length === 0) return text
|
||||
|
||||
const segments: React.ReactNode[] = []
|
||||
let lastEnd = 0
|
||||
|
||||
textMatches.forEach(([start, end], i) => {
|
||||
const globalIndex = matchIndices[i]
|
||||
const isCurrent = globalIndex === currentMatchIndex
|
||||
|
||||
if (start > lastEnd) {
|
||||
segments.push(<span key={`t-${path}-${start}`}>{text.slice(lastEnd, start)}</span>)
|
||||
}
|
||||
|
||||
segments.push(
|
||||
<mark
|
||||
key={`m-${path}-${start}`}
|
||||
data-search-match
|
||||
data-match-index={globalIndex}
|
||||
className={cn(
|
||||
'rounded-sm',
|
||||
isCurrent ? STYLES.currentMatchHighlight : STYLES.matchHighlight
|
||||
)}
|
||||
>
|
||||
{text.slice(start, end)}
|
||||
</mark>
|
||||
)
|
||||
lastEnd = end
|
||||
})
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
segments.push(<span key={`t-${path}-${lastEnd}`}>{text.slice(lastEnd)}</span>)
|
||||
}
|
||||
|
||||
return <>{segments}</>
|
||||
}
|
||||
|
||||
interface HighlightedTextProps {
|
||||
text: string
|
||||
matchIndices: number[]
|
||||
path: string
|
||||
currentMatchIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text with search highlights for non-virtualized mode.
|
||||
* Accepts currentMatchIndex as prop to ensure re-render when it changes.
|
||||
*/
|
||||
const HighlightedText = memo(function HighlightedText({
|
||||
text,
|
||||
matchIndices,
|
||||
path,
|
||||
currentMatchIndex,
|
||||
}: HighlightedTextProps) {
|
||||
const searchContext = useContext(SearchContext)
|
||||
|
||||
if (!searchContext || matchIndices.length === 0) return <>{text}</>
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderHighlightedSegments(text, searchContext.query, matchIndices, currentMatchIndex, path)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
interface StructuredNodeProps {
|
||||
name: string
|
||||
value: unknown
|
||||
path: string
|
||||
expandedPaths: Set<string>
|
||||
onToggle: (path: string) => void
|
||||
wrapText: boolean
|
||||
currentMatchIndex: number
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive node component for non-virtualized rendering.
|
||||
* Preserves exact original styling with border-left tree lines.
|
||||
*/
|
||||
const StructuredNode = memo(function StructuredNode({
|
||||
name,
|
||||
value,
|
||||
path,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
wrapText,
|
||||
currentMatchIndex,
|
||||
isError = false,
|
||||
}: StructuredNodeProps) {
|
||||
const searchContext = useContext(SearchContext)
|
||||
const type = getTypeLabel(value)
|
||||
const isPrimitiveValue = isPrimitive(value)
|
||||
const isEmptyValue = !isPrimitiveValue && isEmpty(value)
|
||||
const isExpanded = expandedPaths.has(path)
|
||||
|
||||
const handleToggle = useCallback(() => onToggle(path), [onToggle, path])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}
|
||||
},
|
||||
[handleToggle]
|
||||
)
|
||||
|
||||
const childEntries = useMemo(
|
||||
() => (isPrimitiveValue || isEmptyValue ? [] : buildEntries(value, path)),
|
||||
[value, isPrimitiveValue, isEmptyValue, path]
|
||||
)
|
||||
|
||||
const collapsedSummary = useMemo(
|
||||
() => (isPrimitiveValue ? null : getCollapsedSummary(value)),
|
||||
[value, isPrimitiveValue]
|
||||
)
|
||||
|
||||
const badgeVariant = isError ? 'red' : BADGE_VARIANTS[type]
|
||||
const valueText = isPrimitiveValue ? formatPrimitive(value) : ''
|
||||
const matchIndices = searchContext?.pathToMatchIndices.get(path) ?? EMPTY_MATCH_INDICES
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<div
|
||||
className={STYLES.row}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className={cn(STYLES.keyName, isError && 'text-[var(--text-error)]')}>{name}</span>
|
||||
<Badge variant={badgeVariant} className={STYLES.badge}>
|
||||
{type}
|
||||
</Badge>
|
||||
{!isExpanded && collapsedSummary && (
|
||||
<span className={STYLES.summary}>{collapsedSummary}</span>
|
||||
)}
|
||||
<ChevronDown className={cn(STYLES.chevron, !isExpanded && '-rotate-90')} />
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={STYLES.indent}>
|
||||
{isPrimitiveValue ? (
|
||||
<div
|
||||
className={cn(
|
||||
STYLES.value,
|
||||
wrapText ? '[word-break:break-word]' : 'whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
<HighlightedText
|
||||
text={valueText}
|
||||
matchIndices={matchIndices}
|
||||
path={path}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
/>
|
||||
</div>
|
||||
) : isEmptyValue ? (
|
||||
<div className={STYLES.emptyValue}>{Array.isArray(value) ? '[]' : '{}'}</div>
|
||||
) : (
|
||||
childEntries.map((entry) => (
|
||||
<StructuredNode
|
||||
key={entry.path}
|
||||
name={entry.key}
|
||||
value={entry.value}
|
||||
path={entry.path}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
wrapText={wrapText}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Flattened row for virtualization.
|
||||
*/
|
||||
interface FlatRow {
|
||||
path: string
|
||||
key: string
|
||||
value: unknown
|
||||
depth: number
|
||||
type: 'header' | 'value' | 'empty'
|
||||
valueType: ValueType
|
||||
isExpanded: boolean
|
||||
isError: boolean
|
||||
collapsedSummary: string | null
|
||||
displayText: string
|
||||
matchIndices: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the tree into rows for virtualization.
|
||||
*/
|
||||
function flattenTree(
|
||||
data: unknown,
|
||||
expandedPaths: Set<string>,
|
||||
pathToMatchIndices: Map<string, number[]>,
|
||||
isError: boolean
|
||||
): FlatRow[] {
|
||||
const rows: FlatRow[] = []
|
||||
|
||||
if (isError) {
|
||||
const errorText = extractErrorMessage(data)
|
||||
const isExpanded = expandedPaths.has('root.error')
|
||||
|
||||
rows.push({
|
||||
path: 'root.error',
|
||||
key: 'error',
|
||||
value: errorText,
|
||||
depth: 0,
|
||||
type: 'header',
|
||||
valueType: 'string',
|
||||
isExpanded,
|
||||
isError: true,
|
||||
collapsedSummary: null,
|
||||
displayText: '',
|
||||
matchIndices: [],
|
||||
})
|
||||
|
||||
if (isExpanded) {
|
||||
rows.push({
|
||||
path: 'root.error.value',
|
||||
key: '',
|
||||
value: errorText,
|
||||
depth: 1,
|
||||
type: 'value',
|
||||
valueType: 'string',
|
||||
isExpanded: false,
|
||||
isError: true,
|
||||
collapsedSummary: null,
|
||||
displayText: errorText,
|
||||
matchIndices: pathToMatchIndices.get('root.error') ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function processNode(key: string, value: unknown, path: string, depth: number): void {
|
||||
const valueType = getTypeLabel(value)
|
||||
const isPrimitiveValue = isPrimitive(value)
|
||||
const isEmptyValue = !isPrimitiveValue && isEmpty(value)
|
||||
const isExpanded = expandedPaths.has(path)
|
||||
const collapsedSummary = isPrimitiveValue ? null : getCollapsedSummary(value)
|
||||
|
||||
rows.push({
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
depth,
|
||||
type: 'header',
|
||||
valueType,
|
||||
isExpanded,
|
||||
isError: false,
|
||||
collapsedSummary,
|
||||
displayText: '',
|
||||
matchIndices: [],
|
||||
})
|
||||
|
||||
if (isExpanded) {
|
||||
if (isPrimitiveValue) {
|
||||
rows.push({
|
||||
path: `${path}.value`,
|
||||
key: '',
|
||||
value,
|
||||
depth: depth + 1,
|
||||
type: 'value',
|
||||
valueType,
|
||||
isExpanded: false,
|
||||
isError: false,
|
||||
collapsedSummary: null,
|
||||
displayText: formatPrimitive(value),
|
||||
matchIndices: pathToMatchIndices.get(path) ?? [],
|
||||
})
|
||||
} else if (isEmptyValue) {
|
||||
rows.push({
|
||||
path: `${path}.empty`,
|
||||
key: '',
|
||||
value,
|
||||
depth: depth + 1,
|
||||
type: 'empty',
|
||||
valueType,
|
||||
isExpanded: false,
|
||||
isError: false,
|
||||
collapsedSummary: null,
|
||||
displayText: Array.isArray(value) ? '[]' : '{}',
|
||||
matchIndices: [],
|
||||
})
|
||||
} else {
|
||||
for (const entry of buildEntries(value, path)) {
|
||||
processNode(entry.key, entry.value, entry.path, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPrimitive(data)) {
|
||||
processNode('value', data, 'root.value', 0)
|
||||
} else if (data && typeof data === 'object') {
|
||||
for (const entry of buildEntries(data, 'root')) {
|
||||
processNode(entry.key, entry.value, entry.path, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts total visible rows for determining virtualization threshold.
|
||||
*/
|
||||
function countVisibleRows(data: unknown, expandedPaths: Set<string>, isError: boolean): number {
|
||||
if (isError) return expandedPaths.has('root.error') ? 2 : 1
|
||||
|
||||
let count = 0
|
||||
|
||||
function countNode(value: unknown, path: string): void {
|
||||
count++
|
||||
if (!expandedPaths.has(path)) return
|
||||
|
||||
if (isPrimitive(value) || isEmpty(value)) {
|
||||
count++
|
||||
} else {
|
||||
for (const entry of buildEntries(value, path)) {
|
||||
countNode(entry.value, entry.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPrimitive(data)) {
|
||||
countNode(data, 'root.value')
|
||||
} else if (data && typeof data === 'object') {
|
||||
for (const entry of buildEntries(data, 'root')) {
|
||||
countNode(entry.value, entry.path)
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
interface VirtualizedRowProps {
|
||||
rows: FlatRow[]
|
||||
onToggle: (path: string) => void
|
||||
wrapText: boolean
|
||||
searchQuery: string
|
||||
currentMatchIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtualized row component for large data sets.
|
||||
*/
|
||||
function VirtualizedRow({ index, style, ...props }: RowComponentProps<VirtualizedRowProps>) {
|
||||
const { rows, onToggle, wrapText, searchQuery, currentMatchIndex } = props
|
||||
const row = rows[index]
|
||||
const paddingLeft = CONFIG.BASE_PADDING + row.depth * CONFIG.INDENT_PER_LEVEL
|
||||
|
||||
if (row.type === 'header') {
|
||||
const badgeVariant = row.isError ? 'red' : BADGE_VARIANTS[row.valueType]
|
||||
|
||||
return (
|
||||
<div style={{ ...style, paddingLeft }} data-row-index={index}>
|
||||
<div
|
||||
className={STYLES.row}
|
||||
onClick={() => onToggle(row.path)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle(row.path)
|
||||
}
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-expanded={row.isExpanded}
|
||||
>
|
||||
<span className={cn(STYLES.keyName, row.isError && 'text-[var(--text-error)]')}>
|
||||
{row.key}
|
||||
</span>
|
||||
<Badge variant={badgeVariant} className={STYLES.badge}>
|
||||
{row.valueType}
|
||||
</Badge>
|
||||
{!row.isExpanded && row.collapsedSummary && (
|
||||
<span className={STYLES.summary}>{row.collapsedSummary}</span>
|
||||
)}
|
||||
<ChevronDown className={cn(STYLES.chevron, !row.isExpanded && '-rotate-90')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (row.type === 'empty') {
|
||||
return (
|
||||
<div style={{ ...style, paddingLeft }} data-row-index={index}>
|
||||
<div className={STYLES.emptyValue}>{row.displayText}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...style, paddingLeft }} data-row-index={index}>
|
||||
<div
|
||||
className={cn(
|
||||
STYLES.value,
|
||||
row.isError && 'text-[var(--text-error)]',
|
||||
wrapText ? '[word-break:break-word]' : 'whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
{renderHighlightedSegments(
|
||||
row.displayText,
|
||||
searchQuery,
|
||||
row.matchIndices,
|
||||
currentMatchIndex,
|
||||
row.path
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface StructuredOutputProps {
|
||||
data: unknown
|
||||
wrapText?: boolean
|
||||
isError?: boolean
|
||||
isRunning?: boolean
|
||||
className?: string
|
||||
searchQuery?: string
|
||||
currentMatchIndex?: number
|
||||
onMatchCountChange?: (count: number) => void
|
||||
contentRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders structured data as nested collapsible blocks.
|
||||
* Uses virtualization for large data sets (>200 visible rows) while
|
||||
* preserving exact original styling for smaller data sets.
|
||||
*/
|
||||
export const StructuredOutput = memo(function StructuredOutput({
|
||||
data,
|
||||
wrapText = true,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
className,
|
||||
searchQuery,
|
||||
currentMatchIndex = 0,
|
||||
onMatchCountChange,
|
||||
contentRef,
|
||||
}: StructuredOutputProps) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() =>
|
||||
computeInitialPaths(data, isError)
|
||||
)
|
||||
const prevDataRef = useRef(data)
|
||||
const prevIsErrorRef = useRef(isError)
|
||||
const internalRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useListRef(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
const setContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
;(internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||
if (contentRef) {
|
||||
;(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||
}
|
||||
},
|
||||
[contentRef]
|
||||
)
|
||||
|
||||
// Measure container height
|
||||
useEffect(() => {
|
||||
const container = internalRef.current?.parentElement
|
||||
if (!container) return
|
||||
|
||||
const updateHeight = () => setContainerHeight(container.clientHeight)
|
||||
updateHeight()
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight)
|
||||
resizeObserver.observe(container)
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
// Reset expanded paths when data changes
|
||||
useEffect(() => {
|
||||
if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) {
|
||||
prevDataRef.current = data
|
||||
prevIsErrorRef.current = isError
|
||||
setExpandedPaths(computeInitialPaths(data, isError))
|
||||
}
|
||||
}, [data, isError])
|
||||
|
||||
const allMatchPaths = useMemo(() => {
|
||||
if (!searchQuery) return []
|
||||
if (isError) {
|
||||
const errorText = extractErrorMessage(data)
|
||||
const count = findTextMatches(errorText, searchQuery).length
|
||||
return Array(count).fill('root.error') as string[]
|
||||
}
|
||||
return collectAllMatchPaths(data, searchQuery, 'root')
|
||||
}, [data, searchQuery, isError])
|
||||
|
||||
useEffect(() => {
|
||||
onMatchCountChange?.(allMatchPaths.length)
|
||||
}, [allMatchPaths.length, onMatchCountChange])
|
||||
|
||||
const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths])
|
||||
|
||||
// Auto-expand to current match
|
||||
useEffect(() => {
|
||||
if (
|
||||
allMatchPaths.length === 0 ||
|
||||
currentMatchIndex < 0 ||
|
||||
currentMatchIndex >= allMatchPaths.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentPath = allMatchPaths[currentMatchIndex]
|
||||
const pathsToExpand = [currentPath, ...getAncestorPaths(currentPath)]
|
||||
|
||||
setExpandedPaths((prev) => {
|
||||
if (pathsToExpand.every((p) => prev.has(p))) return prev
|
||||
const next = new Set(prev)
|
||||
pathsToExpand.forEach((p) => next.add(p))
|
||||
return next
|
||||
})
|
||||
}, [currentMatchIndex, allMatchPaths])
|
||||
|
||||
const handleToggle = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const rootEntries = useMemo<NodeEntry[]>(() => {
|
||||
if (isPrimitive(data)) return [{ key: 'value', value: data, path: 'root.value' }]
|
||||
return buildEntries(data, 'root')
|
||||
}, [data])
|
||||
|
||||
const searchContextValue = useMemo<SearchContextValue | null>(() => {
|
||||
if (!searchQuery) return null
|
||||
return { query: searchQuery, pathToMatchIndices }
|
||||
}, [searchQuery, pathToMatchIndices])
|
||||
|
||||
const visibleRowCount = useMemo(
|
||||
() => countVisibleRows(data, expandedPaths, isError),
|
||||
[data, expandedPaths, isError]
|
||||
)
|
||||
const useVirtualization = visibleRowCount > CONFIG.VIRTUALIZATION_THRESHOLD
|
||||
|
||||
const flatRows = useMemo(() => {
|
||||
if (!useVirtualization) return []
|
||||
return flattenTree(data, expandedPaths, pathToMatchIndices, isError)
|
||||
}, [data, expandedPaths, pathToMatchIndices, isError, useVirtualization])
|
||||
|
||||
// Scroll to match (virtualized)
|
||||
useEffect(() => {
|
||||
if (!useVirtualization || allMatchPaths.length === 0 || !listRef.current) return
|
||||
|
||||
const currentPath = allMatchPaths[currentMatchIndex]
|
||||
const targetPath = currentPath.endsWith('.value') ? currentPath : `${currentPath}.value`
|
||||
const rowIndex = flatRows.findIndex((r) => r.path === targetPath || r.path === currentPath)
|
||||
|
||||
if (rowIndex !== -1) {
|
||||
listRef.current.scrollToRow({ index: rowIndex, align: 'center' })
|
||||
}
|
||||
}, [currentMatchIndex, allMatchPaths, flatRows, listRef, useVirtualization])
|
||||
|
||||
// Scroll to match (non-virtualized)
|
||||
useEffect(() => {
|
||||
if (useVirtualization || allMatchPaths.length === 0) return
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const match = internalRef.current?.querySelector(
|
||||
`[data-match-index="${currentMatchIndex}"]`
|
||||
) as HTMLElement | null
|
||||
match?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [currentMatchIndex, allMatchPaths.length, expandedPaths, useVirtualization])
|
||||
|
||||
const containerClass = cn('flex flex-col pl-[20px]', wrapText && 'overflow-x-hidden', className)
|
||||
const virtualizedContainerClass = cn('relative', wrapText && 'overflow-x-hidden', className)
|
||||
const listClass = wrapText ? 'overflow-x-hidden' : 'overflow-x-auto'
|
||||
|
||||
// Running state
|
||||
if (isRunning && data === undefined) {
|
||||
return (
|
||||
<div ref={setContainerRef} className={containerClass}>
|
||||
<div className={STYLES.row}>
|
||||
<span className={STYLES.keyName}>running</span>
|
||||
<Badge variant='green' className={STYLES.badge}>
|
||||
Running
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (rootEntries.length === 0 && !isError) {
|
||||
return (
|
||||
<div ref={setContainerRef} className={containerClass}>
|
||||
<span className={STYLES.emptyValue}>null</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Virtualized rendering
|
||||
if (useVirtualization) {
|
||||
return (
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
className={virtualizedContainerClass}
|
||||
style={{ height: containerHeight }}
|
||||
>
|
||||
<List
|
||||
listRef={listRef}
|
||||
defaultHeight={containerHeight}
|
||||
rowCount={flatRows.length}
|
||||
rowHeight={CONFIG.ROW_HEIGHT}
|
||||
rowComponent={VirtualizedRow}
|
||||
rowProps={{
|
||||
rows: flatRows,
|
||||
onToggle: handleToggle,
|
||||
wrapText,
|
||||
searchQuery: searchQuery ?? '',
|
||||
currentMatchIndex,
|
||||
}}
|
||||
overscanCount={CONFIG.OVERSCAN_COUNT}
|
||||
className={listClass}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Non-virtualized rendering (preserves exact original styling)
|
||||
if (isError) {
|
||||
return (
|
||||
<SearchContext.Provider value={searchContextValue}>
|
||||
<div ref={setContainerRef} className={containerClass}>
|
||||
<StructuredNode
|
||||
name='error'
|
||||
value={extractErrorMessage(data)}
|
||||
path='root.error'
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={handleToggle}
|
||||
wrapText={wrapText}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
isError
|
||||
/>
|
||||
</div>
|
||||
</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={searchContextValue}>
|
||||
<div ref={setContainerRef} className={containerClass}>
|
||||
{rootEntries.map((entry) => (
|
||||
<StructuredNode
|
||||
key={entry.path}
|
||||
name={entry.key}
|
||||
value={entry.value}
|
||||
path={entry.path}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={handleToggle}
|
||||
wrapText={wrapText}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SearchContext.Provider>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export { OutputContextMenu, type OutputContextMenuProps } from './components/output-context-menu'
|
||||
export { StructuredOutput, type StructuredOutputProps } from './components/structured-output'
|
||||
export type { OutputPanelProps } from './output-panel'
|
||||
export { OutputPanel } from './output-panel'
|
||||
@@ -0,0 +1,643 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
Check,
|
||||
Clipboard,
|
||||
Database,
|
||||
MoreHorizontal,
|
||||
Palette,
|
||||
Pause,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Button,
|
||||
Code,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover'
|
||||
import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu'
|
||||
import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output'
|
||||
import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button'
|
||||
import type {
|
||||
BlockInfo,
|
||||
TerminalFilters,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
|
||||
interface OutputCodeContentProps {
|
||||
code: string
|
||||
language: 'javascript' | 'json'
|
||||
wrapText: boolean
|
||||
searchQuery: string | undefined
|
||||
currentMatchIndex: number
|
||||
onMatchCountChange: (count: number) => void
|
||||
contentRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const OutputCodeContent = React.memo(function OutputCodeContent({
|
||||
code,
|
||||
language,
|
||||
wrapText,
|
||||
searchQuery,
|
||||
currentMatchIndex,
|
||||
onMatchCountChange,
|
||||
contentRef,
|
||||
}: OutputCodeContentProps) {
|
||||
return (
|
||||
<Code.Viewer
|
||||
code={code}
|
||||
showGutter
|
||||
language={language}
|
||||
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
|
||||
paddingLeft={8}
|
||||
gutterStyle={{ backgroundColor: 'transparent' }}
|
||||
wrapText={wrapText}
|
||||
searchQuery={searchQuery}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={onMatchCountChange}
|
||||
contentRef={contentRef}
|
||||
virtualized
|
||||
showCollapseColumn={language === 'json'}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Props for the OutputPanel component
|
||||
* Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth)
|
||||
* are accessed directly from useTerminalStore to reduce prop drilling.
|
||||
*/
|
||||
export interface OutputPanelProps {
|
||||
selectedEntry: ConsoleEntry
|
||||
handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void
|
||||
handleHeaderClick: () => void
|
||||
isExpanded: boolean
|
||||
expandToLastHeight: () => void
|
||||
showInput: boolean
|
||||
setShowInput: (show: boolean) => void
|
||||
hasInputData: boolean
|
||||
isPlaygroundEnabled: boolean
|
||||
shouldShowTrainingButton: boolean
|
||||
isTraining: boolean
|
||||
handleTrainingClick: (e: React.MouseEvent) => void
|
||||
showCopySuccess: boolean
|
||||
handleCopy: () => void
|
||||
filteredEntries: ConsoleEntry[]
|
||||
handleExportConsole: (e: React.MouseEvent) => void
|
||||
hasActiveFilters: boolean
|
||||
handleClearConsole: (e: React.MouseEvent) => void
|
||||
shouldShowCodeDisplay: boolean
|
||||
outputDataStringified: string
|
||||
outputData: unknown
|
||||
handleClearConsoleFromMenu: () => void
|
||||
filters: TerminalFilters
|
||||
toggleBlock: (blockId: string) => void
|
||||
toggleStatus: (status: 'error' | 'info') => void
|
||||
uniqueBlocks: BlockInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Output panel component that manages its own search state.
|
||||
* Accesses store-backed settings directly to reduce prop drilling.
|
||||
*/
|
||||
export const OutputPanel = React.memo(function OutputPanel({
|
||||
selectedEntry,
|
||||
handleOutputPanelResizeMouseDown,
|
||||
handleHeaderClick,
|
||||
isExpanded,
|
||||
expandToLastHeight,
|
||||
showInput,
|
||||
setShowInput,
|
||||
hasInputData,
|
||||
isPlaygroundEnabled,
|
||||
shouldShowTrainingButton,
|
||||
isTraining,
|
||||
handleTrainingClick,
|
||||
showCopySuccess,
|
||||
handleCopy,
|
||||
filteredEntries,
|
||||
handleExportConsole,
|
||||
hasActiveFilters,
|
||||
handleClearConsole,
|
||||
shouldShowCodeDisplay,
|
||||
outputDataStringified,
|
||||
outputData,
|
||||
handleClearConsoleFromMenu,
|
||||
filters,
|
||||
toggleBlock,
|
||||
toggleStatus,
|
||||
uniqueBlocks,
|
||||
}: OutputPanelProps) {
|
||||
// Access store-backed settings directly to reduce prop drilling
|
||||
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
|
||||
const wrapText = useTerminalStore((state) => state.wrapText)
|
||||
const setWrapText = useTerminalStore((state) => state.setWrapText)
|
||||
const openOnRun = useTerminalStore((state) => state.openOnRun)
|
||||
const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun)
|
||||
const structuredView = useTerminalStore((state) => state.structuredView)
|
||||
const setStructuredView = useTerminalStore((state) => state.setStructuredView)
|
||||
|
||||
const outputContentRef = useRef<HTMLDivElement>(null)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
|
||||
const {
|
||||
isSearchActive: isOutputSearchActive,
|
||||
searchQuery: outputSearchQuery,
|
||||
setSearchQuery: setOutputSearchQuery,
|
||||
matchCount,
|
||||
currentMatchIndex,
|
||||
activateSearch: activateOutputSearch,
|
||||
closeSearch: closeOutputSearch,
|
||||
goToNextMatch,
|
||||
goToPreviousMatch,
|
||||
handleMatchCountChange,
|
||||
searchInputRef: outputSearchInputRef,
|
||||
} = useCodeViewerFeatures({
|
||||
contentRef: outputContentRef,
|
||||
externalWrapText: wrapText,
|
||||
onWrapTextChange: setWrapText,
|
||||
})
|
||||
|
||||
// Context menu state for output panel
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
const [storedSelectionText, setStoredSelectionText] = useState('')
|
||||
const {
|
||||
isOpen: isOutputMenuOpen,
|
||||
position: outputMenuPosition,
|
||||
menuRef: outputMenuRef,
|
||||
handleContextMenu: handleOutputContextMenu,
|
||||
closeMenu: closeOutputMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const handleOutputPanelContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const selection = window.getSelection()
|
||||
const selectionText = selection?.toString() || ''
|
||||
setStoredSelectionText(selectionText)
|
||||
setHasSelection(selectionText.length > 0)
|
||||
handleOutputContextMenu(e)
|
||||
},
|
||||
[handleOutputContextMenu]
|
||||
)
|
||||
|
||||
const handleCopySelection = useCallback(() => {
|
||||
if (storedSelectionText) {
|
||||
navigator.clipboard.writeText(storedSelectionText)
|
||||
}
|
||||
}, [storedSelectionText])
|
||||
|
||||
// Memoized callbacks to avoid inline arrow functions
|
||||
const handleToggleStructuredView = useCallback(() => {
|
||||
setStructuredView(!structuredView)
|
||||
}, [structuredView, setStructuredView])
|
||||
|
||||
const handleToggleWrapText = useCallback(() => {
|
||||
setWrapText(!wrapText)
|
||||
}, [wrapText, setWrapText])
|
||||
|
||||
const handleToggleOpenOnRun = useCallback(() => {
|
||||
setOpenOnRun(!openOnRun)
|
||||
}, [openOnRun, setOpenOnRun])
|
||||
|
||||
const handleCopyClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
handleCopy()
|
||||
},
|
||||
[handleCopy]
|
||||
)
|
||||
|
||||
const handleSearchClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
activateOutputSearch()
|
||||
},
|
||||
[activateOutputSearch]
|
||||
)
|
||||
|
||||
const handleCloseSearchClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
closeOutputSearch()
|
||||
},
|
||||
[closeOutputSearch]
|
||||
)
|
||||
|
||||
const handleOutputButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!isExpanded) {
|
||||
expandToLastHeight()
|
||||
}
|
||||
if (showInput) setShowInput(false)
|
||||
},
|
||||
[isExpanded, expandToLastHeight, showInput, setShowInput]
|
||||
)
|
||||
|
||||
const handleInputButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!isExpanded) {
|
||||
expandToLastHeight()
|
||||
}
|
||||
setShowInput(true)
|
||||
},
|
||||
[isExpanded, expandToLastHeight, setShowInput]
|
||||
)
|
||||
|
||||
const handleToggleButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
handleHeaderClick()
|
||||
},
|
||||
[handleHeaderClick]
|
||||
)
|
||||
|
||||
/**
|
||||
* Track text selection state for context menu.
|
||||
* Skip updates when the context menu is open to prevent the selection
|
||||
* state from changing mid-click (which would disable the copy button).
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
if (isOutputMenuOpen) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
setHasSelection(Boolean(selection && selection.toString().length > 0))
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', handleSelectionChange)
|
||||
return () => document.removeEventListener('selectionchange', handleSelectionChange)
|
||||
}, [isOutputMenuOpen])
|
||||
|
||||
// Memoize the search query for structured output to avoid re-renders
|
||||
const structuredSearchQuery = useMemo(
|
||||
() => (isOutputSearchActive ? outputSearchQuery : undefined),
|
||||
[isOutputSearchActive, outputSearchQuery]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--surface-1)]'
|
||||
style={{ width: `${outputPanelWidth}px` }}
|
||||
>
|
||||
{/* Horizontal Resize Handle */}
|
||||
<div
|
||||
className='-ml-[4px] absolute top-0 bottom-0 left-0 z-20 w-[8px] cursor-ew-resize'
|
||||
onMouseDown={handleOutputPanelResizeMouseDown}
|
||||
role='separator'
|
||||
aria-label='Resize output panel'
|
||||
aria-orientation='vertical'
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[10px]'
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={handleOutputButtonClick}
|
||||
aria-label='Show output'
|
||||
>
|
||||
Output
|
||||
</Button>
|
||||
{hasInputData && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={handleInputButtonClick}
|
||||
aria-label='Show input'
|
||||
>
|
||||
Input
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{/* Unified filter popover */}
|
||||
{filteredEntries.length > 0 && (
|
||||
<FilterPopover
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
toggleStatus={toggleStatus}
|
||||
toggleBlock={toggleBlock}
|
||||
uniqueBlocks={uniqueBlocks}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOutputSearchActive ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleCloseSearchClick}
|
||||
aria-label='Close search'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Close search</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleSearchClick}
|
||||
aria-label='Search in output'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<Search className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Search</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{isPlaygroundEnabled && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Link href='/playground'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
aria-label='Component Playground'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<Palette className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Component Playground</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{shouldShowTrainingButton && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleTrainingClick}
|
||||
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
|
||||
className={clsx(
|
||||
'!p-1.5 -m-1.5',
|
||||
isTraining && 'text-orange-600 dark:text-orange-400'
|
||||
)}
|
||||
>
|
||||
{isTraining ? (
|
||||
<Pause className='h-[12px] w-[12px]' />
|
||||
) : (
|
||||
<Database className='h-[12px] w-[12px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleCopyClick}
|
||||
aria-label='Copy output'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<Check className='h-[12px] w-[12px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[12px] w-[12px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{showCopySuccess ? 'Copied' : 'Copy output'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{filteredEntries.length > 0 && (
|
||||
<>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleExportConsole}
|
||||
aria-label='Download console CSV'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<ArrowDownToLine className='h-3 w-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Download CSV</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleClearConsole}
|
||||
aria-label='Clear console'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
)}
|
||||
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen} size='sm'>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='Terminal options'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<MoreHorizontal className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
collisionPadding={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ minWidth: '140px', maxWidth: '160px' }}
|
||||
className='gap-[2px]'
|
||||
>
|
||||
<PopoverItem
|
||||
active={structuredView}
|
||||
showCheck={structuredView}
|
||||
onClick={handleToggleStructuredView}
|
||||
>
|
||||
<span>Structured view</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem active={wrapText} showCheck={wrapText} onClick={handleToggleWrapText}>
|
||||
<span>Wrap text</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={openOnRun}
|
||||
showCheck={openOnRun}
|
||||
onClick={handleToggleOpenOnRun}
|
||||
>
|
||||
<span>Open on run</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ToggleButton isExpanded={isExpanded} onClick={handleToggleButtonClick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Overlay */}
|
||||
{isOutputSearchActive && (
|
||||
<div
|
||||
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--surface-1)] px-[6px] shadow-sm'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-toolbar-root
|
||||
data-search-active='true'
|
||||
>
|
||||
<Input
|
||||
ref={outputSearchInputRef}
|
||||
type='text'
|
||||
value={outputSearchQuery}
|
||||
onChange={(e) => setOutputSearchQuery(e.target.value)}
|
||||
placeholder='Search...'
|
||||
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-[58px] font-medium text-[11px]',
|
||||
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={goToPreviousMatch}
|
||||
aria-label='Previous match'
|
||||
className='!p-1.5 -m-1.5'
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={goToNextMatch}
|
||||
aria-label='Next match'
|
||||
className='!p-1.5 -m-1.5'
|
||||
disabled={matchCount === 0}
|
||||
>
|
||||
<ArrowDown className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={closeOutputSearch}
|
||||
aria-label='Close search'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}
|
||||
onContextMenu={handleOutputPanelContextMenu}
|
||||
>
|
||||
{shouldShowCodeDisplay ? (
|
||||
<OutputCodeContent
|
||||
code={selectedEntry.input.code}
|
||||
language={(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'}
|
||||
wrapText={wrapText}
|
||||
searchQuery={structuredSearchQuery}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
contentRef={outputContentRef}
|
||||
/>
|
||||
) : structuredView ? (
|
||||
<StructuredOutput
|
||||
data={outputData}
|
||||
wrapText={wrapText}
|
||||
isError={!showInput && Boolean(selectedEntry.error)}
|
||||
isRunning={!showInput && Boolean(selectedEntry.isRunning)}
|
||||
className='min-h-full'
|
||||
searchQuery={structuredSearchQuery}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
contentRef={outputContentRef}
|
||||
/>
|
||||
) : (
|
||||
<OutputCodeContent
|
||||
code={outputDataStringified}
|
||||
language='json'
|
||||
wrapText={wrapText}
|
||||
searchQuery={structuredSearchQuery}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
contentRef={outputContentRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Panel Context Menu */}
|
||||
<OutputContextMenu
|
||||
isOpen={isOutputMenuOpen}
|
||||
position={outputMenuPosition}
|
||||
menuRef={outputMenuRef}
|
||||
onClose={closeOutputMenu}
|
||||
onCopySelection={handleCopySelection}
|
||||
onCopyAll={handleCopy}
|
||||
onSearch={activateOutputSearch}
|
||||
structuredView={structuredView}
|
||||
onToggleStructuredView={handleToggleStructuredView}
|
||||
wrapText={wrapText}
|
||||
onToggleWrap={handleToggleWrapText}
|
||||
openOnRun={openOnRun}
|
||||
onToggleOpenOnRun={handleToggleOpenOnRun}
|
||||
onClearConsole={handleClearConsoleFromMenu}
|
||||
hasSelection={hasSelection}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
|
||||
/**
|
||||
* Running badge component - displays a consistent "Running" indicator
|
||||
*/
|
||||
export const RunningBadge = memo(function RunningBadge() {
|
||||
return (
|
||||
<Badge variant='green' className={BADGE_STYLE}>
|
||||
Running
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Props for StatusDisplay component
|
||||
*/
|
||||
export interface StatusDisplayProps {
|
||||
isRunning: boolean
|
||||
isCanceled: boolean
|
||||
formattedDuration: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable status display for terminal rows.
|
||||
* Shows Running badge, 'canceled' text, or formatted duration.
|
||||
*/
|
||||
export const StatusDisplay = memo(function StatusDisplay({
|
||||
isRunning,
|
||||
isCanceled,
|
||||
formattedDuration,
|
||||
}: StatusDisplayProps) {
|
||||
if (isRunning) {
|
||||
return <RunningBadge />
|
||||
}
|
||||
if (isCanceled) {
|
||||
return <>canceled</>
|
||||
}
|
||||
return <>{formattedDuration}</>
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { ToggleButton, type ToggleButtonProps } from './toggle-button'
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
export interface ToggleButtonProps {
|
||||
isExpanded: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle button component for terminal expand/collapse
|
||||
*/
|
||||
export const ToggleButton = memo(function ToggleButton({ isExpanded, onClick }: ToggleButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1.5 -m-1.5'
|
||||
onClick={onClick}
|
||||
aria-label='Toggle terminal'
|
||||
>
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
|
||||
!isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types'
|
||||
export { useOutputPanelResize } from './use-output-panel-resize'
|
||||
export { useTerminalFilters } from './use-terminal-filters'
|
||||
export { useTerminalResize } from './use-terminal-resize'
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { OUTPUT_PANEL_WIDTH } from '@/stores/constants'
|
||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
|
||||
const BLOCK_COLUMN_WIDTH = 240
|
||||
|
||||
export function useOutputPanelResize() {
|
||||
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
@@ -25,7 +23,7 @@ export function useOutputPanelResize() {
|
||||
|
||||
const newWidth = window.innerWidth - e.clientX - panelWidth
|
||||
const terminalWidth = window.innerWidth - sidebarWidth - panelWidth
|
||||
const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH
|
||||
const maxWidth = terminalWidth - TERMINAL_BLOCK_COLUMN_WIDTH
|
||||
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
|
||||
|
||||
setOutputPanelWidth(clampedWidth)
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type {
|
||||
SortConfig,
|
||||
TerminalFilters,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
|
||||
/**
|
||||
* Sort configuration
|
||||
*/
|
||||
export type SortField = 'timestamp'
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface SortConfig {
|
||||
field: SortField
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter configuration state
|
||||
*/
|
||||
export interface TerminalFilters {
|
||||
blockIds: Set<string>
|
||||
statuses: Set<'error' | 'info'>
|
||||
runIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage terminal filters and sorting.
|
||||
* Provides filter state, sort state, and filtering/sorting logic for console entries.
|
||||
@@ -31,7 +15,6 @@ export function useTerminalFilters() {
|
||||
const [filters, setFilters] = useState<TerminalFilters>({
|
||||
blockIds: new Set(),
|
||||
statuses: new Set(),
|
||||
runIds: new Set(),
|
||||
})
|
||||
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||
@@ -69,21 +52,6 @@ export function useTerminalFilters() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles a run ID filter
|
||||
*/
|
||||
const toggleRunId = useCallback((runId: string) => {
|
||||
setFilters((prev) => {
|
||||
const newRunIds = new Set(prev.runIds)
|
||||
if (newRunIds.has(runId)) {
|
||||
newRunIds.delete(runId)
|
||||
} else {
|
||||
newRunIds.add(runId)
|
||||
}
|
||||
return { ...prev, runIds: newRunIds }
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles sort direction between ascending and descending
|
||||
*/
|
||||
@@ -101,7 +69,6 @@ export function useTerminalFilters() {
|
||||
setFilters({
|
||||
blockIds: new Set(),
|
||||
statuses: new Set(),
|
||||
runIds: new Set(),
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -109,7 +76,7 @@ export function useTerminalFilters() {
|
||||
* Checks if any filters are active
|
||||
*/
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0
|
||||
return filters.blockIds.size > 0 || filters.statuses.size > 0
|
||||
}, [filters])
|
||||
|
||||
/**
|
||||
@@ -134,14 +101,6 @@ export function useTerminalFilters() {
|
||||
if (!hasStatus) return false
|
||||
}
|
||||
|
||||
// Run ID filter
|
||||
if (
|
||||
filters.runIds.size > 0 &&
|
||||
(!entry.executionId || !filters.runIds.has(entry.executionId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -164,7 +123,6 @@ export function useTerminalFilters() {
|
||||
sortConfig,
|
||||
toggleBlock,
|
||||
toggleStatus,
|
||||
toggleRunId,
|
||||
toggleSort,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Terminal filter configuration state
|
||||
*/
|
||||
export interface TerminalFilters {
|
||||
blockIds: Set<string>
|
||||
statuses: Set<'error' | 'info'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu position for positioning floating menus
|
||||
*/
|
||||
export interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort field options for terminal entries
|
||||
*/
|
||||
export type SortField = 'timestamp'
|
||||
|
||||
/**
|
||||
* Sort direction options
|
||||
*/
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Sort configuration for terminal entries
|
||||
*/
|
||||
export interface SortConfig {
|
||||
field: SortField
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Status type for console entries
|
||||
*/
|
||||
export type EntryStatus = 'error' | 'info'
|
||||
|
||||
/**
|
||||
* Block information for filters
|
||||
*/
|
||||
export interface BlockInfo {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Common row styling classes for terminal components
|
||||
*/
|
||||
export const ROW_STYLES = {
|
||||
base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]',
|
||||
selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]',
|
||||
hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
|
||||
nested:
|
||||
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
|
||||
iconButton: '!p-1.5 -m-1.5',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Common badge styling for status badges
|
||||
*/
|
||||
export const BADGE_STYLE = 'rounded-[4px] px-[4px] py-[0px] text-[11px]'
|
||||
@@ -0,0 +1,452 @@
|
||||
import type React from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
|
||||
/**
|
||||
* Subflow colors matching the subflow tool configs
|
||||
*/
|
||||
const SUBFLOW_COLORS = {
|
||||
loop: '#2FB3FF',
|
||||
parallel: '#FEE12B',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Retrieves the icon component for a given block type
|
||||
*/
|
||||
export function getBlockIcon(
|
||||
blockType: string
|
||||
): React.ComponentType<{ className?: string }> | null {
|
||||
const blockConfig = getBlock(blockType)
|
||||
|
||||
if (blockConfig?.icon) {
|
||||
return blockConfig.icon
|
||||
}
|
||||
|
||||
if (blockType === 'loop') {
|
||||
return RepeatIcon
|
||||
}
|
||||
|
||||
if (blockType === 'parallel') {
|
||||
return SplitIcon
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the background color for a block type
|
||||
*/
|
||||
export function getBlockColor(blockType: string): string {
|
||||
const blockConfig = getBlock(blockType)
|
||||
if (blockConfig?.bgColor) {
|
||||
return blockConfig.bgColor
|
||||
}
|
||||
// Use proper subflow colors matching the toolbar configs
|
||||
if (blockType === 'loop') {
|
||||
return SUBFLOW_COLORS.loop
|
||||
}
|
||||
if (blockType === 'parallel') {
|
||||
return SUBFLOW_COLORS.parallel
|
||||
}
|
||||
return '#6b7280'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to readable format
|
||||
*/
|
||||
export function formatDuration(ms?: number): string {
|
||||
if (ms === undefined || ms === null) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a keyboard event originated from a text-editable element
|
||||
*/
|
||||
export function isEventFromEditableElement(e: KeyboardEvent): boolean {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target) return false
|
||||
|
||||
const isEditable = (el: HTMLElement | null): boolean => {
|
||||
if (!el) return false
|
||||
if (el instanceof HTMLInputElement) return true
|
||||
if (el instanceof HTMLTextAreaElement) return true
|
||||
if ((el as HTMLElement).isContentEditable) return true
|
||||
const role = el.getAttribute('role')
|
||||
if (role === 'textbox' || role === 'combobox') return true
|
||||
return false
|
||||
}
|
||||
|
||||
let el: HTMLElement | null = target
|
||||
while (el) {
|
||||
if (isEditable(el)) return true
|
||||
el = el.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block type is a subflow (loop or parallel)
|
||||
*/
|
||||
export function isSubflowBlockType(blockType: string): boolean {
|
||||
const lower = blockType?.toLowerCase() || ''
|
||||
return lower === 'loop' || lower === 'parallel'
|
||||
}
|
||||
|
||||
/**
|
||||
* Node type for the tree structure
|
||||
*/
|
||||
export type EntryNodeType = 'block' | 'subflow' | 'iteration'
|
||||
|
||||
/**
|
||||
* Entry node for tree structure - represents a block, subflow, or iteration
|
||||
*/
|
||||
export interface EntryNode {
|
||||
/** The console entry (for blocks) or synthetic entry (for subflows/iterations) */
|
||||
entry: ConsoleEntry
|
||||
/** Child nodes */
|
||||
children: EntryNode[]
|
||||
/** Node type */
|
||||
nodeType: EntryNodeType
|
||||
/** Iteration info for iteration nodes */
|
||||
iterationInfo?: {
|
||||
current: number
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution group interface for grouping entries by execution
|
||||
*/
|
||||
export interface ExecutionGroup {
|
||||
executionId: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
startTimeMs: number
|
||||
endTimeMs: number
|
||||
duration: number
|
||||
status: 'success' | 'error'
|
||||
/** Flat list of entries (legacy, kept for filters) */
|
||||
entries: ConsoleEntry[]
|
||||
/** Tree structure of entry nodes for nested display */
|
||||
entryTree: EntryNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Iteration group for grouping blocks within the same iteration
|
||||
*/
|
||||
interface IterationGroup {
|
||||
iterationType: string
|
||||
iterationCurrent: number
|
||||
iterationTotal?: number
|
||||
blocks: ConsoleEntry[]
|
||||
startTimeMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree structure from flat entries.
|
||||
* Groups iteration entries by (iterationType, iterationCurrent), showing all blocks
|
||||
* that executed within each iteration.
|
||||
* Sorts by start time to ensure chronological order.
|
||||
*/
|
||||
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
|
||||
// Separate regular blocks from iteration entries
|
||||
const regularBlocks: ConsoleEntry[] = []
|
||||
const iterationEntries: ConsoleEntry[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.iterationType && entry.iterationCurrent !== undefined) {
|
||||
iterationEntries.push(entry)
|
||||
} else {
|
||||
regularBlocks.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Group iteration entries by (iterationType, iterationCurrent)
|
||||
const iterationGroupsMap = new Map<string, IterationGroup>()
|
||||
for (const entry of iterationEntries) {
|
||||
const key = `${entry.iterationType}-${entry.iterationCurrent}`
|
||||
let group = iterationGroupsMap.get(key)
|
||||
const entryStartMs = new Date(entry.startedAt || entry.timestamp).getTime()
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
iterationType: entry.iterationType!,
|
||||
iterationCurrent: entry.iterationCurrent!,
|
||||
iterationTotal: entry.iterationTotal,
|
||||
blocks: [],
|
||||
startTimeMs: entryStartMs,
|
||||
}
|
||||
iterationGroupsMap.set(key, group)
|
||||
} else {
|
||||
// Update start time to earliest
|
||||
if (entryStartMs < group.startTimeMs) {
|
||||
group.startTimeMs = entryStartMs
|
||||
}
|
||||
// Update total if available
|
||||
if (entry.iterationTotal !== undefined) {
|
||||
group.iterationTotal = entry.iterationTotal
|
||||
}
|
||||
}
|
||||
group.blocks.push(entry)
|
||||
}
|
||||
|
||||
// Sort blocks within each iteration by start time ascending (oldest first, top-down)
|
||||
for (const group of iterationGroupsMap.values()) {
|
||||
group.blocks.sort((a, b) => {
|
||||
const aStart = new Date(a.startedAt || a.timestamp).getTime()
|
||||
const bStart = new Date(b.startedAt || b.timestamp).getTime()
|
||||
return aStart - bStart
|
||||
})
|
||||
}
|
||||
|
||||
// Group iterations by iterationType to create subflow parents
|
||||
const subflowGroups = new Map<string, IterationGroup[]>()
|
||||
for (const group of iterationGroupsMap.values()) {
|
||||
const type = group.iterationType
|
||||
let groups = subflowGroups.get(type)
|
||||
if (!groups) {
|
||||
groups = []
|
||||
subflowGroups.set(type, groups)
|
||||
}
|
||||
groups.push(group)
|
||||
}
|
||||
|
||||
// Sort iterations within each subflow by iteration number
|
||||
for (const groups of subflowGroups.values()) {
|
||||
groups.sort((a, b) => a.iterationCurrent - b.iterationCurrent)
|
||||
}
|
||||
|
||||
// Build subflow nodes with iteration children
|
||||
const subflowNodes: EntryNode[] = []
|
||||
for (const [iterationType, iterationGroups] of subflowGroups.entries()) {
|
||||
// Calculate subflow timing from all its iterations
|
||||
const firstIteration = iterationGroups[0]
|
||||
const allBlocks = iterationGroups.flatMap((g) => g.blocks)
|
||||
const subflowStartMs = Math.min(
|
||||
...allBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
|
||||
)
|
||||
const subflowEndMs = Math.max(
|
||||
...allBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
|
||||
)
|
||||
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
|
||||
// Create synthetic subflow parent entry
|
||||
const syntheticSubflow: ConsoleEntry = {
|
||||
id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
|
||||
timestamp: new Date(subflowStartMs).toISOString(),
|
||||
workflowId: firstIteration.blocks[0]?.workflowId || '',
|
||||
blockId: `${iterationType}-container`,
|
||||
blockName: iterationType.charAt(0).toUpperCase() + iterationType.slice(1),
|
||||
blockType: iterationType,
|
||||
executionId: firstIteration.blocks[0]?.executionId,
|
||||
startedAt: new Date(subflowStartMs).toISOString(),
|
||||
endedAt: new Date(subflowEndMs).toISOString(),
|
||||
durationMs: totalDuration,
|
||||
success: !allBlocks.some((b) => b.error),
|
||||
}
|
||||
|
||||
// Build iteration child nodes
|
||||
const iterationNodes: EntryNode[] = iterationGroups.map((iterGroup) => {
|
||||
// Create synthetic iteration entry
|
||||
const iterBlocks = iterGroup.blocks
|
||||
const iterStartMs = Math.min(
|
||||
...iterBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
|
||||
)
|
||||
const iterEndMs = Math.max(
|
||||
...iterBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
|
||||
)
|
||||
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
|
||||
|
||||
const syntheticIteration: ConsoleEntry = {
|
||||
id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
|
||||
timestamp: new Date(iterStartMs).toISOString(),
|
||||
workflowId: iterBlocks[0]?.workflowId || '',
|
||||
blockId: `iteration-${iterGroup.iterationCurrent}`,
|
||||
blockName: `Iteration ${iterGroup.iterationCurrent}${iterGroup.iterationTotal !== undefined ? ` / ${iterGroup.iterationTotal}` : ''}`,
|
||||
blockType: iterationType,
|
||||
executionId: iterBlocks[0]?.executionId,
|
||||
startedAt: new Date(iterStartMs).toISOString(),
|
||||
endedAt: new Date(iterEndMs).toISOString(),
|
||||
durationMs: iterDuration,
|
||||
success: !iterBlocks.some((b) => b.error),
|
||||
iterationCurrent: iterGroup.iterationCurrent,
|
||||
iterationTotal: iterGroup.iterationTotal,
|
||||
iterationType: iterationType as 'loop' | 'parallel',
|
||||
}
|
||||
|
||||
// Block nodes within this iteration
|
||||
const blockNodes: EntryNode[] = iterBlocks.map((block) => ({
|
||||
entry: block,
|
||||
children: [],
|
||||
nodeType: 'block' as const,
|
||||
}))
|
||||
|
||||
return {
|
||||
entry: syntheticIteration,
|
||||
children: blockNodes,
|
||||
nodeType: 'iteration' as const,
|
||||
iterationInfo: {
|
||||
current: iterGroup.iterationCurrent,
|
||||
total: iterGroup.iterationTotal,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
subflowNodes.push({
|
||||
entry: syntheticSubflow,
|
||||
children: iterationNodes,
|
||||
nodeType: 'subflow' as const,
|
||||
})
|
||||
}
|
||||
|
||||
// Build nodes for regular blocks
|
||||
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
|
||||
entry,
|
||||
children: [],
|
||||
nodeType: 'block' as const,
|
||||
}))
|
||||
|
||||
// Combine all nodes and sort by start time ascending (oldest first, top-down)
|
||||
const allNodes = [...subflowNodes, ...regularNodes]
|
||||
allNodes.sort((a, b) => {
|
||||
const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
|
||||
const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
|
||||
return aStart - bStart
|
||||
})
|
||||
|
||||
return allNodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups console entries by execution ID and builds a tree structure.
|
||||
* Pre-computes timestamps for efficient sorting.
|
||||
*/
|
||||
export function groupEntriesByExecution(entries: ConsoleEntry[]): ExecutionGroup[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ meta: Omit<ExecutionGroup, 'entryTree'>; entries: ConsoleEntry[] }
|
||||
>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const execId = entry.executionId || entry.id
|
||||
|
||||
const entryStartTime = entry.startedAt || entry.timestamp
|
||||
const entryEndTime = entry.endedAt || entry.timestamp
|
||||
const entryStartMs = new Date(entryStartTime).getTime()
|
||||
const entryEndMs = new Date(entryEndTime).getTime()
|
||||
|
||||
let group = groups.get(execId)
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
meta: {
|
||||
executionId: execId,
|
||||
startTime: entryStartTime,
|
||||
endTime: entryEndTime,
|
||||
startTimeMs: entryStartMs,
|
||||
endTimeMs: entryEndMs,
|
||||
duration: 0,
|
||||
status: 'success',
|
||||
entries: [],
|
||||
},
|
||||
entries: [],
|
||||
}
|
||||
groups.set(execId, group)
|
||||
} else {
|
||||
// Update timing bounds
|
||||
if (entryStartMs < group.meta.startTimeMs) {
|
||||
group.meta.startTime = entryStartTime
|
||||
group.meta.startTimeMs = entryStartMs
|
||||
}
|
||||
if (entryEndMs > group.meta.endTimeMs) {
|
||||
group.meta.endTime = entryEndTime
|
||||
group.meta.endTimeMs = entryEndMs
|
||||
}
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if (entry.error) {
|
||||
group.meta.status = 'error'
|
||||
}
|
||||
|
||||
group.entries.push(entry)
|
||||
}
|
||||
|
||||
// Build tree structure for each group
|
||||
const result: ExecutionGroup[] = []
|
||||
for (const group of groups.values()) {
|
||||
group.meta.duration = group.meta.endTimeMs - group.meta.startTimeMs
|
||||
group.meta.entries = group.entries
|
||||
result.push({
|
||||
...group.meta,
|
||||
entryTree: buildEntryTree(group.entries),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by start time descending (newest first)
|
||||
result.sort((a, b) => b.startTimeMs - a.startTimeMs)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens entry tree into display order for keyboard navigation
|
||||
*/
|
||||
export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] {
|
||||
const result: ConsoleEntry[] = []
|
||||
for (const node of nodes) {
|
||||
result.push(node.entry)
|
||||
if (node.children.length > 0) {
|
||||
result.push(...flattenEntryTree(node.children))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Block entry with parent tracking for navigation
|
||||
*/
|
||||
export interface NavigableBlockEntry {
|
||||
entry: ConsoleEntry
|
||||
executionId: string
|
||||
/** IDs of parent nodes (subflows, iterations) that contain this block */
|
||||
parentNodeIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens entry tree to only include actual block entries (not subflows/iterations).
|
||||
* Also tracks parent node IDs for auto-expanding when navigating.
|
||||
*/
|
||||
export function flattenBlockEntriesOnly(
|
||||
nodes: EntryNode[],
|
||||
executionId: string,
|
||||
parentIds: string[] = []
|
||||
): NavigableBlockEntry[] {
|
||||
const result: NavigableBlockEntry[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === 'block') {
|
||||
result.push({
|
||||
entry: node.entry,
|
||||
executionId,
|
||||
parentNodeIds: parentIds,
|
||||
})
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
const newParentIds = node.nodeType !== 'block' ? [...parentIds, node.entry.id] : parentIds
|
||||
result.push(...flattenBlockEntriesOnly(node.children, executionId, newParentIds))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal height configuration constants
|
||||
*/
|
||||
export const TERMINAL_CONFIG = {
|
||||
NEAR_MIN_THRESHOLD: 40,
|
||||
BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH,
|
||||
HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]',
|
||||
} as const
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { readSSEStream } from '@/lib/core/utils/sse'
|
||||
import type { GenerationType } from '@/blocks/types'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
|
||||
@@ -184,52 +185,10 @@ export function useWand({
|
||||
throw new Error('Response body is null')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const lineData = line.substring(6)
|
||||
|
||||
if (lineData === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(lineData)
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
if (data.chunk) {
|
||||
accumulatedContent += data.chunk
|
||||
if (onStreamChunk) {
|
||||
onStreamChunk(data.chunk)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
break
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.debug('Failed to parse SSE line', { line, parseError })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
const accumulatedContent = await readSSEStream(response.body, {
|
||||
onChunk: onStreamChunk,
|
||||
signal: abortControllerRef.current?.signal,
|
||||
})
|
||||
|
||||
if (accumulatedContent) {
|
||||
onGeneratedContent(accumulatedContent)
|
||||
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
TriggerUtils,
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { coerceValue } from '@/executor/utils/start-block'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
import { useExecutionStream } from '@/hooks/use-execution-stream'
|
||||
import { WorkflowValidationError } from '@/serializer'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { type ConsoleEntry, useTerminalConsoleStore } from '@/stores/terminal'
|
||||
@@ -81,7 +84,8 @@ export function useWorkflowExecution() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||
const { toggleConsole, addConsole } = useTerminalConsoleStore()
|
||||
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } =
|
||||
useTerminalConsoleStore()
|
||||
const { getAllVariables } = useEnvironmentStore()
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||
const {
|
||||
@@ -98,11 +102,15 @@ export function useWorkflowExecution() {
|
||||
setActiveBlocks,
|
||||
setBlockRunStatus,
|
||||
setEdgeRunStatus,
|
||||
setLastExecutionSnapshot,
|
||||
getLastExecutionSnapshot,
|
||||
clearLastExecutionSnapshot,
|
||||
} = useExecutionStore()
|
||||
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
|
||||
const executionStream = useExecutionStream()
|
||||
const currentChatExecutionIdRef = useRef<string | null>(null)
|
||||
const isViewingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
/**
|
||||
* Validates debug state before performing debug operations
|
||||
@@ -668,7 +676,8 @@ export function useWorkflowExecution() {
|
||||
onStream?: (se: StreamingExecution) => Promise<void>,
|
||||
executionId?: string,
|
||||
onBlockComplete?: (blockId: string, output: any) => Promise<void>,
|
||||
overrideTriggerType?: 'chat' | 'manual' | 'api'
|
||||
overrideTriggerType?: 'chat' | 'manual' | 'api',
|
||||
stopAfterBlockId?: string
|
||||
): Promise<ExecutionResult | StreamingExecution> => {
|
||||
// Use diff workflow for execution when available, regardless of canvas view state
|
||||
const executionWorkflowState = null as {
|
||||
@@ -867,6 +876,8 @@ export function useWorkflowExecution() {
|
||||
if (activeWorkflowId) {
|
||||
logger.info('Using server-side executor')
|
||||
|
||||
const executionId = uuidv4()
|
||||
|
||||
let executionResult: ExecutionResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
@@ -876,6 +887,8 @@ export function useWorkflowExecution() {
|
||||
const activeBlocksSet = new Set<string>()
|
||||
const streamedContent = new Map<string, string>()
|
||||
const accumulatedBlockLogs: BlockLog[] = []
|
||||
const accumulatedBlockStates = new Map<string, BlockState>()
|
||||
const executedBlockIds = new Set<string>()
|
||||
|
||||
// Execute the workflow
|
||||
try {
|
||||
@@ -887,6 +900,7 @@ export function useWorkflowExecution() {
|
||||
triggerType: overrideTriggerType || 'manual',
|
||||
useDraftState: true,
|
||||
isClientSession: true,
|
||||
stopAfterBlockId,
|
||||
workflowStateOverride: executionWorkflowState
|
||||
? {
|
||||
blocks: executionWorkflowState.blocks,
|
||||
@@ -910,24 +924,49 @@ export function useWorkflowExecution() {
|
||||
incomingEdges.forEach((edge) => {
|
||||
setEdgeRunStatus(edge.id, 'success')
|
||||
})
|
||||
|
||||
// Add entry to terminal immediately with isRunning=true
|
||||
const startedAt = new Date().toISOString()
|
||||
addConsole({
|
||||
input: {},
|
||||
output: undefined,
|
||||
success: undefined,
|
||||
durationMs: undefined,
|
||||
startedAt,
|
||||
endedAt: undefined,
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: data.blockId,
|
||||
executionId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
isRunning: true,
|
||||
// Pass through iteration context for subflow grouping
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
})
|
||||
},
|
||||
|
||||
onBlockCompleted: (data) => {
|
||||
logger.info('onBlockCompleted received:', { data })
|
||||
|
||||
activeBlocksSet.delete(data.blockId)
|
||||
// Create a new Set to trigger React re-render
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
|
||||
// Track successful block execution in run path
|
||||
setBlockRunStatus(data.blockId, 'success')
|
||||
|
||||
// Edges already tracked in onBlockStarted, no need to track again
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
output: data.output,
|
||||
executed: true,
|
||||
executionTime: data.durationMs,
|
||||
})
|
||||
|
||||
const isContainerBlock = data.blockType === 'loop' || data.blockType === 'parallel'
|
||||
if (isContainerBlock) return
|
||||
|
||||
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
|
||||
const endedAt = new Date().toISOString()
|
||||
|
||||
// Accumulate block log for the execution result
|
||||
accumulatedBlockLogs.push({
|
||||
blockId: data.blockId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
@@ -940,24 +979,23 @@ export function useWorkflowExecution() {
|
||||
endedAt,
|
||||
})
|
||||
|
||||
// Add to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionId || uuidv4(),
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
// Pass through iteration context for console pills
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
})
|
||||
// Update existing console entry (created in onBlockStarted) with completion data
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
input: data.input || {},
|
||||
replaceOutput: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
endedAt,
|
||||
isRunning: false,
|
||||
// Pass through iteration context for subflow grouping
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
},
|
||||
executionId
|
||||
)
|
||||
|
||||
// Call onBlockComplete callback if provided
|
||||
if (onBlockComplete) {
|
||||
@@ -992,25 +1030,24 @@ export function useWorkflowExecution() {
|
||||
endedAt,
|
||||
})
|
||||
|
||||
// Add error to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: data.blockId,
|
||||
executionId: executionId || uuidv4(),
|
||||
blockName: data.blockName,
|
||||
blockType: data.blockType,
|
||||
// Pass through iteration context for console pills
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
})
|
||||
// Update existing console entry (created in onBlockStarted) with error data
|
||||
updateConsole(
|
||||
data.blockId,
|
||||
{
|
||||
input: data.input || {},
|
||||
replaceOutput: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
endedAt,
|
||||
isRunning: false,
|
||||
// Pass through iteration context for subflow grouping
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
},
|
||||
executionId
|
||||
)
|
||||
},
|
||||
|
||||
onStreamChunk: (data) => {
|
||||
@@ -1056,6 +1093,53 @@ export function useWorkflowExecution() {
|
||||
},
|
||||
logs: accumulatedBlockLogs,
|
||||
}
|
||||
|
||||
// Add trigger block to executed blocks so downstream blocks can use run-from-block
|
||||
if (data.success && startBlockId) {
|
||||
executedBlockIds.add(startBlockId)
|
||||
}
|
||||
|
||||
if (data.success && activeWorkflowId) {
|
||||
if (stopAfterBlockId) {
|
||||
const existingSnapshot = getLastExecutionSnapshot(activeWorkflowId)
|
||||
const mergedBlockStates = {
|
||||
...(existingSnapshot?.blockStates || {}),
|
||||
...Object.fromEntries(accumulatedBlockStates),
|
||||
}
|
||||
const mergedExecutedBlocks = new Set([
|
||||
...(existingSnapshot?.executedBlocks || []),
|
||||
...executedBlockIds,
|
||||
])
|
||||
const snapshot: SerializableExecutionState = {
|
||||
blockStates: mergedBlockStates,
|
||||
executedBlocks: Array.from(mergedExecutedBlocks),
|
||||
blockLogs: [...(existingSnapshot?.blockLogs || []), ...accumulatedBlockLogs],
|
||||
decisions: existingSnapshot?.decisions || { router: {}, condition: {} },
|
||||
completedLoops: existingSnapshot?.completedLoops || [],
|
||||
activeExecutionPath: Array.from(mergedExecutedBlocks),
|
||||
}
|
||||
setLastExecutionSnapshot(activeWorkflowId, snapshot)
|
||||
logger.info('Merged execution snapshot after run-until-block', {
|
||||
workflowId: activeWorkflowId,
|
||||
newBlocksExecuted: executedBlockIds.size,
|
||||
totalExecutedBlocks: mergedExecutedBlocks.size,
|
||||
})
|
||||
} else {
|
||||
const snapshot: SerializableExecutionState = {
|
||||
blockStates: Object.fromEntries(accumulatedBlockStates),
|
||||
executedBlocks: Array.from(executedBlockIds),
|
||||
blockLogs: accumulatedBlockLogs,
|
||||
decisions: { router: {}, condition: {} },
|
||||
completedLoops: [],
|
||||
activeExecutionPath: Array.from(executedBlockIds),
|
||||
}
|
||||
setLastExecutionSnapshot(activeWorkflowId, snapshot)
|
||||
logger.info('Stored execution snapshot for run-from-block', {
|
||||
workflowId: activeWorkflowId,
|
||||
executedBlocksCount: executedBlockIds.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onExecutionError: (data) => {
|
||||
@@ -1089,7 +1173,7 @@ export function useWorkflowExecution() {
|
||||
endedAt: new Date().toISOString(),
|
||||
workflowId: activeWorkflowId,
|
||||
blockId: 'validation',
|
||||
executionId: executionId || uuidv4(),
|
||||
executionId,
|
||||
blockName: 'Workflow Validation',
|
||||
blockType: 'validation',
|
||||
})
|
||||
@@ -1358,6 +1442,11 @@ export function useWorkflowExecution() {
|
||||
// Mark current chat execution as superseded so its cleanup won't affect new executions
|
||||
currentChatExecutionIdRef.current = null
|
||||
|
||||
// Mark all running entries as canceled in the terminal
|
||||
if (activeWorkflowId) {
|
||||
cancelRunningEntries(activeWorkflowId)
|
||||
}
|
||||
|
||||
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
@@ -1374,8 +1463,334 @@ export function useWorkflowExecution() {
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
setActiveBlocks,
|
||||
activeWorkflowId,
|
||||
cancelRunningEntries,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles running workflow from a specific block using cached outputs
|
||||
*/
|
||||
const handleRunFromBlock = useCallback(
|
||||
async (blockId: string, workflowId: string) => {
|
||||
const snapshot = getLastExecutionSnapshot(workflowId)
|
||||
const workflowEdges = useWorkflowStore.getState().edges
|
||||
const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId)
|
||||
const isTriggerBlock = incomingEdges.length === 0
|
||||
|
||||
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
|
||||
const isSourceSatisfied = (sourceId: string) => {
|
||||
if (snapshot?.executedBlocks.includes(sourceId)) return true
|
||||
// Check if source is a trigger (has no incoming edges itself)
|
||||
const sourceIncomingEdges = workflowEdges.filter((edge) => edge.target === sourceId)
|
||||
return sourceIncomingEdges.length === 0
|
||||
}
|
||||
|
||||
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
|
||||
if (!snapshot && !isTriggerBlock) {
|
||||
logger.error('No execution snapshot available for run-from-block', { workflowId, blockId })
|
||||
return
|
||||
}
|
||||
|
||||
const dependenciesSatisfied =
|
||||
isTriggerBlock || incomingEdges.every((edge) => isSourceSatisfied(edge.source))
|
||||
|
||||
if (!dependenciesSatisfied) {
|
||||
logger.error('Upstream dependencies not satisfied for run-from-block', {
|
||||
workflowId,
|
||||
blockId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// For trigger blocks, always use empty snapshot to prevent stale data from different
|
||||
// execution paths from being resolved. For non-trigger blocks, use the existing snapshot.
|
||||
const emptySnapshot: SerializableExecutionState = {
|
||||
blockStates: {},
|
||||
executedBlocks: [],
|
||||
blockLogs: [],
|
||||
decisions: { router: {}, condition: {} },
|
||||
completedLoops: [],
|
||||
activeExecutionPath: [],
|
||||
}
|
||||
const effectiveSnapshot: SerializableExecutionState = isTriggerBlock
|
||||
? emptySnapshot
|
||||
: snapshot || emptySnapshot
|
||||
|
||||
// Extract mock payload for trigger blocks
|
||||
let workflowInput: any
|
||||
if (isTriggerBlock) {
|
||||
const workflowBlocks = useWorkflowStore.getState().blocks
|
||||
const mergedStates = mergeSubblockState(workflowBlocks, workflowId)
|
||||
const candidates = resolveStartCandidates(mergedStates, { execution: 'manual' })
|
||||
const candidate = candidates.find((c) => c.blockId === blockId)
|
||||
|
||||
if (candidate) {
|
||||
if (triggerNeedsMockPayload(candidate)) {
|
||||
workflowInput = extractTriggerMockPayload(candidate)
|
||||
} else if (
|
||||
candidate.path === StartBlockPath.SPLIT_API ||
|
||||
candidate.path === StartBlockPath.SPLIT_INPUT ||
|
||||
candidate.path === StartBlockPath.UNIFIED
|
||||
) {
|
||||
const inputFormatValue = candidate.block.subBlocks?.inputFormat?.value
|
||||
if (Array.isArray(inputFormatValue)) {
|
||||
const testInput: Record<string, any> = {}
|
||||
inputFormatValue.forEach((field: any) => {
|
||||
if (field && typeof field === 'object' && field.name && field.value !== undefined) {
|
||||
testInput[field.name] = coerceValue(field.type, field.value)
|
||||
}
|
||||
})
|
||||
if (Object.keys(testInput).length > 0) {
|
||||
workflowInput = testInput
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: block is trigger by position but not classified as start candidate
|
||||
const block = mergedStates[blockId]
|
||||
if (block) {
|
||||
const blockConfig = getBlock(block.type)
|
||||
const hasTriggers = blockConfig?.triggers?.available?.length
|
||||
|
||||
if (hasTriggers || block.triggerMode) {
|
||||
workflowInput = extractTriggerMockPayload({
|
||||
blockId,
|
||||
block,
|
||||
path: StartBlockPath.EXTERNAL_TRIGGER,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsExecuting(true)
|
||||
const executionId = uuidv4()
|
||||
const accumulatedBlockLogs: BlockLog[] = []
|
||||
const accumulatedBlockStates = new Map<string, BlockState>()
|
||||
const executedBlockIds = new Set<string>()
|
||||
const activeBlocksSet = new Set<string>()
|
||||
|
||||
try {
|
||||
await executionStream.executeFromBlock({
|
||||
workflowId,
|
||||
startBlockId: blockId,
|
||||
sourceSnapshot: effectiveSnapshot,
|
||||
input: workflowInput,
|
||||
callbacks: {
|
||||
onBlockStarted: (data) => {
|
||||
activeBlocksSet.add(data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
|
||||
const incomingEdges = workflowEdges.filter((edge) => edge.target === data.blockId)
|
||||
incomingEdges.forEach((edge) => {
|
||||
setEdgeRunStatus(edge.id, 'success')
|
||||
})
|
||||
},
|
||||
|
||||
onBlockCompleted: (data) => {
|
||||
activeBlocksSet.delete(data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(data.blockId, 'success')
|
||||
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
output: data.output,
|
||||
executed: true,
|
||||
executionTime: data.durationMs,
|
||||
})
|
||||
|
||||
const isContainerBlock = data.blockType === 'loop' || data.blockType === 'parallel'
|
||||
if (isContainerBlock) return
|
||||
|
||||
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
|
||||
const endedAt = new Date().toISOString()
|
||||
|
||||
accumulatedBlockLogs.push({
|
||||
blockId: data.blockId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
input: data.input || {},
|
||||
output: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output: data.output,
|
||||
success: true,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
})
|
||||
},
|
||||
|
||||
onBlockError: (data) => {
|
||||
activeBlocksSet.delete(data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(data.blockId, 'error')
|
||||
|
||||
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
|
||||
const endedAt = new Date().toISOString()
|
||||
|
||||
accumulatedBlockLogs.push({
|
||||
blockId: data.blockId,
|
||||
blockName: data.blockName || 'Unknown Block',
|
||||
blockType: data.blockType || 'unknown',
|
||||
input: data.input || {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
})
|
||||
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
output: {},
|
||||
success: false,
|
||||
error: data.error,
|
||||
durationMs: data.durationMs,
|
||||
startedAt,
|
||||
endedAt,
|
||||
workflowId,
|
||||
blockId: data.blockId,
|
||||
executionId,
|
||||
blockName: data.blockName,
|
||||
blockType: data.blockType,
|
||||
iterationCurrent: data.iterationCurrent,
|
||||
iterationTotal: data.iterationTotal,
|
||||
iterationType: data.iterationType,
|
||||
})
|
||||
},
|
||||
|
||||
onExecutionCompleted: (data) => {
|
||||
if (data.success) {
|
||||
// Add the start block (trigger) to executed blocks
|
||||
executedBlockIds.add(blockId)
|
||||
|
||||
const mergedBlockStates: Record<string, BlockState> = {
|
||||
...effectiveSnapshot.blockStates,
|
||||
}
|
||||
for (const [bId, state] of accumulatedBlockStates) {
|
||||
mergedBlockStates[bId] = state
|
||||
}
|
||||
|
||||
const mergedExecutedBlocks = new Set([
|
||||
...effectiveSnapshot.executedBlocks,
|
||||
...executedBlockIds,
|
||||
])
|
||||
|
||||
const updatedSnapshot: SerializableExecutionState = {
|
||||
...effectiveSnapshot,
|
||||
blockStates: mergedBlockStates,
|
||||
executedBlocks: Array.from(mergedExecutedBlocks),
|
||||
blockLogs: [...effectiveSnapshot.blockLogs, ...accumulatedBlockLogs],
|
||||
activeExecutionPath: Array.from(mergedExecutedBlocks),
|
||||
}
|
||||
setLastExecutionSnapshot(workflowId, updatedSnapshot)
|
||||
}
|
||||
},
|
||||
|
||||
onExecutionError: (data) => {
|
||||
const isWorkflowModified =
|
||||
data.error?.includes('Block not found in workflow') ||
|
||||
data.error?.includes('Upstream dependency not executed')
|
||||
|
||||
if (isWorkflowModified) {
|
||||
clearLastExecutionSnapshot(workflowId)
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message:
|
||||
'Workflow was modified. Run the workflow again to enable running from block.',
|
||||
workflowId,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: data.error || 'Run from block failed',
|
||||
workflowId,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
logger.error('Run-from-block failed:', error)
|
||||
}
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
setActiveBlocks(new Set())
|
||||
}
|
||||
},
|
||||
[
|
||||
getLastExecutionSnapshot,
|
||||
setLastExecutionSnapshot,
|
||||
clearLastExecutionSnapshot,
|
||||
setIsExecuting,
|
||||
setActiveBlocks,
|
||||
setBlockRunStatus,
|
||||
setEdgeRunStatus,
|
||||
addNotification,
|
||||
addConsole,
|
||||
executionStream,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles running workflow until a specific block (stops after that block completes)
|
||||
*/
|
||||
const handleRunUntilBlock = useCallback(
|
||||
async (blockId: string, workflowId: string) => {
|
||||
if (!workflowId || workflowId !== activeWorkflowId) {
|
||||
logger.error('Invalid workflow ID for run-until-block', { workflowId, activeWorkflowId })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId })
|
||||
|
||||
setExecutionResult(null)
|
||||
setIsExecuting(true)
|
||||
|
||||
const executionId = uuidv4()
|
||||
try {
|
||||
const result = await executeWorkflow(
|
||||
undefined,
|
||||
undefined,
|
||||
executionId,
|
||||
undefined,
|
||||
'manual',
|
||||
blockId
|
||||
)
|
||||
if (result && 'success' in result) {
|
||||
setExecutionResult(result)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorResult = handleExecutionError(error, { executionId })
|
||||
return errorResult
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setActiveBlocks(new Set())
|
||||
}
|
||||
},
|
||||
[activeWorkflowId, setExecutionResult, setIsExecuting, setIsDebugging, setActiveBlocks]
|
||||
)
|
||||
|
||||
return {
|
||||
isExecuting,
|
||||
isDebugging,
|
||||
@@ -1386,5 +1801,7 @@ export function useWorkflowExecution() {
|
||||
handleResumeDebug,
|
||||
handleCancelDebug,
|
||||
handleCancelExecution,
|
||||
handleRunFromBlock,
|
||||
handleRunUntilBlock,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useCurrentWorkflow,
|
||||
useNodeUtilities,
|
||||
useShiftSelectionLock,
|
||||
useWorkflowExecution,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
calculateContainerDimensions,
|
||||
@@ -302,6 +303,8 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
|
||||
|
||||
const { handleRunFromBlock, handleRunUntilBlock } = useWorkflowExecution()
|
||||
|
||||
const snapToGridSize = useSnapToGridSize()
|
||||
const snapToGrid = snapToGridSize > 0
|
||||
|
||||
@@ -733,13 +736,16 @@ const WorkflowContent = React.memo(() => {
|
||||
[collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection]
|
||||
)
|
||||
|
||||
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
|
||||
useShallow((state) => ({
|
||||
activeBlockIds: state.activeBlockIds,
|
||||
pendingBlocks: state.pendingBlocks,
|
||||
isDebugging: state.isDebugging,
|
||||
}))
|
||||
)
|
||||
const { activeBlockIds, pendingBlocks, isDebugging, isExecuting, getLastExecutionSnapshot } =
|
||||
useExecutionStore(
|
||||
useShallow((state) => ({
|
||||
activeBlockIds: state.activeBlockIds,
|
||||
pendingBlocks: state.pendingBlocks,
|
||||
isDebugging: state.isDebugging,
|
||||
isExecuting: state.isExecuting,
|
||||
getLastExecutionSnapshot: state.getLastExecutionSnapshot,
|
||||
}))
|
||||
)
|
||||
|
||||
const [dragStartParentId, setDragStartParentId] = useState<string | null>(null)
|
||||
|
||||
@@ -1102,6 +1108,50 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}, [contextMenuBlocks])
|
||||
|
||||
const handleContextRunFromBlock = useCallback(() => {
|
||||
if (contextMenuBlocks.length !== 1) return
|
||||
const blockId = contextMenuBlocks[0].id
|
||||
handleRunFromBlock(blockId, workflowIdParam)
|
||||
}, [contextMenuBlocks, workflowIdParam, handleRunFromBlock])
|
||||
|
||||
const handleContextRunUntilBlock = useCallback(() => {
|
||||
if (contextMenuBlocks.length !== 1) return
|
||||
const blockId = contextMenuBlocks[0].id
|
||||
handleRunUntilBlock(blockId, workflowIdParam)
|
||||
}, [contextMenuBlocks, workflowIdParam, handleRunUntilBlock])
|
||||
|
||||
const runFromBlockState = useMemo(() => {
|
||||
if (contextMenuBlocks.length !== 1) {
|
||||
return { canRun: false, reason: undefined }
|
||||
}
|
||||
const block = contextMenuBlocks[0]
|
||||
const snapshot = getLastExecutionSnapshot(workflowIdParam)
|
||||
const incomingEdges = edges.filter((edge) => edge.target === block.id)
|
||||
const isTriggerBlock = incomingEdges.length === 0
|
||||
|
||||
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
|
||||
const isSourceSatisfied = (sourceId: string) => {
|
||||
if (snapshot?.executedBlocks.includes(sourceId)) return true
|
||||
// Check if source is a trigger (has no incoming edges itself)
|
||||
const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId)
|
||||
return sourceIncomingEdges.length === 0
|
||||
}
|
||||
|
||||
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
|
||||
const dependenciesSatisfied =
|
||||
isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
|
||||
const isNoteBlock = block.type === 'note'
|
||||
const isInsideSubflow =
|
||||
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
||||
|
||||
if (isInsideSubflow) return { canRun: false, reason: 'Cannot run from inside subflow' }
|
||||
if (!dependenciesSatisfied) return { canRun: false, reason: 'Run upstream blocks first' }
|
||||
if (isNoteBlock) return { canRun: false, reason: undefined }
|
||||
if (isExecuting) return { canRun: false, reason: undefined }
|
||||
|
||||
return { canRun: true, reason: undefined }
|
||||
}, [contextMenuBlocks, edges, workflowIdParam, getLastExecutionSnapshot, isExecuting])
|
||||
|
||||
const handleContextAddBlock = useCallback(() => {
|
||||
useSearchModalStore.getState().open()
|
||||
}, [])
|
||||
@@ -1750,37 +1800,32 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
}, [screenToFlowPosition, handleToolbarDrop])
|
||||
|
||||
/**
|
||||
* Focus canvas on changed blocks when diff appears.
|
||||
*/
|
||||
/** Tracks blocks to pan to after diff updates. */
|
||||
const pendingZoomBlockIdsRef = useRef<Set<string> | null>(null)
|
||||
const prevDiffReadyRef = useRef(false)
|
||||
const seenDiffBlocksRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Phase 1: When diff becomes ready, record which blocks we want to zoom to
|
||||
// Phase 2 effect is located after displayNodes is defined (search for "Phase 2")
|
||||
/** Queues newly changed blocks for viewport panning. */
|
||||
useEffect(() => {
|
||||
if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) {
|
||||
// Diff just became ready - record blocks to zoom to
|
||||
const changedBlockIds = [
|
||||
...(diffAnalysis.new_blocks || []),
|
||||
...(diffAnalysis.edited_blocks || []),
|
||||
]
|
||||
|
||||
if (changedBlockIds.length > 0) {
|
||||
pendingZoomBlockIdsRef.current = new Set(changedBlockIds)
|
||||
} else {
|
||||
// No specific blocks to focus on, fit all after a frame
|
||||
pendingZoomBlockIdsRef.current = null
|
||||
requestAnimationFrame(() => {
|
||||
fitViewToBounds({ padding: 0.1, duration: 600 })
|
||||
})
|
||||
}
|
||||
} else if (!isDiffReady && prevDiffReadyRef.current) {
|
||||
// Diff was cleared (accepted/rejected) - cancel any pending zoom
|
||||
if (!isDiffReady || !diffAnalysis) {
|
||||
pendingZoomBlockIdsRef.current = null
|
||||
seenDiffBlocksRef.current.clear()
|
||||
return
|
||||
}
|
||||
prevDiffReadyRef.current = isDiffReady
|
||||
}, [isDiffReady, diffAnalysis, fitViewToBounds])
|
||||
|
||||
const newBlocks = new Set<string>()
|
||||
const allBlocks = [...(diffAnalysis.new_blocks || []), ...(diffAnalysis.edited_blocks || [])]
|
||||
|
||||
for (const id of allBlocks) {
|
||||
if (!seenDiffBlocksRef.current.has(id)) {
|
||||
newBlocks.add(id)
|
||||
}
|
||||
seenDiffBlocksRef.current.add(id)
|
||||
}
|
||||
|
||||
if (newBlocks.size > 0) {
|
||||
pendingZoomBlockIdsRef.current = newBlocks
|
||||
}
|
||||
}, [isDiffReady, diffAnalysis])
|
||||
|
||||
/** Displays trigger warning notifications. */
|
||||
useEffect(() => {
|
||||
@@ -2188,18 +2233,12 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
|
||||
|
||||
// Phase 2: When displayNodes updates, check if pending zoom blocks are ready
|
||||
// (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined)
|
||||
/** Pans viewport to pending blocks once they have valid dimensions. */
|
||||
useEffect(() => {
|
||||
const pendingBlockIds = pendingZoomBlockIdsRef.current
|
||||
if (!pendingBlockIds || pendingBlockIds.size === 0) {
|
||||
return
|
||||
}
|
||||
if (!pendingBlockIds || pendingBlockIds.size === 0) return
|
||||
|
||||
// Find the nodes we're waiting for
|
||||
const pendingNodes = displayNodes.filter((node) => pendingBlockIds.has(node.id))
|
||||
|
||||
// Check if all expected nodes are present with valid dimensions
|
||||
const allNodesReady =
|
||||
pendingNodes.length === pendingBlockIds.size &&
|
||||
pendingNodes.every(
|
||||
@@ -2211,16 +2250,20 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
|
||||
if (allNodesReady) {
|
||||
logger.info('Diff ready - focusing on changed blocks', {
|
||||
logger.info('Focusing on changed blocks', {
|
||||
changedBlockIds: Array.from(pendingBlockIds),
|
||||
foundNodes: pendingNodes.length,
|
||||
})
|
||||
// Clear pending state before zooming to prevent re-triggers
|
||||
pendingZoomBlockIdsRef.current = null
|
||||
// Use requestAnimationFrame to ensure React has finished rendering
|
||||
|
||||
const nodesWithAbsolutePositions = pendingNodes.map((node) => ({
|
||||
...node,
|
||||
position: getNodeAbsolutePosition(node.id),
|
||||
}))
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
fitViewToBounds({
|
||||
nodes: pendingNodes,
|
||||
nodes: nodesWithAbsolutePositions,
|
||||
duration: 600,
|
||||
padding: 0.1,
|
||||
minZoom: 0.5,
|
||||
@@ -2228,7 +2271,7 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [displayNodes, fitViewToBounds])
|
||||
}, [displayNodes, fitViewToBounds, getNodeAbsolutePosition])
|
||||
|
||||
/** Handles ActionBar remove-from-subflow events. */
|
||||
useEffect(() => {
|
||||
@@ -2302,33 +2345,12 @@ const WorkflowContent = React.memo(() => {
|
||||
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
||||
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
||||
|
||||
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
selectedIdsRef.current = null
|
||||
setDisplayNodes((nds) => {
|
||||
const updated = applyNodeChanges(changes, nds)
|
||||
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
||||
if (!hasSelectionChange) return updated
|
||||
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
|
||||
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
|
||||
return resolved
|
||||
})
|
||||
const selectedIds = selectedIdsRef.current as string[] | null
|
||||
if (selectedIds !== null) {
|
||||
syncPanelWithSelection(selectedIds)
|
||||
}
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates container dimensions in displayNodes during drag.
|
||||
* This allows live resizing of containers as their children are dragged.
|
||||
* Updates container dimensions in displayNodes during drag or keyboard movement.
|
||||
*/
|
||||
const updateContainerDimensionsDuringDrag = useCallback(
|
||||
(draggedNodeId: string, draggedNodePosition: { x: number; y: number }) => {
|
||||
const parentId = blocks[draggedNodeId]?.data?.parentId
|
||||
const updateContainerDimensionsDuringMove = useCallback(
|
||||
(movedNodeId: string, movedNodePosition: { x: number; y: number }) => {
|
||||
const parentId = blocks[movedNodeId]?.data?.parentId
|
||||
if (!parentId) return
|
||||
|
||||
setDisplayNodes((currentNodes) => {
|
||||
@@ -2336,7 +2358,7 @@ const WorkflowContent = React.memo(() => {
|
||||
if (childNodes.length === 0) return currentNodes
|
||||
|
||||
const childPositions = childNodes.map((node) => {
|
||||
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
|
||||
const nodePosition = node.id === movedNodeId ? movedNodePosition : node.position
|
||||
const { width, height } = getBlockDimensions(node.id)
|
||||
return { x: nodePosition.x, y: nodePosition.y, width, height }
|
||||
})
|
||||
@@ -2367,6 +2389,55 @@ const WorkflowContent = React.memo(() => {
|
||||
[blocks, getBlockDimensions]
|
||||
)
|
||||
|
||||
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
selectedIdsRef.current = null
|
||||
setDisplayNodes((nds) => {
|
||||
const updated = applyNodeChanges(changes, nds)
|
||||
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
||||
if (!hasSelectionChange) return updated
|
||||
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
|
||||
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
|
||||
return resolved
|
||||
})
|
||||
const selectedIds = selectedIdsRef.current as string[] | null
|
||||
if (selectedIds !== null) {
|
||||
syncPanelWithSelection(selectedIds)
|
||||
}
|
||||
|
||||
// Handle position changes (e.g., from keyboard arrow key movement)
|
||||
// Update container dimensions when child nodes are moved and persist to backend
|
||||
// Only persist if not in a drag operation (drag-end is handled by onNodeDragStop)
|
||||
const isInDragOperation =
|
||||
getDragStartPosition() !== null || multiNodeDragStartRef.current.size > 0
|
||||
const keyboardPositionUpdates: Array<{ id: string; position: { x: number; y: number } }> = []
|
||||
for (const change of changes) {
|
||||
if (
|
||||
change.type === 'position' &&
|
||||
!change.dragging &&
|
||||
'position' in change &&
|
||||
change.position
|
||||
) {
|
||||
updateContainerDimensionsDuringMove(change.id, change.position)
|
||||
if (!isInDragOperation) {
|
||||
keyboardPositionUpdates.push({ id: change.id, position: change.position })
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist keyboard movements to backend for collaboration sync
|
||||
if (keyboardPositionUpdates.length > 0) {
|
||||
collaborativeBatchUpdatePositions(keyboardPositionUpdates)
|
||||
}
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
updateContainerDimensionsDuringMove,
|
||||
collaborativeBatchUpdatePositions,
|
||||
getDragStartPosition,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Effect to resize loops when nodes change (add/remove/position change).
|
||||
* Runs on structural changes only - not during drag (position-only changes).
|
||||
@@ -2611,7 +2682,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// If the node is inside a container, update container dimensions during drag
|
||||
if (currentParentId) {
|
||||
updateContainerDimensionsDuringDrag(node.id, node.position)
|
||||
updateContainerDimensionsDuringMove(node.id, node.position)
|
||||
}
|
||||
|
||||
// Check if this is a starter block - starter blocks should never be in containers
|
||||
@@ -2728,7 +2799,7 @@ const WorkflowContent = React.memo(() => {
|
||||
blocks,
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
updateContainerDimensionsDuringDrag,
|
||||
updateContainerDimensionsDuringMove,
|
||||
highlightContainerNode,
|
||||
]
|
||||
)
|
||||
@@ -3418,11 +3489,19 @@ const WorkflowContent = React.memo(() => {
|
||||
onRemoveFromSubflow={handleContextRemoveFromSubflow}
|
||||
onOpenEditor={handleContextOpenEditor}
|
||||
onRename={handleContextRename}
|
||||
onRunFromBlock={handleContextRunFromBlock}
|
||||
onRunUntilBlock={handleContextRunUntilBlock}
|
||||
hasClipboard={hasClipboard()}
|
||||
showRemoveFromSubflow={contextMenuBlocks.some(
|
||||
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
||||
)}
|
||||
canRunFromBlock={runFromBlockState.canRun}
|
||||
disableEdit={!effectivePermissions.canEdit}
|
||||
isExecuting={isExecuting}
|
||||
isPositionalTrigger={
|
||||
contextMenuBlocks.length === 1 &&
|
||||
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
|
||||
}
|
||||
/>
|
||||
|
||||
<CanvasMenu
|
||||
|
||||
@@ -4,11 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
ChevronUp,
|
||||
Clipboard,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
RepeatIcon,
|
||||
Search,
|
||||
SplitIcon,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} 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 { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu'
|
||||
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
|
||||
@@ -690,6 +694,7 @@ interface ExecutionData {
|
||||
output?: unknown
|
||||
status?: string
|
||||
durationMs?: number
|
||||
childWorkflowSnapshotId?: string
|
||||
}
|
||||
|
||||
interface WorkflowVariable {
|
||||
@@ -714,6 +719,8 @@ interface PreviewEditorProps {
|
||||
parallels?: Record<string, Parallel>
|
||||
/** When true, shows "Not Executed" badge if no executionData is provided */
|
||||
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 */
|
||||
onClose?: () => void
|
||||
/** Callback to drill down into a nested workflow block */
|
||||
@@ -739,6 +746,7 @@ function PreviewEditorContent({
|
||||
loops,
|
||||
parallels,
|
||||
isExecutionMode = false,
|
||||
childWorkflowSnapshots,
|
||||
onClose,
|
||||
onDrillDown,
|
||||
}: PreviewEditorProps) {
|
||||
@@ -768,17 +776,35 @@ function PreviewEditorContent({
|
||||
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
|
||||
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 */
|
||||
const handleExpandChildWorkflow = useCallback(() => {
|
||||
if (!childWorkflowId || !childWorkflowState) return
|
||||
if (!childWorkflowId) return
|
||||
|
||||
if (isExecutionMode && onDrillDown) {
|
||||
onDrillDown(block.id, childWorkflowState)
|
||||
if (!childWorkflowSnapshotState) return
|
||||
onDrillDown(block.id, childWorkflowSnapshotState)
|
||||
} else if (workspaceId) {
|
||||
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 subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
@@ -813,6 +839,13 @@ function PreviewEditorContent({
|
||||
} = useContextMenu()
|
||||
|
||||
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(
|
||||
(e: React.MouseEvent, content: string, copyOnly: boolean) => {
|
||||
@@ -862,9 +895,6 @@ function PreviewEditorContent({
|
||||
}
|
||||
}, [contextMenuData.content])
|
||||
|
||||
/**
|
||||
* Handles mouse down event on the resize handle to initiate resizing
|
||||
*/
|
||||
const handleConnectionsResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setIsResizing(true)
|
||||
@@ -874,18 +904,12 @@ function PreviewEditorContent({
|
||||
[connectionsHeight]
|
||||
)
|
||||
|
||||
/**
|
||||
* Toggle connections collapsed state
|
||||
*/
|
||||
const toggleConnectionsCollapsed = useCallback(() => {
|
||||
setConnectionsHeight((prev) =>
|
||||
prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT
|
||||
)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Sets up resize event listeners during resize operations
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
|
||||
@@ -1141,15 +1165,17 @@ function PreviewEditorContent({
|
||||
<div className='relative flex h-full w-80 flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
|
||||
{/* Header - styled like editor */}
|
||||
<div className='mx-[-1px] flex flex-shrink-0 items-center gap-[8px] rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-[12px] py-[6px]'>
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ backgroundColor: blockConfig.bgColor }}
|
||||
>
|
||||
<IconComponent
|
||||
icon={blockConfig.icon}
|
||||
className='h-[12px] w-[12px] text-[var(--white)]'
|
||||
/>
|
||||
</div>
|
||||
{block.type !== 'note' && (
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ backgroundColor: blockConfig.bgColor }}
|
||||
>
|
||||
<IconComponent
|
||||
icon={blockConfig.icon}
|
||||
className='h-[12px] w-[12px] text-[var(--white)]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{block.name || blockConfig.name}
|
||||
</span>
|
||||
@@ -1203,7 +1229,11 @@ function PreviewEditorContent({
|
||||
}
|
||||
emptyMessage='No input data'
|
||||
>
|
||||
<div onContextMenu={handleExecutionContextMenu} ref={contentRef}>
|
||||
<div
|
||||
onContextMenu={handleExecutionContextMenu}
|
||||
ref={contentRef}
|
||||
className='relative'
|
||||
>
|
||||
<Code.Viewer
|
||||
code={formatValueAsJson(executionData.input)}
|
||||
language='json'
|
||||
@@ -1213,6 +1243,49 @@ function PreviewEditorContent({
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
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>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
@@ -1229,7 +1302,7 @@ function PreviewEditorContent({
|
||||
emptyMessage='No output data'
|
||||
isError={executionData.status === 'error'}
|
||||
>
|
||||
<div onContextMenu={handleExecutionContextMenu}>
|
||||
<div onContextMenu={handleExecutionContextMenu} className='relative'>
|
||||
<Code.Viewer
|
||||
code={formatValueAsJson(executionData.output)}
|
||||
language='json'
|
||||
@@ -1242,6 +1315,49 @@ function PreviewEditorContent({
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
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>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
@@ -1254,7 +1370,7 @@ function PreviewEditorContent({
|
||||
Workflow Preview
|
||||
</div>
|
||||
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||
{isLoadingChildWorkflow ? (
|
||||
{resolvedIsLoadingChildWorkflow ? (
|
||||
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
|
||||
<div
|
||||
className='h-[18px] w-[18px] animate-spin rounded-full'
|
||||
@@ -1267,11 +1383,11 @@ function PreviewEditorContent({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : childWorkflowState ? (
|
||||
) : resolvedChildWorkflowState ? (
|
||||
<>
|
||||
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
|
||||
<PreviewWorkflow
|
||||
workflowState={childWorkflowState}
|
||||
workflowState={resolvedChildWorkflowState}
|
||||
height={160}
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
@@ -1303,7 +1419,9 @@ function PreviewEditorContent({
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||
Unable to load preview
|
||||
{isMissingChildWorkflow
|
||||
? DELETED_WORKFLOW_LABEL
|
||||
: 'Unable to load preview'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockVisibleForMode,
|
||||
} 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 { getBlock } from '@/blocks'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
@@ -112,7 +113,7 @@ function resolveWorkflowName(
|
||||
if (!rawValue || typeof rawValue !== 'string') return null
|
||||
|
||||
const workflowMap = useWorkflowRegistry.getState().workflows
|
||||
return workflowMap[rawValue]?.name ?? null
|
||||
return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,8 +412,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
|
||||
const IconComponent = blockConfig.icon
|
||||
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
const isNoteBlock = type === 'note'
|
||||
|
||||
const shouldShowDefaultHandles = !isStarterOrTrigger
|
||||
const shouldShowDefaultHandles = !isStarterOrTrigger && !isNoteBlock
|
||||
const hasSubBlocks = visibleSubBlocks.length > 0
|
||||
const hasContentBelowHeader =
|
||||
type === 'condition'
|
||||
@@ -574,8 +576,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Source and error handles for non-condition/router blocks */}
|
||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
|
||||
{/* Source and error handles for non-condition/router/note blocks */}
|
||||
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && !isNoteBlock && (
|
||||
<>
|
||||
<Handle
|
||||
type='source'
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { memo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
|
||||
/** Execution status for subflows in preview mode */
|
||||
@@ -13,6 +15,8 @@ interface WorkflowPreviewSubflowData {
|
||||
width?: number
|
||||
height?: number
|
||||
kind: 'loop' | 'parallel'
|
||||
/** Whether this subflow is enabled */
|
||||
enabled?: boolean
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
/** Execution status for highlighting the subflow container */
|
||||
@@ -27,7 +31,15 @@ interface WorkflowPreviewSubflowData {
|
||||
* or interactive features.
|
||||
*/
|
||||
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false, executionStatus } = data
|
||||
const {
|
||||
name,
|
||||
width = 500,
|
||||
height = 300,
|
||||
kind,
|
||||
enabled = true,
|
||||
isPreviewSelected = false,
|
||||
executionStatus,
|
||||
} = data
|
||||
|
||||
const isLoop = kind === 'loop'
|
||||
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
|
||||
@@ -84,14 +96,21 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: blockIconBg }}
|
||||
style={{ backgroundColor: enabled ? blockIconBg : 'var(--surface-4)' }}
|
||||
>
|
||||
<BlockIcon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span className='font-medium text-[16px]' title={blockName}>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-[16px]',
|
||||
!enabled && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
title={blockName}
|
||||
>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
{!enabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
</div>
|
||||
|
||||
{/* Content area - matches workflow structure */}
|
||||
|
||||
@@ -23,11 +23,7 @@ import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/type
|
||||
|
||||
const logger = createLogger('PreviewWorkflow')
|
||||
|
||||
/**
|
||||
* Gets block dimensions for preview purposes.
|
||||
* For containers, uses stored dimensions or defaults.
|
||||
* For regular blocks, uses stored height or estimates based on type.
|
||||
*/
|
||||
/** Gets block dimensions, using stored values or defaults. */
|
||||
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
return {
|
||||
@@ -50,10 +46,7 @@ function getPreviewBlockDimensions(block: BlockState): { width: number; height:
|
||||
return estimateBlockDimensions(block.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions and sizes.
|
||||
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
|
||||
*/
|
||||
/** Calculates container dimensions from child block positions. */
|
||||
function calculateContainerDimensions(
|
||||
containerId: string,
|
||||
blocks: Record<string, BlockState>
|
||||
@@ -91,12 +84,7 @@ function calculateContainerDimensions(
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the leftmost block ID from a workflow state.
|
||||
* Excludes subflow containers (loop/parallel) from consideration.
|
||||
* @param workflowState - The workflow state to search
|
||||
* @returns The ID of the leftmost block, or null if no blocks exist
|
||||
*/
|
||||
/** Finds the leftmost block ID, excluding subflow containers. */
|
||||
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
|
||||
if (!workflowState?.blocks) return null
|
||||
|
||||
@@ -118,7 +106,7 @@ export function getLeftmostBlockId(workflowState: WorkflowState | null | undefin
|
||||
/** Execution status for edges/nodes in the preview */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
/** Calculates absolute position for blocks, handling nested subflows */
|
||||
/** Calculates absolute position, handling nested subflows. */
|
||||
function calculateAbsolutePosition(
|
||||
block: BlockState,
|
||||
blocks: Record<string, BlockState>
|
||||
@@ -164,10 +152,7 @@ interface PreviewWorkflowProps {
|
||||
lightweight?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview node types using minimal components without hooks or store subscriptions.
|
||||
* This prevents interaction issues while allowing canvas panning and node clicking.
|
||||
*/
|
||||
/** Preview node types using minimal, hook-free components. */
|
||||
const previewNodeTypes: NodeTypes = {
|
||||
workflowBlock: PreviewBlock,
|
||||
noteBlock: PreviewBlock,
|
||||
@@ -185,11 +170,7 @@ interface FitViewOnChangeProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
|
||||
* Only triggers on actual node additions/removals, not on selection changes.
|
||||
* Must be rendered inside ReactFlowProvider.
|
||||
*/
|
||||
/** Calls fitView on node changes or container resize. */
|
||||
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
|
||||
const { fitView } = useReactFlow()
|
||||
const lastNodeIdsRef = useRef<string | null>(null)
|
||||
@@ -229,16 +210,7 @@ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeP
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Readonly workflow component for visualizing workflow state.
|
||||
* Renders blocks, subflows, and edges with execution status highlighting.
|
||||
*
|
||||
* @remarks
|
||||
* - Supports panning and node click interactions
|
||||
* - Shows execution path via green edges for successful paths
|
||||
* - Error edges display red by default, green when error path was taken
|
||||
* - Fits view automatically when nodes change or container resizes
|
||||
*/
|
||||
/** Readonly workflow visualization with execution status highlighting. */
|
||||
export function PreviewWorkflow({
|
||||
workflowState,
|
||||
className,
|
||||
@@ -300,49 +272,58 @@ export function PreviewWorkflow({
|
||||
return map
|
||||
}, [workflowState.blocks, isValidWorkflowState])
|
||||
|
||||
/** Derives subflow execution status from child blocks */
|
||||
/** Maps base block IDs to execution data, handling parallel iteration variants (blockId₍n₎). */
|
||||
const blockExecutionMap = useMemo(() => {
|
||||
if (!executedBlocks) return new Map<string, { status: string }>()
|
||||
|
||||
const map = new Map<string, { status: string }>()
|
||||
for (const [key, value] of Object.entries(executedBlocks)) {
|
||||
// Extract base ID (remove iteration suffix like ₍0₎)
|
||||
const baseId = key.includes('₍') ? key.split('₍')[0] : key
|
||||
// Keep first match or error status (error takes precedence)
|
||||
const existing = map.get(baseId)
|
||||
if (!existing || value.status === 'error') {
|
||||
map.set(baseId, value)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [executedBlocks])
|
||||
|
||||
/** Derives subflow status from children. Error takes precedence. */
|
||||
const getSubflowExecutionStatus = useMemo(() => {
|
||||
return (subflowId: string): ExecutionStatus | undefined => {
|
||||
if (!executedBlocks) return undefined
|
||||
|
||||
const childIds = subflowChildrenMap.get(subflowId)
|
||||
if (!childIds?.length) return undefined
|
||||
|
||||
const childStatuses = childIds.map((id) => executedBlocks[id]).filter(Boolean)
|
||||
if (childStatuses.length === 0) return undefined
|
||||
const executedChildren = childIds
|
||||
.map((id) => blockExecutionMap.get(id))
|
||||
.filter((status): status is { status: string } => Boolean(status))
|
||||
|
||||
if (childStatuses.some((s) => s.status === 'error')) return 'error'
|
||||
if (childStatuses.some((s) => s.status === 'success')) return 'success'
|
||||
return 'not-executed'
|
||||
if (executedChildren.length === 0) return undefined
|
||||
if (executedChildren.some((s) => s.status === 'error')) return 'error'
|
||||
return 'success'
|
||||
}
|
||||
}, [executedBlocks, subflowChildrenMap])
|
||||
}, [subflowChildrenMap, blockExecutionMap])
|
||||
|
||||
/** Gets execution status for any block, deriving subflow status from children */
|
||||
/** Gets block status. Subflows derive status from children. */
|
||||
const getBlockExecutionStatus = useMemo(() => {
|
||||
return (blockId: string): { status: string; executed: boolean } | undefined => {
|
||||
if (!executedBlocks) return undefined
|
||||
|
||||
const directStatus = executedBlocks[blockId]
|
||||
const directStatus = blockExecutionMap.get(blockId)
|
||||
if (directStatus) {
|
||||
return { status: directStatus.status, executed: true }
|
||||
}
|
||||
|
||||
const block = workflowState.blocks?.[blockId]
|
||||
if (block && (block.type === 'loop' || block.type === 'parallel')) {
|
||||
if (block?.type === 'loop' || block?.type === 'parallel') {
|
||||
const subflowStatus = getSubflowExecutionStatus(blockId)
|
||||
if (subflowStatus) {
|
||||
return { status: subflowStatus, executed: true }
|
||||
}
|
||||
|
||||
const incomingEdge = workflowState.edges?.find((e) => e.target === blockId)
|
||||
if (incomingEdge && executedBlocks[incomingEdge.source]?.status === 'success') {
|
||||
return { status: 'not-executed', executed: true }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}, [executedBlocks, workflowState.blocks, workflowState.edges, getSubflowExecutionStatus])
|
||||
}, [workflowState.blocks, getSubflowExecutionStatus, blockExecutionMap])
|
||||
|
||||
const edgesStructure = useMemo(() => {
|
||||
if (!isValidWorkflowState) return { count: 0, ids: '' }
|
||||
@@ -380,6 +361,7 @@ export function PreviewWorkflow({
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
kind: block.type as 'loop' | 'parallel',
|
||||
enabled: block.enabled ?? true,
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus: subflowExecutionStatus,
|
||||
lightweight,
|
||||
@@ -406,9 +388,11 @@ export function PreviewWorkflow({
|
||||
}
|
||||
}
|
||||
|
||||
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'workflowBlock',
|
||||
type: nodeType,
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
zIndex: block.data?.parentId ? 10 : undefined,
|
||||
@@ -442,48 +426,29 @@ export function PreviewWorkflow({
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
if (!isValidWorkflowState) return []
|
||||
|
||||
/**
|
||||
* Determines edge execution status for visualization.
|
||||
* Error edges turn green when taken (source errored, target executed).
|
||||
* Normal edges turn green when both source succeeded and target executed.
|
||||
*/
|
||||
/** Edge is green if target executed and source condition met by edge type. */
|
||||
const getEdgeExecutionStatus = (edge: {
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string | null
|
||||
}): ExecutionStatus | undefined => {
|
||||
if (!executedBlocks) return undefined
|
||||
if (blockExecutionMap.size === 0) return undefined
|
||||
|
||||
const targetStatus = getBlockExecutionStatus(edge.target)
|
||||
if (!targetStatus?.executed) return 'not-executed'
|
||||
|
||||
const sourceStatus = getBlockExecutionStatus(edge.source)
|
||||
const targetStatus = getBlockExecutionStatus(edge.target)
|
||||
const isErrorEdge = edge.sourceHandle === 'error'
|
||||
const { sourceHandle } = edge
|
||||
|
||||
if (isErrorEdge) {
|
||||
return sourceStatus?.status === 'error' && targetStatus?.executed
|
||||
? 'success'
|
||||
: 'not-executed'
|
||||
if (sourceHandle === 'error') {
|
||||
return sourceStatus?.status === 'error' ? 'success' : 'not-executed'
|
||||
}
|
||||
|
||||
const isSubflowStartEdge =
|
||||
edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source'
|
||||
|
||||
if (isSubflowStartEdge) {
|
||||
const incomingEdge = workflowState.edges?.find((e) => e.target === edge.source)
|
||||
const incomingSucceeded = incomingEdge
|
||||
? executedBlocks[incomingEdge.source]?.status === 'success'
|
||||
: false
|
||||
return incomingSucceeded ? 'success' : 'not-executed'
|
||||
}
|
||||
|
||||
const targetBlock = workflowState.blocks?.[edge.target]
|
||||
const targetIsSubflow =
|
||||
targetBlock && (targetBlock.type === 'loop' || targetBlock.type === 'parallel')
|
||||
|
||||
if (sourceStatus?.status === 'success' && (targetStatus?.executed || targetIsSubflow)) {
|
||||
if (sourceHandle === 'loop-start-source' || sourceHandle === 'parallel-start-source') {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
return 'not-executed'
|
||||
return sourceStatus?.status === 'success' ? 'success' : 'not-executed'
|
||||
}
|
||||
|
||||
return (workflowState.edges || []).map((edge) => {
|
||||
@@ -505,9 +470,8 @@ export function PreviewWorkflow({
|
||||
}, [
|
||||
edgesStructure,
|
||||
workflowState.edges,
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
executedBlocks,
|
||||
blockExecutionMap,
|
||||
getBlockExecutionStatus,
|
||||
])
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ interface TraceSpan {
|
||||
status?: string
|
||||
duration?: number
|
||||
children?: TraceSpan[]
|
||||
childWorkflowSnapshotId?: string
|
||||
childWorkflowId?: string
|
||||
}
|
||||
|
||||
interface BlockExecutionData {
|
||||
@@ -28,6 +30,7 @@ interface BlockExecutionData {
|
||||
durationMs: number
|
||||
/** Child trace spans for nested workflow blocks */
|
||||
children?: TraceSpan[]
|
||||
childWorkflowSnapshotId?: string
|
||||
}
|
||||
|
||||
/** Represents a level in the workflow navigation stack */
|
||||
@@ -35,12 +38,13 @@ interface WorkflowStackEntry {
|
||||
workflowState: WorkflowState
|
||||
traceSpans: TraceSpan[]
|
||||
blockExecutions: Record<string, BlockExecutionData>
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts child trace spans from a workflow block's execution data.
|
||||
* Checks both the `children` property (where trace span processing moves them)
|
||||
* and the legacy `output.childTraceSpans` for compatibility.
|
||||
* Checks `children` property (where trace-spans processing puts them),
|
||||
* with fallback to `output.childTraceSpans` for old stored logs.
|
||||
*/
|
||||
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
|
||||
if (!blockExecution) return []
|
||||
@@ -49,6 +53,7 @@ function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined):
|
||||
return blockExecution.children
|
||||
}
|
||||
|
||||
// Backward compat: old stored logs may have childTraceSpans in output
|
||||
if (blockExecution.output && typeof blockExecution.output === 'object') {
|
||||
const output = blockExecution.output as Record<string, unknown>
|
||||
if (Array.isArray(output.childTraceSpans)) {
|
||||
@@ -88,6 +93,7 @@ export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockEx
|
||||
status: span.status || 'unknown',
|
||||
durationMs: span.duration || 0,
|
||||
children: span.children,
|
||||
childWorkflowSnapshotId: span.childWorkflowSnapshotId,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +108,8 @@ interface PreviewProps {
|
||||
traceSpans?: TraceSpan[]
|
||||
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
|
||||
blockExecutions?: Record<string, BlockExecutionData>
|
||||
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
|
||||
childWorkflowSnapshots?: Record<string, WorkflowState>
|
||||
/** Additional CSS class names */
|
||||
className?: string
|
||||
/** Height of the component */
|
||||
@@ -134,6 +142,7 @@ export function Preview({
|
||||
workflowState: rootWorkflowState,
|
||||
traceSpans: rootTraceSpans,
|
||||
blockExecutions: providedBlockExecutions,
|
||||
childWorkflowSnapshots,
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
@@ -143,7 +152,6 @@ export function Preview({
|
||||
initialSelectedBlockId,
|
||||
autoSelectLeftmost = true,
|
||||
}: PreviewProps) {
|
||||
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
|
||||
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
|
||||
if (initialSelectedBlockId) return initialSelectedBlockId
|
||||
if (autoSelectLeftmost) {
|
||||
@@ -152,17 +160,14 @@ export function Preview({
|
||||
return null
|
||||
})
|
||||
|
||||
/** Stack for nested workflow navigation. Empty means we're at the root level. */
|
||||
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
|
||||
|
||||
/** Block executions for the root level */
|
||||
const rootBlockExecutions = useMemo(() => {
|
||||
if (providedBlockExecutions) return providedBlockExecutions
|
||||
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
|
||||
return buildBlockExecutions(rootTraceSpans)
|
||||
}, [providedBlockExecutions, rootTraceSpans])
|
||||
|
||||
/** Current block executions - either from stack or root */
|
||||
const blockExecutions = useMemo(() => {
|
||||
if (workflowStack.length > 0) {
|
||||
return workflowStack[workflowStack.length - 1].blockExecutions
|
||||
@@ -170,7 +175,6 @@ export function Preview({
|
||||
return rootBlockExecutions
|
||||
}, [workflowStack, rootBlockExecutions])
|
||||
|
||||
/** Current workflow state - either from stack or root */
|
||||
const workflowState = useMemo(() => {
|
||||
if (workflowStack.length > 0) {
|
||||
return workflowStack[workflowStack.length - 1].workflowState
|
||||
@@ -178,41 +182,39 @@ export function Preview({
|
||||
return rootWorkflowState
|
||||
}, [workflowStack, rootWorkflowState])
|
||||
|
||||
/** Whether we're in execution mode (have trace spans/block executions) */
|
||||
const isExecutionMode = useMemo(() => {
|
||||
return Object.keys(blockExecutions).length > 0
|
||||
}, [blockExecutions])
|
||||
|
||||
/** Handler to drill down into a nested workflow block */
|
||||
const handleDrillDown = useCallback(
|
||||
(blockId: string, childWorkflowState: WorkflowState) => {
|
||||
const blockExecution = blockExecutions[blockId]
|
||||
const childTraceSpans = extractChildTraceSpans(blockExecution)
|
||||
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
|
||||
|
||||
const workflowName = childWorkflowState.metadata?.name || 'Nested Workflow'
|
||||
|
||||
setWorkflowStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
workflowState: childWorkflowState,
|
||||
traceSpans: childTraceSpans,
|
||||
blockExecutions: childBlockExecutions,
|
||||
workflowName,
|
||||
},
|
||||
])
|
||||
|
||||
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
|
||||
const leftmostId = getLeftmostBlockId(childWorkflowState)
|
||||
setPinnedBlockId(leftmostId)
|
||||
},
|
||||
[blockExecutions]
|
||||
)
|
||||
|
||||
/** Handler to go back up the stack */
|
||||
const handleGoBack = useCallback(() => {
|
||||
setWorkflowStack((prev) => prev.slice(0, -1))
|
||||
setPinnedBlockId(null)
|
||||
}, [])
|
||||
|
||||
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
|
||||
const handleNodeClick = useCallback((blockId: string) => {
|
||||
setPinnedBlockId(blockId)
|
||||
}, [])
|
||||
@@ -231,6 +233,8 @@ export function Preview({
|
||||
|
||||
const isNested = workflowStack.length > 0
|
||||
|
||||
const currentWorkflowName = isNested ? workflowStack[workflowStack.length - 1].workflowName : null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height, width }}
|
||||
@@ -241,20 +245,27 @@ export function Preview({
|
||||
)}
|
||||
>
|
||||
{isNested && (
|
||||
<div className='absolute top-[12px] left-[12px] z-20'>
|
||||
<div className='absolute top-[12px] left-[12px] z-20 flex items-center gap-[6px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleGoBack}
|
||||
className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]'
|
||||
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)]'
|
||||
>
|
||||
<ArrowLeft className='h-[13px] w-[13px]' />
|
||||
<span className='font-medium text-[13px]'>Back</span>
|
||||
<ArrowLeft className='h-[12px] w-[12px]' />
|
||||
<span className='font-medium text-[12px]'>Back</span>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -283,6 +294,7 @@ export function Preview({
|
||||
loops={workflowState.loops}
|
||||
parallels={workflowState.parallels}
|
||||
isExecutionMode={isExecutionMode}
|
||||
childWorkflowSnapshots={childWorkflowSnapshots}
|
||||
onClose={handleEditorClose}
|
||||
onDrillDown={handleDrillDown}
|
||||
/>
|
||||
|
||||
@@ -2,19 +2,38 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { BookOpen, Layout, ScrollText } from 'lucide-react'
|
||||
import { Database, HelpCircle, Layout, Settings } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { Library } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import type {
|
||||
SearchBlockItem,
|
||||
SearchDocItem,
|
||||
SearchToolOperationItem,
|
||||
} from '@/stores/modals/search/types'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
|
||||
function customFilter(value: string, search: string): number {
|
||||
const searchLower = search.toLowerCase()
|
||||
const valueLower = value.toLowerCase()
|
||||
|
||||
if (valueLower === searchLower) return 1
|
||||
if (valueLower.startsWith(searchLower)) return 0.9
|
||||
if (valueLower.includes(searchLower)) return 0.7
|
||||
|
||||
const searchWords = searchLower.split(/\s+/).filter(Boolean)
|
||||
if (searchWords.length > 1) {
|
||||
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
|
||||
if (allWordsMatch) return 0.5
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
@@ -43,8 +62,10 @@ interface PageItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
href: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
shortcut?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export function SearchModal({
|
||||
@@ -57,10 +78,10 @@ export function SearchModal({
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const brand = useBrandConfig()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -70,42 +91,71 @@ export function SearchModal({
|
||||
(state) => state.data
|
||||
)
|
||||
|
||||
const openHelpModal = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-help-modal'))
|
||||
}, [])
|
||||
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: '⌘⇧L',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'Docs',
|
||||
icon: BookOpen,
|
||||
href: brand.documentationUrl || 'https://docs.sim.ai/',
|
||||
},
|
||||
],
|
||||
[workspaceId, brand.documentationUrl]
|
||||
(): PageItem[] =>
|
||||
[
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: '⌘⇧L',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
hidden: permissionConfig.hideTemplates,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
name: 'Knowledge Base',
|
||||
icon: Database,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
hidden: permissionConfig.hideKnowledgeBaseTab,
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
name: 'Help',
|
||||
icon: HelpCircle,
|
||||
onClick: openHelpModal,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: openSettingsModal,
|
||||
},
|
||||
].filter((page) => !page.hidden),
|
||||
[
|
||||
workspaceId,
|
||||
openHelpModal,
|
||||
openSettingsModal,
|
||||
permissionConfig.hideTemplates,
|
||||
permissionConfig.hideKnowledgeBaseTab,
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch('')
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
if (open && inputRef.current) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(inputRef.current, '')
|
||||
inputRef.current.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value)
|
||||
const handleSearchChange = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const list = document.querySelector('[cmdk-list]')
|
||||
if (list) {
|
||||
@@ -179,10 +229,14 @@ export function SearchModal({
|
||||
|
||||
const handlePageSelect = useCallback(
|
||||
(page: PageItem) => {
|
||||
if (page.href.startsWith('http')) {
|
||||
window.open(page.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(page.href)
|
||||
if (page.onClick) {
|
||||
page.onClick()
|
||||
} else if (page.href) {
|
||||
if (page.href.startsWith('http')) {
|
||||
window.open(page.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(page.href)
|
||||
}
|
||||
}
|
||||
onOpenChange(false)
|
||||
},
|
||||
@@ -203,28 +257,6 @@ export function SearchModal({
|
||||
const showToolOperations = isOnWorkflowPage && toolOperations.length > 0
|
||||
const showDocs = isOnWorkflowPage && docs.length > 0
|
||||
|
||||
const customFilter = useCallback((value: string, search: string, keywords?: string[]) => {
|
||||
const searchLower = search.toLowerCase()
|
||||
const valueLower = value.toLowerCase()
|
||||
|
||||
if (valueLower === searchLower) return 1
|
||||
if (valueLower.startsWith(searchLower)) return 0.8
|
||||
if (valueLower.includes(searchLower)) return 0.6
|
||||
|
||||
const searchWords = searchLower.split(/\s+/).filter(Boolean)
|
||||
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
|
||||
if (allWordsMatch && searchWords.length > 0) return 0.4
|
||||
|
||||
if (keywords?.length) {
|
||||
const keywordsLower = keywords.join(' ').toLowerCase()
|
||||
if (keywordsLower.includes(searchLower)) return 0.3
|
||||
const keywordWordsMatch = searchWords.every((word) => keywordsLower.includes(word))
|
||||
if (keywordWordsMatch && searchWords.length > 0) return 0.2
|
||||
}
|
||||
|
||||
return 0
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
@@ -253,7 +285,6 @@ export function SearchModal({
|
||||
<Command label='Search' filter={customFilter}>
|
||||
<Command.Input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
autoFocus
|
||||
onValueChange={handleSearchChange}
|
||||
placeholder='Search anything...'
|
||||
@@ -269,8 +300,7 @@ export function SearchModal({
|
||||
{blocks.map((block) => (
|
||||
<CommandItem
|
||||
key={block.id}
|
||||
value={block.name}
|
||||
keywords={[block.description]}
|
||||
value={`${block.name} block-${block.id}`}
|
||||
onSelect={() => handleBlockSelect(block, 'block')}
|
||||
icon={block.icon}
|
||||
bgColor={block.bgColor}
|
||||
@@ -287,8 +317,7 @@ export function SearchModal({
|
||||
{tools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
value={tool.name}
|
||||
keywords={[tool.description]}
|
||||
value={`${tool.name} tool-${tool.id}`}
|
||||
onSelect={() => handleBlockSelect(tool, 'tool')}
|
||||
icon={tool.icon}
|
||||
bgColor={tool.bgColor}
|
||||
@@ -305,8 +334,7 @@ export function SearchModal({
|
||||
{triggers.map((trigger) => (
|
||||
<CommandItem
|
||||
key={trigger.id}
|
||||
value={trigger.name}
|
||||
keywords={[trigger.description]}
|
||||
value={`${trigger.name} trigger-${trigger.id}`}
|
||||
onSelect={() => handleBlockSelect(trigger, 'trigger')}
|
||||
icon={trigger.icon}
|
||||
bgColor={trigger.bgColor}
|
||||
@@ -323,7 +351,7 @@ export function SearchModal({
|
||||
{workflows.map((workflow) => (
|
||||
<Command.Item
|
||||
key={workflow.id}
|
||||
value={workflow.name}
|
||||
value={`${workflow.name} workflow-${workflow.id}`}
|
||||
onSelect={() => handleWorkflowSelect(workflow)}
|
||||
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
|
||||
>
|
||||
@@ -345,8 +373,7 @@ export function SearchModal({
|
||||
{toolOperations.map((op) => (
|
||||
<CommandItem
|
||||
key={op.id}
|
||||
value={op.searchValue}
|
||||
keywords={op.keywords}
|
||||
value={`${op.searchValue} operation-${op.id}`}
|
||||
onSelect={() => handleToolOperationSelect(op)}
|
||||
icon={op.icon}
|
||||
bgColor={op.bgColor}
|
||||
@@ -363,7 +390,7 @@ export function SearchModal({
|
||||
{workspaces.map((workspace) => (
|
||||
<Command.Item
|
||||
key={workspace.id}
|
||||
value={workspace.name}
|
||||
value={`${workspace.name} workspace-${workspace.id}`}
|
||||
onSelect={() => handleWorkspaceSelect(workspace)}
|
||||
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
|
||||
>
|
||||
@@ -381,7 +408,7 @@ export function SearchModal({
|
||||
{docs.map((doc) => (
|
||||
<CommandItem
|
||||
key={doc.id}
|
||||
value={`${doc.name} docs documentation`}
|
||||
value={`${doc.name} docs documentation doc-${doc.id}`}
|
||||
onSelect={() => handleDocSelect(doc)}
|
||||
icon={doc.icon}
|
||||
bgColor='#6B7280'
|
||||
@@ -400,7 +427,7 @@ export function SearchModal({
|
||||
return (
|
||||
<Command.Item
|
||||
key={page.id}
|
||||
value={page.name}
|
||||
value={`${page.name} page-${page.id}`}
|
||||
onSelect={() => handlePageSelect(page)}
|
||||
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
|
||||
>
|
||||
@@ -433,7 +460,6 @@ const groupHeadingClassName =
|
||||
|
||||
interface CommandItemProps {
|
||||
value: string
|
||||
keywords?: string[]
|
||||
onSelect: () => void
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
@@ -443,7 +469,6 @@ interface CommandItemProps {
|
||||
|
||||
function CommandItem({
|
||||
value,
|
||||
keywords,
|
||||
onSelect,
|
||||
icon: Icon,
|
||||
bgColor,
|
||||
@@ -453,7 +478,6 @@ function CommandItem({
|
||||
return (
|
||||
<Command.Item
|
||||
value={value}
|
||||
keywords={keywords}
|
||||
onSelect={onSelect}
|
||||
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
|
||||
>
|
||||
|
||||
@@ -164,7 +164,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
...prev,
|
||||
{
|
||||
email: normalized,
|
||||
permissionType: 'read',
|
||||
permissionType: 'admin',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
@@ -66,7 +66,10 @@ function generateSignature(secret: string, timestamp: number, body: string): str
|
||||
async function buildPayload(
|
||||
log: WorkflowExecutionLog,
|
||||
subscription: typeof workspaceNotificationSubscription.$inferSelect
|
||||
): Promise<NotificationPayload> {
|
||||
): Promise<NotificationPayload | null> {
|
||||
// Skip notifications for deleted workflows
|
||||
if (!log.workflowId) return null
|
||||
|
||||
const workflowData = await db
|
||||
.select({ name: workflowTable.name, userId: workflowTable.userId })
|
||||
.from(workflowTable)
|
||||
@@ -526,6 +529,13 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
|
||||
const attempts = claimed[0].attempts
|
||||
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 }
|
||||
|
||||
switch (notificationType) {
|
||||
|
||||
@@ -80,6 +80,15 @@ Example:
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
title: 'Timeout (ms)',
|
||||
type: 'short-input',
|
||||
placeholder: '300000',
|
||||
description:
|
||||
'Request timeout in milliseconds (default: 300000 = 5 minutes, max: 600000 = 10 minutes)',
|
||||
mode: 'advanced',
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['http_request'],
|
||||
@@ -90,6 +99,7 @@ Example:
|
||||
headers: { type: 'json', description: 'Request headers' },
|
||||
body: { type: 'json', description: 'Request body data' },
|
||||
params: { type: 'json', description: 'URL query parameters' },
|
||||
timeout: { type: 'number', description: 'Request timeout in milliseconds' },
|
||||
},
|
||||
outputs: {
|
||||
data: { type: 'json', description: 'API response data (JSON, text, or other formats)' },
|
||||
|
||||
@@ -9,7 +9,7 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
|
||||
description: 'Interact with YouTube videos, channels, and playlists',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'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.',
|
||||
'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.',
|
||||
docsLink: 'https://docs.sim.ai/tools/youtube',
|
||||
category: 'tools',
|
||||
bgColor: '#FF0000',
|
||||
@@ -21,7 +21,9 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Search Videos', id: 'youtube_search' },
|
||||
{ label: 'Get Trending Videos', id: 'youtube_trending' },
|
||||
{ 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 Videos', id: 'youtube_channel_videos' },
|
||||
{ label: 'Get Channel Playlists', id: 'youtube_channel_playlists' },
|
||||
@@ -49,6 +51,13 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
|
||||
integer: true,
|
||||
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',
|
||||
title: 'Filter by Channel ID',
|
||||
@@ -56,6 +65,19 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
|
||||
placeholder: 'Filter results to a specific channel',
|
||||
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',
|
||||
title: 'Published After',
|
||||
@@ -131,7 +153,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
id: 'videoCategoryId',
|
||||
title: 'Category ID',
|
||||
type: 'short-input',
|
||||
placeholder: '10 for Music, 20 for Gaming',
|
||||
placeholder: 'Use Get Video Categories to find IDs',
|
||||
condition: { field: 'operation', value: 'youtube_search' },
|
||||
},
|
||||
{
|
||||
@@ -163,7 +185,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
title: 'Region Code',
|
||||
type: 'short-input',
|
||||
placeholder: 'US, GB, JP',
|
||||
condition: { field: 'operation', value: 'youtube_search' },
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['youtube_search', 'youtube_trending', 'youtube_video_categories'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'relevanceLanguage',
|
||||
@@ -184,6 +209,31 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
value: () => 'moderate',
|
||||
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
|
||||
{
|
||||
id: 'videoId',
|
||||
@@ -193,6 +243,14 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
required: true,
|
||||
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
|
||||
{
|
||||
id: 'channelId',
|
||||
@@ -241,6 +299,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
value: () => 'date',
|
||||
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
|
||||
{
|
||||
id: 'channelId',
|
||||
@@ -260,6 +325,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
integer: true,
|
||||
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
|
||||
{
|
||||
id: 'playlistId',
|
||||
@@ -279,6 +351,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
integer: true,
|
||||
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
|
||||
{
|
||||
id: 'videoId',
|
||||
@@ -309,6 +388,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
value: () => 'relevance',
|
||||
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)
|
||||
{
|
||||
id: 'apiKey',
|
||||
@@ -321,13 +407,15 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'youtube_search',
|
||||
'youtube_video_details',
|
||||
'youtube_channel_info',
|
||||
'youtube_channel_videos',
|
||||
'youtube_channel_playlists',
|
||||
'youtube_playlist_items',
|
||||
'youtube_channel_videos',
|
||||
'youtube_comments',
|
||||
'youtube_playlist_items',
|
||||
'youtube_search',
|
||||
'youtube_trending',
|
||||
'youtube_video_categories',
|
||||
'youtube_video_details',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -339,8 +427,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
switch (params.operation) {
|
||||
case 'youtube_search':
|
||||
return 'youtube_search'
|
||||
case 'youtube_trending':
|
||||
return 'youtube_trending'
|
||||
case 'youtube_video_details':
|
||||
return 'youtube_video_details'
|
||||
case 'youtube_video_categories':
|
||||
return 'youtube_video_categories'
|
||||
case 'youtube_channel_info':
|
||||
return 'youtube_channel_info'
|
||||
case 'youtube_channel_videos':
|
||||
@@ -363,6 +455,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
// Search Videos
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
maxResults: { type: 'number', description: 'Maximum number of results' },
|
||||
pageToken: { type: 'string', description: 'Page token for pagination' },
|
||||
// Search Filters
|
||||
publishedAfter: { type: 'string', description: 'Published after date (RFC 3339)' },
|
||||
publishedBefore: { type: 'string', description: 'Published before date (RFC 3339)' },
|
||||
@@ -370,9 +463,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
videoCategoryId: { type: 'string', description: 'YouTube category ID' },
|
||||
videoDefinition: { type: 'string', description: 'Video quality 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)' },
|
||||
relevanceLanguage: { type: 'string', description: 'Language code (ISO 639-1)' },
|
||||
safeSearch: { type: 'string', description: 'Safe search level' },
|
||||
hl: { type: 'string', description: 'Language for category names' },
|
||||
// Video Details & Comments
|
||||
videoId: { type: 'string', description: 'YouTube video ID' },
|
||||
// Channel Info
|
||||
@@ -384,7 +479,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
order: { type: 'string', description: 'Sort order' },
|
||||
},
|
||||
outputs: {
|
||||
// Search Videos & Playlist Items
|
||||
// Search Videos, Trending, Playlist Items, Captions, Categories
|
||||
items: { type: 'json', description: 'List of items returned' },
|
||||
totalResults: { type: 'number', description: 'Total number of results' },
|
||||
nextPageToken: { type: 'string', description: 'Token for next page' },
|
||||
@@ -399,11 +494,33 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
viewCount: { type: 'number', description: 'View count' },
|
||||
likeCount: { type: 'number', description: 'Like count' },
|
||||
commentCount: { type: 'number', description: 'Comment count' },
|
||||
favoriteCount: { type: 'number', description: 'Favorite count' },
|
||||
thumbnail: { type: 'string', description: 'Thumbnail URL' },
|
||||
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
|
||||
subscriberCount: { type: 'number', description: 'Subscriber count' },
|
||||
videoCount: { type: 'number', description: 'Total video count' },
|
||||
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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
.code-editor-theme .token.char,
|
||||
.code-editor-theme .token.builtin,
|
||||
.code-editor-theme .token.inserted {
|
||||
color: #dc2626 !important;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.code-editor-theme .token.operator,
|
||||
@@ -49,7 +49,7 @@
|
||||
.code-editor-theme .token.atrule,
|
||||
.code-editor-theme .token.attr-value,
|
||||
.code-editor-theme .token.keyword {
|
||||
color: #2563eb !important;
|
||||
color: #2f55ff !important;
|
||||
}
|
||||
|
||||
.code-editor-theme .token.function,
|
||||
@@ -119,7 +119,7 @@
|
||||
.dark .code-editor-theme .token.atrule,
|
||||
.dark .code-editor-theme .token.attr-value,
|
||||
.dark .code-editor-theme .token.keyword {
|
||||
color: #4db8ff !important;
|
||||
color: #2fa1ff !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.function,
|
||||
|
||||
@@ -21,7 +21,7 @@ export { Loader } from './loader'
|
||||
export { MoreHorizontal } from './more-horizontal'
|
||||
export { NoWrap } from './no-wrap'
|
||||
export { PanelLeft } from './panel-left'
|
||||
export { Play } from './play'
|
||||
export { Play, PlayOutline } from './play'
|
||||
export { Redo } from './redo'
|
||||
export { Rocket } from './rocket'
|
||||
export { Trash } from './trash'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Play icon component
|
||||
* Play icon component (filled/solid version)
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function Play(props: SVGProps<SVGSVGElement>) {
|
||||
@@ -21,3 +21,27 @@ export function Play(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play icon component (stroke/outline version, matches lucide style)
|
||||
* Uses 24x24 viewBox and strokeWidth 2 for consistency with other icons.
|
||||
* @param props - SVG properties including className, stroke, etc.
|
||||
*/
|
||||
export function PlayOutline(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M14.7175 4.07175C16.6036 5.37051 18.0001 6.39111 19.0000 7.32600C20.0087 8.26733 20.9617 9.25123 21.3031 10.5484C21.5534 11.4996 21.5534 12.5003 21.3031 13.4515C20.9617 14.7487 20.0087 15.7326 19.0000 16.6739C18.0001 17.6088 16.6037 18.6294 14.7176 19.9281C12.9093 21.1827 11.0470 22.2407 9.6333 22.8420C8.2082 23.4482 6.9090 23.7554 5.6463 23.3976C4.6383 23.1346 3.7940 22.6355 3.1138 21.9492C2.1907 21.0179 1.9001 19.7306 1.7248 18.1814C1.5507 16.6436 1.5507 14.6305 1.5508 12.0701V11.9298C1.5507 9.36936 1.5507 7.35626 1.7248 5.81844C1.9001 4.26926 2.1907 2.982 3.1138 2.05063C3.7940 1.36438 4.6383 0.865267 5.6463 0.602306C6.9090 0.244489 8.2082 0.551707 9.6333 1.15785C11.0470 1.75916 12.9092 2.81712 14.7175 4.07175Z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ export interface DAG {
|
||||
parallelConfigs: Map<string, SerializedParallel>
|
||||
}
|
||||
|
||||
export interface DAGBuildOptions {
|
||||
/** Trigger block ID to start path construction from */
|
||||
triggerBlockId?: string
|
||||
/** Saved incoming edges from snapshot for resumption */
|
||||
savedIncomingEdges?: Record<string, string[]>
|
||||
/** Include all enabled blocks instead of only those reachable from trigger */
|
||||
includeAllBlocks?: boolean
|
||||
}
|
||||
|
||||
export class DAGBuilder {
|
||||
private pathConstructor = new PathConstructor()
|
||||
private loopConstructor = new LoopConstructor()
|
||||
@@ -40,11 +49,9 @@ export class DAGBuilder {
|
||||
private nodeConstructor = new NodeConstructor()
|
||||
private edgeConstructor = new EdgeConstructor()
|
||||
|
||||
build(
|
||||
workflow: SerializedWorkflow,
|
||||
triggerBlockId?: string,
|
||||
savedIncomingEdges?: Record<string, string[]>
|
||||
): DAG {
|
||||
build(workflow: SerializedWorkflow, options: DAGBuildOptions = {}): DAG {
|
||||
const { triggerBlockId, savedIncomingEdges, includeAllBlocks } = options
|
||||
|
||||
const dag: DAG = {
|
||||
nodes: new Map(),
|
||||
loopConfigs: new Map(),
|
||||
@@ -53,7 +60,7 @@ export class DAGBuilder {
|
||||
|
||||
this.initializeConfigs(workflow, dag)
|
||||
|
||||
const reachableBlocks = this.pathConstructor.execute(workflow, triggerBlockId)
|
||||
const reachableBlocks = this.pathConstructor.execute(workflow, triggerBlockId, includeAllBlocks)
|
||||
|
||||
this.loopConstructor.execute(dag, reachableBlocks)
|
||||
this.parallelConstructor.execute(dag, reachableBlocks)
|
||||
|
||||
@@ -6,7 +6,16 @@ import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
const logger = createLogger('PathConstructor')
|
||||
|
||||
export class PathConstructor {
|
||||
execute(workflow: SerializedWorkflow, triggerBlockId?: string): Set<string> {
|
||||
execute(
|
||||
workflow: SerializedWorkflow,
|
||||
triggerBlockId?: string,
|
||||
includeAllBlocks?: boolean
|
||||
): Set<string> {
|
||||
// For run-from-block mode, include all enabled blocks regardless of trigger reachability
|
||||
if (includeAllBlocks) {
|
||||
return this.getAllEnabledBlocks(workflow)
|
||||
}
|
||||
|
||||
const resolvedTriggerId = this.findTriggerBlock(workflow, triggerBlockId)
|
||||
|
||||
if (!resolvedTriggerId) {
|
||||
|
||||
@@ -6,6 +6,7 @@ interface ChildWorkflowErrorOptions {
|
||||
childWorkflowName: string
|
||||
childTraceSpans?: TraceSpan[]
|
||||
executionResult?: ExecutionResult
|
||||
childWorkflowSnapshotId?: string
|
||||
cause?: Error
|
||||
}
|
||||
|
||||
@@ -16,6 +17,7 @@ export class ChildWorkflowError extends Error {
|
||||
readonly childTraceSpans: TraceSpan[]
|
||||
readonly childWorkflowName: string
|
||||
readonly executionResult?: ExecutionResult
|
||||
readonly childWorkflowSnapshotId?: string
|
||||
|
||||
constructor(options: ChildWorkflowErrorOptions) {
|
||||
super(options.message, { cause: options.cause })
|
||||
@@ -23,6 +25,7 @@ export class ChildWorkflowError extends Error {
|
||||
this.childWorkflowName = options.childWorkflowName
|
||||
this.childTraceSpans = options.childTraceSpans ?? []
|
||||
this.executionResult = options.executionResult
|
||||
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
|
||||
}
|
||||
|
||||
static isChildWorkflowError(error: unknown): error is ChildWorkflowError {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
containsUserFileWithMetadata,
|
||||
hydrateUserFilesWithBase64,
|
||||
} from '@/lib/uploads/utils/user-file-base64.server'
|
||||
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
|
||||
import {
|
||||
BlockType,
|
||||
buildResumeApiUrl,
|
||||
@@ -34,6 +35,7 @@ import { validateBlockType } from '@/executor/utils/permission-check'
|
||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('BlockExecutor')
|
||||
|
||||
@@ -87,7 +89,7 @@ export class BlockExecutor {
|
||||
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
|
||||
|
||||
if (blockLog) {
|
||||
blockLog.input = this.parseJsonInputs(resolvedInputs)
|
||||
blockLog.input = this.sanitizeInputsForLog(resolvedInputs)
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupSelfReference?.()
|
||||
@@ -150,6 +152,9 @@ export class BlockExecutor {
|
||||
blockLog.durationMs = duration
|
||||
blockLog.success = true
|
||||
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
|
||||
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
|
||||
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
|
||||
}
|
||||
}
|
||||
|
||||
this.state.setBlockOutput(node.id, normalizedOutput, duration)
|
||||
@@ -162,7 +167,7 @@ export class BlockExecutor {
|
||||
ctx,
|
||||
node,
|
||||
block,
|
||||
this.parseJsonInputs(resolvedInputs),
|
||||
this.sanitizeInputsForLog(resolvedInputs),
|
||||
displayOutput,
|
||||
duration
|
||||
)
|
||||
@@ -232,6 +237,9 @@ export class BlockExecutor {
|
||||
if (ChildWorkflowError.isChildWorkflowError(error)) {
|
||||
errorOutput.childTraceSpans = error.childTraceSpans
|
||||
errorOutput.childWorkflowName = error.childWorkflowName
|
||||
if (error.childWorkflowSnapshotId) {
|
||||
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
|
||||
}
|
||||
}
|
||||
|
||||
this.state.setBlockOutput(node.id, errorOutput, duration)
|
||||
@@ -241,8 +249,12 @@ export class BlockExecutor {
|
||||
blockLog.durationMs = duration
|
||||
blockLog.success = false
|
||||
blockLog.error = errorMessage
|
||||
blockLog.input = this.parseJsonInputs(input)
|
||||
blockLog.input = this.sanitizeInputsForLog(input)
|
||||
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
|
||||
|
||||
if (errorOutput.childTraceSpans && Array.isArray(errorOutput.childTraceSpans)) {
|
||||
blockLog.childTraceSpans = errorOutput.childTraceSpans
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
@@ -260,7 +272,7 @@ export class BlockExecutor {
|
||||
ctx,
|
||||
node,
|
||||
block,
|
||||
this.parseJsonInputs(input),
|
||||
this.sanitizeInputsForLog(input),
|
||||
displayOutput,
|
||||
duration
|
||||
)
|
||||
@@ -352,29 +364,41 @@ export class BlockExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON string inputs to objects for log display only.
|
||||
* Attempts to parse any string that looks like JSON.
|
||||
* Sanitizes inputs for log display.
|
||||
* - Filters out system fields (UI-only, readonly, internal flags)
|
||||
* - Removes UI state from inputFormat items (e.g., collapsed)
|
||||
* - Parses JSON strings to objects for readability
|
||||
* Returns a new object - does not mutate the original inputs.
|
||||
*/
|
||||
private parseJsonInputs(inputs: Record<string, any>): Record<string, any> {
|
||||
let result = inputs
|
||||
let hasChanges = false
|
||||
private sanitizeInputsForLog(inputs: Record<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
// isJSONString is a quick heuristic (checks for { or [), not a validator.
|
||||
// Invalid JSON is safely caught below - this just avoids JSON.parse on every string.
|
||||
if (typeof value !== 'string' || !isJSONString(value)) {
|
||||
if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (!hasChanges) {
|
||||
result = { ...inputs }
|
||||
hasChanges = true
|
||||
if (key === 'inputFormat' && Array.isArray(value)) {
|
||||
result[key] = sanitizeInputFormat(value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'tools' && Array.isArray(value)) {
|
||||
result[key] = sanitizeTools(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// isJSONString is a quick heuristic (checks for { or [), not a validator.
|
||||
// Invalid JSON is safely caught below - this just avoids JSON.parse on every string.
|
||||
if (typeof value === 'string' && isJSONString(value)) {
|
||||
try {
|
||||
result[key] = JSON.parse(value.trim())
|
||||
} catch {
|
||||
// Not valid JSON, keep original string
|
||||
result[key] = value
|
||||
}
|
||||
result[key] = JSON.parse(value.trim())
|
||||
} catch {
|
||||
// Not valid JSON, keep original string
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ExecutionEngine {
|
||||
private allowResumeTriggers: boolean
|
||||
private cancelledFlag = false
|
||||
private errorFlag = false
|
||||
private stoppedEarlyFlag = false
|
||||
private executionError: Error | null = null
|
||||
private lastCancellationCheck = 0
|
||||
private readonly useRedisCancellation: boolean
|
||||
@@ -105,7 +106,7 @@ export class ExecutionEngine {
|
||||
this.initializeQueue(triggerBlockId)
|
||||
|
||||
while (this.hasWork()) {
|
||||
if ((await this.checkCancellation()) || this.errorFlag) {
|
||||
if ((await this.checkCancellation()) || this.errorFlag || this.stoppedEarlyFlag) {
|
||||
break
|
||||
}
|
||||
await this.processQueue()
|
||||
@@ -259,6 +260,16 @@ export class ExecutionEngine {
|
||||
}
|
||||
|
||||
private initializeQueue(triggerBlockId?: string): void {
|
||||
if (this.context.runFromBlockContext) {
|
||||
const { startBlockId } = this.context.runFromBlockContext
|
||||
logger.info('Initializing queue for run-from-block mode', {
|
||||
startBlockId,
|
||||
dirtySetSize: this.context.runFromBlockContext.dirtySet.size,
|
||||
})
|
||||
this.addToQueue(startBlockId)
|
||||
return
|
||||
}
|
||||
|
||||
const pendingBlocks = this.context.metadata.pendingBlocks
|
||||
const remainingEdges = (this.context.metadata as any).remainingEdges
|
||||
|
||||
@@ -385,6 +396,17 @@ export class ExecutionEngine {
|
||||
this.finalOutput = output
|
||||
}
|
||||
|
||||
if (this.context.stopAfterBlockId === nodeId) {
|
||||
// For loop/parallel sentinels, only stop if the subflow has fully exited (all iterations done)
|
||||
// shouldContinue: true means more iterations, shouldExit: true means loop is done
|
||||
const shouldContinueLoop = output.shouldContinue === true
|
||||
if (!shouldContinueLoop) {
|
||||
logger.info('Stopping execution after target block', { nodeId })
|
||||
this.stoppedEarlyFlag = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false)
|
||||
|
||||
logger.info('Processing outgoing edges', {
|
||||
|
||||
@@ -5,17 +5,31 @@ import { BlockExecutor } from '@/executor/execution/block-executor'
|
||||
import { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
import { ExecutionEngine } from '@/executor/execution/engine'
|
||||
import { ExecutionState } from '@/executor/execution/state'
|
||||
import type { ContextExtensions, WorkflowInput } from '@/executor/execution/types'
|
||||
import type {
|
||||
ContextExtensions,
|
||||
SerializableExecutionState,
|
||||
WorkflowInput,
|
||||
} from '@/executor/execution/types'
|
||||
import { createBlockHandlers } from '@/executor/handlers/registry'
|
||||
import { LoopOrchestrator } from '@/executor/orchestrators/loop'
|
||||
import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
|
||||
import { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
|
||||
import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types'
|
||||
import {
|
||||
computeExecutionSets,
|
||||
type RunFromBlockContext,
|
||||
resolveContainerToSentinelStart,
|
||||
validateRunFromBlock,
|
||||
} from '@/executor/utils/run-from-block'
|
||||
import {
|
||||
buildResolutionFromBlock,
|
||||
buildStartBlockOutput,
|
||||
resolveExecutorStartBlock,
|
||||
} from '@/executor/utils/start-block'
|
||||
import {
|
||||
extractLoopIdFromSentinel,
|
||||
extractParallelIdFromSentinel,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
@@ -48,7 +62,10 @@ export class DAGExecutor {
|
||||
|
||||
async execute(workflowId: string, triggerBlockId?: string): Promise<ExecutionResult> {
|
||||
const savedIncomingEdges = this.contextExtensions.dagIncomingEdges
|
||||
const dag = this.dagBuilder.build(this.workflow, triggerBlockId, savedIncomingEdges)
|
||||
const dag = this.dagBuilder.build(this.workflow, {
|
||||
triggerBlockId,
|
||||
savedIncomingEdges,
|
||||
})
|
||||
const { context, state } = this.createExecutionContext(workflowId, triggerBlockId)
|
||||
|
||||
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
|
||||
@@ -89,17 +106,156 @@ export class DAGExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute from a specific block using cached outputs for upstream blocks.
|
||||
*/
|
||||
async executeFromBlock(
|
||||
workflowId: string,
|
||||
startBlockId: string,
|
||||
sourceSnapshot: SerializableExecutionState
|
||||
): Promise<ExecutionResult> {
|
||||
// Build full DAG with all blocks to compute upstream set for snapshot filtering
|
||||
// includeAllBlocks is needed because the startBlockId might be a trigger not reachable from the main trigger
|
||||
const dag = this.dagBuilder.build(this.workflow, { includeAllBlocks: true })
|
||||
|
||||
const executedBlocks = new Set(sourceSnapshot.executedBlocks)
|
||||
const validation = validateRunFromBlock(startBlockId, dag, executedBlocks)
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error)
|
||||
}
|
||||
|
||||
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, startBlockId)
|
||||
const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId
|
||||
|
||||
// Extract container IDs from sentinel IDs in reachable upstream set
|
||||
// Use reachableUpstreamSet (not upstreamSet) to preserve sibling branch outputs
|
||||
// Example: A->C, B->C where C references A.result || B.result
|
||||
// When running from A, B's output should be preserved for C to reference
|
||||
const reachableContainerIds = new Set<string>()
|
||||
for (const nodeId of reachableUpstreamSet) {
|
||||
const loopId = extractLoopIdFromSentinel(nodeId)
|
||||
if (loopId) reachableContainerIds.add(loopId)
|
||||
const parallelId = extractParallelIdFromSentinel(nodeId)
|
||||
if (parallelId) reachableContainerIds.add(parallelId)
|
||||
}
|
||||
|
||||
// Filter snapshot to include all blocks reachable from dirty blocks
|
||||
// This preserves sibling branch outputs that dirty blocks may reference
|
||||
const filteredBlockStates: Record<string, any> = {}
|
||||
for (const [blockId, state] of Object.entries(sourceSnapshot.blockStates)) {
|
||||
if (reachableUpstreamSet.has(blockId) || reachableContainerIds.has(blockId)) {
|
||||
filteredBlockStates[blockId] = state
|
||||
}
|
||||
}
|
||||
const filteredExecutedBlocks = sourceSnapshot.executedBlocks.filter(
|
||||
(id) => reachableUpstreamSet.has(id) || reachableContainerIds.has(id)
|
||||
)
|
||||
|
||||
// Filter loop/parallel executions to only include reachable containers
|
||||
const filteredLoopExecutions: Record<string, any> = {}
|
||||
if (sourceSnapshot.loopExecutions) {
|
||||
for (const [loopId, execution] of Object.entries(sourceSnapshot.loopExecutions)) {
|
||||
if (reachableContainerIds.has(loopId)) {
|
||||
filteredLoopExecutions[loopId] = execution
|
||||
}
|
||||
}
|
||||
}
|
||||
const filteredParallelExecutions: Record<string, any> = {}
|
||||
if (sourceSnapshot.parallelExecutions) {
|
||||
for (const [parallelId, execution] of Object.entries(sourceSnapshot.parallelExecutions)) {
|
||||
if (reachableContainerIds.has(parallelId)) {
|
||||
filteredParallelExecutions[parallelId] = execution
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSnapshot: SerializableExecutionState = {
|
||||
...sourceSnapshot,
|
||||
blockStates: filteredBlockStates,
|
||||
executedBlocks: filteredExecutedBlocks,
|
||||
loopExecutions: filteredLoopExecutions,
|
||||
parallelExecutions: filteredParallelExecutions,
|
||||
}
|
||||
|
||||
logger.info('Executing from block', {
|
||||
workflowId,
|
||||
startBlockId,
|
||||
effectiveStartBlockId,
|
||||
dirtySetSize: dirtySet.size,
|
||||
upstreamSetSize: upstreamSet.size,
|
||||
reachableUpstreamSetSize: reachableUpstreamSet.size,
|
||||
})
|
||||
|
||||
// Remove incoming edges from non-dirty sources so convergent blocks don't wait for cached upstream
|
||||
for (const nodeId of dirtySet) {
|
||||
const node = dag.nodes.get(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
const nonDirtyIncoming: string[] = []
|
||||
for (const sourceId of node.incomingEdges) {
|
||||
if (!dirtySet.has(sourceId)) {
|
||||
nonDirtyIncoming.push(sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourceId of nonDirtyIncoming) {
|
||||
node.incomingEdges.delete(sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
const runFromBlockContext = { startBlockId: effectiveStartBlockId, dirtySet }
|
||||
const { context, state } = this.createExecutionContext(workflowId, undefined, {
|
||||
snapshotState: filteredSnapshot,
|
||||
runFromBlockContext,
|
||||
})
|
||||
|
||||
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
|
||||
const loopOrchestrator = new LoopOrchestrator(dag, state, resolver)
|
||||
loopOrchestrator.setContextExtensions(this.contextExtensions)
|
||||
const parallelOrchestrator = new ParallelOrchestrator(dag, state)
|
||||
parallelOrchestrator.setResolver(resolver)
|
||||
parallelOrchestrator.setContextExtensions(this.contextExtensions)
|
||||
const allHandlers = createBlockHandlers()
|
||||
const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state)
|
||||
const edgeManager = new EdgeManager(dag)
|
||||
loopOrchestrator.setEdgeManager(edgeManager)
|
||||
const nodeOrchestrator = new NodeExecutionOrchestrator(
|
||||
dag,
|
||||
state,
|
||||
blockExecutor,
|
||||
loopOrchestrator,
|
||||
parallelOrchestrator
|
||||
)
|
||||
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
|
||||
|
||||
return await engine.run()
|
||||
}
|
||||
|
||||
private createExecutionContext(
|
||||
workflowId: string,
|
||||
triggerBlockId?: string
|
||||
triggerBlockId?: string,
|
||||
overrides?: {
|
||||
snapshotState?: SerializableExecutionState
|
||||
runFromBlockContext?: RunFromBlockContext
|
||||
}
|
||||
): { context: ExecutionContext; state: ExecutionState } {
|
||||
const snapshotState = this.contextExtensions.snapshotState
|
||||
const snapshotState = overrides?.snapshotState ?? this.contextExtensions.snapshotState
|
||||
const blockStates = snapshotState?.blockStates
|
||||
? new Map(Object.entries(snapshotState.blockStates))
|
||||
: new Map<string, BlockState>()
|
||||
const executedBlocks = snapshotState?.executedBlocks
|
||||
let executedBlocks = snapshotState?.executedBlocks
|
||||
? new Set(snapshotState.executedBlocks)
|
||||
: new Set<string>()
|
||||
|
||||
if (overrides?.runFromBlockContext) {
|
||||
const { dirtySet } = overrides.runFromBlockContext
|
||||
executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id)))
|
||||
logger.info('Cleared executed status for dirty blocks', {
|
||||
dirtySetSize: dirtySet.size,
|
||||
remainingExecutedBlocks: executedBlocks.size,
|
||||
})
|
||||
}
|
||||
|
||||
const state = new ExecutionState(blockStates, executedBlocks)
|
||||
|
||||
const context: ExecutionContext = {
|
||||
@@ -109,7 +265,7 @@ export class DAGExecutor {
|
||||
userId: this.contextExtensions.userId,
|
||||
isDeployedContext: this.contextExtensions.isDeployedContext,
|
||||
blockStates: state.getBlockStates(),
|
||||
blockLogs: snapshotState?.blockLogs ?? [],
|
||||
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
|
||||
metadata: {
|
||||
...this.contextExtensions.metadata,
|
||||
startTime: new Date().toISOString(),
|
||||
@@ -169,6 +325,8 @@ export class DAGExecutor {
|
||||
abortSignal: this.contextExtensions.abortSignal,
|
||||
includeFileBase64: this.contextExtensions.includeFileBase64,
|
||||
base64MaxBytes: this.contextExtensions.base64MaxBytes,
|
||||
runFromBlockContext: overrides?.runFromBlockContext,
|
||||
stopAfterBlockId: this.contextExtensions.stopAfterBlockId,
|
||||
}
|
||||
|
||||
if (this.contextExtensions.resumeFromSnapshot) {
|
||||
@@ -193,6 +351,15 @@ export class DAGExecutor {
|
||||
pendingBlocks: context.metadata.pendingBlocks,
|
||||
skipStarterBlockInit: true,
|
||||
})
|
||||
} else if (overrides?.runFromBlockContext) {
|
||||
// In run-from-block mode, initialize the start block only if it's a regular block
|
||||
// Skip for sentinels/containers (loop/parallel) which aren't real blocks
|
||||
const startBlockId = overrides.runFromBlockContext.startBlockId
|
||||
const isRegularBlock = this.workflow.blocks.some((b) => b.id === startBlockId)
|
||||
|
||||
if (isRegularBlock) {
|
||||
this.initializeStarterBlock(context, state, startBlockId)
|
||||
}
|
||||
} else {
|
||||
this.initializeStarterBlock(context, state, triggerBlockId)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockLog, BlockState, NormalizedBlockOutput } from '@/executor/types'
|
||||
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export interface ExecutionMetadata {
|
||||
@@ -105,6 +106,17 @@ export interface ContextExtensions {
|
||||
output: { input?: any; output: NormalizedBlockOutput; executionTime: number },
|
||||
iterationContext?: IterationContext
|
||||
) => Promise<void>
|
||||
|
||||
/**
|
||||
* Run-from-block configuration. When provided, executor runs in partial
|
||||
* execution mode starting from the specified block.
|
||||
*/
|
||||
runFromBlockContext?: RunFromBlockContext
|
||||
|
||||
/**
|
||||
* Stop execution after this block completes. Used for "run until block" feature.
|
||||
*/
|
||||
stopAfterBlockId?: string
|
||||
}
|
||||
|
||||
export interface WorkflowInput {
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
}
|
||||
|
||||
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
|
||||
'Error in child workflow "child-workflow-id": Maximum workflow nesting depth of 10 exceeded'
|
||||
'"child-workflow-id" failed: Maximum workflow nesting depth of 10 exceeded'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
'Error in child workflow "non-existent-workflow": Child workflow non-existent-workflow not found'
|
||||
'"non-existent-workflow" failed: Child workflow non-existent-workflow not found'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
'Error in child workflow "child-workflow-id": Network error'
|
||||
'"child-workflow-id" failed: Network error'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -198,6 +198,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
childWorkflowId: 'child-id',
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: { data: 'test result' },
|
||||
childTraceSpans: [],
|
||||
@@ -212,7 +213,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
|
||||
expect(() =>
|
||||
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
|
||||
).toThrow('Error in child workflow "Child Workflow": Child workflow failed')
|
||||
).toThrow('"Child Workflow" failed: Child workflow failed')
|
||||
|
||||
try {
|
||||
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
|
||||
@@ -235,6 +236,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
childWorkflowId: 'child-id',
|
||||
childWorkflowName: 'Child Workflow',
|
||||
result: { nested: 'data' },
|
||||
childTraceSpans: [],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
@@ -52,6 +53,12 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
throw new Error('No workflow selected for execution')
|
||||
}
|
||||
|
||||
// Initialize with registry name, will be updated with loaded workflow name
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
const workflowMetadata = workflows[workflowId]
|
||||
let childWorkflowName = workflowMetadata?.name || workflowId
|
||||
|
||||
let childWorkflowSnapshotId: string | undefined
|
||||
try {
|
||||
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
|
||||
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
|
||||
@@ -75,9 +82,8 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
throw new Error(`Child workflow ${workflowId} not found`)
|
||||
}
|
||||
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
const workflowMetadata = workflows[workflowId]
|
||||
const childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
|
||||
// Update with loaded workflow name (more reliable than registry)
|
||||
childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
|
||||
|
||||
logger.info(
|
||||
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
|
||||
@@ -103,6 +109,12 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
childWorkflowInput = inputs.input
|
||||
}
|
||||
|
||||
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
|
||||
workflowId,
|
||||
childWorkflow.workflowState
|
||||
)
|
||||
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
|
||||
|
||||
const subExecutor = new Executor({
|
||||
workflow: childWorkflow.serializedState,
|
||||
workflowInput: childWorkflowInput,
|
||||
@@ -135,18 +147,14 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
workflowId,
|
||||
childWorkflowName,
|
||||
duration,
|
||||
childTraceSpans
|
||||
childTraceSpans,
|
||||
childWorkflowSnapshotId
|
||||
)
|
||||
|
||||
return mappedResult
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Error executing child workflow ${workflowId}:`, error)
|
||||
|
||||
const { workflows } = useWorkflowRegistry.getState()
|
||||
const workflowMetadata = workflows[workflowId]
|
||||
const childWorkflowName = workflowMetadata?.name || workflowId
|
||||
|
||||
const originalError = error instanceof Error ? error.message : 'Unknown error'
|
||||
let childTraceSpans: WorkflowTraceSpan[] = []
|
||||
let executionResult: ExecutionResult | undefined
|
||||
|
||||
@@ -165,16 +173,86 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
childTraceSpans = error.childTraceSpans
|
||||
}
|
||||
|
||||
// Build a cleaner error message for nested workflow errors
|
||||
const errorMessage = this.buildNestedWorkflowErrorMessage(childWorkflowName, error)
|
||||
|
||||
throw new ChildWorkflowError({
|
||||
message: `Error in child workflow "${childWorkflowName}": ${originalError}`,
|
||||
message: errorMessage,
|
||||
childWorkflowName,
|
||||
childTraceSpans,
|
||||
executionResult,
|
||||
childWorkflowSnapshotId,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a cleaner error message for nested workflow errors.
|
||||
* Parses nested error messages to extract workflow chain and root error.
|
||||
*/
|
||||
private buildNestedWorkflowErrorMessage(childWorkflowName: string, error: unknown): string {
|
||||
const originalError = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
// Extract any nested workflow names from the error message
|
||||
const { chain, rootError } = this.parseNestedWorkflowError(originalError)
|
||||
|
||||
// Add current workflow to the beginning of the chain
|
||||
chain.unshift(childWorkflowName)
|
||||
|
||||
// If we have a chain (nested workflows), format nicely
|
||||
if (chain.length > 1) {
|
||||
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
|
||||
}
|
||||
|
||||
// Single workflow failure
|
||||
return `"${childWorkflowName}" failed: ${rootError}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a potentially nested workflow error message to extract:
|
||||
* - The chain of workflow names
|
||||
* - The actual root error message (preserving the block prefix for the failing block)
|
||||
*
|
||||
* Handles formats like:
|
||||
* - "workflow-name" failed: error
|
||||
* - [block_type] Block Name: "workflow-name" failed: error
|
||||
* - Workflow chain: A → B | error
|
||||
*/
|
||||
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
|
||||
const chain: string[] = []
|
||||
const remaining = message
|
||||
|
||||
// First, check if it's already in chain format
|
||||
const chainMatch = remaining.match(/^Workflow chain: (.+?) \| (.+)$/)
|
||||
if (chainMatch) {
|
||||
const chainPart = chainMatch[1]
|
||||
const errorPart = chainMatch[2]
|
||||
chain.push(...chainPart.split(' → ').map((s) => s.trim()))
|
||||
return { chain, rootError: errorPart }
|
||||
}
|
||||
|
||||
// Extract workflow names from patterns like:
|
||||
// - "workflow-name" failed:
|
||||
// - [block_type] Block Name: "workflow-name" failed:
|
||||
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
|
||||
let match: RegExpExecArray | null
|
||||
let lastIndex = 0
|
||||
|
||||
match = workflowPattern.exec(remaining)
|
||||
while (match !== null) {
|
||||
chain.push(match[1])
|
||||
lastIndex = match.index + match[0].length
|
||||
match = workflowPattern.exec(remaining)
|
||||
}
|
||||
|
||||
// The root error is everything after the last match
|
||||
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
|
||||
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
|
||||
|
||||
return { chain, rootError: rootError.trim() || 'Unknown error' }
|
||||
}
|
||||
|
||||
private async loadChildWorkflow(workflowId: string) {
|
||||
const headers = await buildAuthHeaders()
|
||||
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
|
||||
@@ -211,6 +289,10 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
)
|
||||
|
||||
const workflowVariables = (workflowData.variables as Record<string, any>) || {}
|
||||
const workflowStateWithVariables = {
|
||||
...workflowState,
|
||||
variables: workflowVariables,
|
||||
}
|
||||
|
||||
if (Object.keys(workflowVariables).length > 0) {
|
||||
logger.info(
|
||||
@@ -222,6 +304,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
name: workflowData.name,
|
||||
serializedState: serializedWorkflow,
|
||||
variables: workflowVariables,
|
||||
workflowState: workflowStateWithVariables,
|
||||
rawBlocks: workflowState.blocks,
|
||||
}
|
||||
}
|
||||
@@ -290,11 +373,16 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
)
|
||||
|
||||
const workflowVariables = (wfData?.variables as Record<string, any>) || {}
|
||||
const workflowStateWithVariables = {
|
||||
...deployedState,
|
||||
variables: workflowVariables,
|
||||
}
|
||||
|
||||
return {
|
||||
name: wfData?.name || DEFAULTS.WORKFLOW_NAME,
|
||||
serializedState: serializedWorkflow,
|
||||
variables: workflowVariables,
|
||||
workflowState: workflowStateWithVariables,
|
||||
rawBlocks: deployedState.blocks,
|
||||
}
|
||||
}
|
||||
@@ -436,7 +524,8 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
childWorkflowId: string,
|
||||
childWorkflowName: string,
|
||||
duration: number,
|
||||
childTraceSpans?: WorkflowTraceSpan[]
|
||||
childTraceSpans?: WorkflowTraceSpan[],
|
||||
childWorkflowSnapshotId?: string
|
||||
): BlockOutput {
|
||||
const success = childResult.success !== false
|
||||
const result = childResult.output || {}
|
||||
@@ -444,15 +533,18 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
if (!success) {
|
||||
logger.warn(`Child workflow ${childWorkflowName} failed`)
|
||||
throw new ChildWorkflowError({
|
||||
message: `Error in child workflow "${childWorkflowName}": ${childResult.error || 'Child workflow execution failed'}`,
|
||||
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
|
||||
childWorkflowName,
|
||||
childTraceSpans: childTraceSpans || [],
|
||||
childWorkflowSnapshotId,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
childWorkflowName,
|
||||
childWorkflowId,
|
||||
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
|
||||
result,
|
||||
childTraceSpans: childTraceSpans || [],
|
||||
} as Record<string, any>
|
||||
|
||||
@@ -276,7 +276,16 @@ export class LoopOrchestrator {
|
||||
scope: LoopScope
|
||||
): LoopContinuationResult {
|
||||
const results = scope.allIterationOutputs
|
||||
this.state.setBlockOutput(loopId, { results }, DEFAULTS.EXECUTION_TIME)
|
||||
const output = { results }
|
||||
this.state.setBlockOutput(loopId, output, DEFAULTS.EXECUTION_TIME)
|
||||
|
||||
// Emit onBlockComplete for the loop container so the UI can track it
|
||||
if (this.contextExtensions?.onBlockComplete) {
|
||||
this.contextExtensions.onBlockComplete(loopId, 'Loop', 'loop', {
|
||||
output,
|
||||
executionTime: DEFAULTS.EXECUTION_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
|
||||
@@ -31,7 +31,18 @@ export class NodeExecutionOrchestrator {
|
||||
throw new Error(`Node not found in DAG: ${nodeId}`)
|
||||
}
|
||||
|
||||
if (this.state.hasExecuted(nodeId)) {
|
||||
if (ctx.runFromBlockContext && !ctx.runFromBlockContext.dirtySet.has(nodeId)) {
|
||||
const cachedOutput = this.state.getBlockOutput(nodeId) || {}
|
||||
logger.debug('Skipping non-dirty block in run-from-block mode', { nodeId })
|
||||
return {
|
||||
nodeId,
|
||||
output: cachedOutput,
|
||||
isFinalOutput: false,
|
||||
}
|
||||
}
|
||||
|
||||
const isDirtyBlock = ctx.runFromBlockContext?.dirtySet.has(nodeId) ?? false
|
||||
if (!isDirtyBlock && this.state.hasExecuted(nodeId)) {
|
||||
const output = this.state.getBlockOutput(nodeId) || {}
|
||||
return {
|
||||
nodeId,
|
||||
|
||||
@@ -260,9 +260,17 @@ export class ParallelOrchestrator {
|
||||
const branchOutputs = scope.branchOutputs.get(i) || []
|
||||
results.push(branchOutputs)
|
||||
}
|
||||
this.state.setBlockOutput(parallelId, {
|
||||
results,
|
||||
})
|
||||
const output = { results }
|
||||
this.state.setBlockOutput(parallelId, output)
|
||||
|
||||
// Emit onBlockComplete for the parallel container so the UI can track it
|
||||
if (this.contextExtensions?.onBlockComplete) {
|
||||
this.contextExtensions.onBlockComplete(parallelId, 'Parallel', 'parallel', {
|
||||
output,
|
||||
executionTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
allBranchesComplete: true,
|
||||
results,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
export interface UserFile {
|
||||
@@ -113,6 +114,12 @@ export interface BlockLog {
|
||||
loopId?: string
|
||||
parallelId?: string
|
||||
iterationIndex?: number
|
||||
/**
|
||||
* Child workflow trace spans for nested workflow execution.
|
||||
* Stored separately from output to keep output clean for display
|
||||
* while preserving data for trace-spans processing.
|
||||
*/
|
||||
childTraceSpans?: TraceSpan[]
|
||||
}
|
||||
|
||||
export interface ExecutionMetadata {
|
||||
@@ -250,6 +257,17 @@ export interface ExecutionContext {
|
||||
* will not have their base64 content fetched.
|
||||
*/
|
||||
base64MaxBytes?: number
|
||||
|
||||
/**
|
||||
* Context for "run from block" mode. When present, only blocks in dirtySet
|
||||
* will be executed; others return cached outputs from the source snapshot.
|
||||
*/
|
||||
runFromBlockContext?: RunFromBlockContext
|
||||
|
||||
/**
|
||||
* Stop execution after this block completes. Used for "run until block" feature.
|
||||
*/
|
||||
stopAfterBlockId?: string
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isHiddenFromDisplay } from '@/blocks/types'
|
||||
import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
|
||||
@@ -7,6 +8,7 @@ import type { SerializedBlock } from '@/serializer/types'
|
||||
/**
|
||||
* Filters block output for logging/display purposes.
|
||||
* Removes internal fields and fields marked with hiddenFromDisplay.
|
||||
* Also recursively filters globally hidden keys from nested objects.
|
||||
*
|
||||
* @param blockType - The block type string (e.g., 'human_in_the_loop', 'workflow')
|
||||
* @param output - The raw block output to filter
|
||||
@@ -44,7 +46,8 @@ export function filterOutputForLog(
|
||||
continue
|
||||
}
|
||||
|
||||
filtered[key] = value
|
||||
// Recursively filter globally hidden keys from nested objects
|
||||
filtered[key] = filterHiddenOutputKeys(value)
|
||||
}
|
||||
|
||||
return filtered
|
||||
|
||||
1537
apps/sim/executor/utils/run-from-block.test.ts
Normal file
219
apps/sim/executor/utils/run-from-block.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { LOOP, PARALLEL } from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
|
||||
/**
|
||||
* Builds the sentinel-start node ID for a loop.
|
||||
*/
|
||||
function buildLoopSentinelStartId(loopId: string): string {
|
||||
return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.START_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the sentinel-start node ID for a parallel.
|
||||
*/
|
||||
function buildParallelSentinelStartId(parallelId: string): string {
|
||||
return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.START_SUFFIX}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block ID is a loop or parallel container and returns the sentinel-start ID if so.
|
||||
* Returns null if the block is not a container.
|
||||
*/
|
||||
export function resolveContainerToSentinelStart(blockId: string, dag: DAG): string | null {
|
||||
if (dag.loopConfigs.has(blockId)) {
|
||||
return buildLoopSentinelStartId(blockId)
|
||||
}
|
||||
if (dag.parallelConfigs.has(blockId)) {
|
||||
return buildParallelSentinelStartId(blockId)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of validating a block for run-from-block execution.
|
||||
*/
|
||||
export interface RunFromBlockValidation {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for run-from-block execution mode.
|
||||
*/
|
||||
export interface RunFromBlockContext {
|
||||
/** The block ID to start execution from */
|
||||
startBlockId: string
|
||||
/** Set of block IDs that need re-execution (start block + all downstream) */
|
||||
dirtySet: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of computing execution sets for run-from-block mode.
|
||||
*/
|
||||
export interface ExecutionSets {
|
||||
/** Blocks that need re-execution (start block + all downstream) */
|
||||
dirtySet: Set<string>
|
||||
/** Blocks that are upstream (ancestors) of the start block */
|
||||
upstreamSet: Set<string>
|
||||
/** Blocks that are upstream of any dirty block (for snapshot preservation) */
|
||||
reachableUpstreamSet: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the dirty set, upstream set, and reachable upstream set.
|
||||
* - Dirty set: start block + all blocks reachable via outgoing edges (need re-execution)
|
||||
* - Upstream set: all blocks reachable via incoming edges from the start block
|
||||
* - Reachable upstream set: all non-dirty blocks that are upstream of ANY dirty block
|
||||
* (includes sibling branches that dirty blocks may reference)
|
||||
*
|
||||
* For loop/parallel containers, starts from the sentinel-start node and includes
|
||||
* the container ID itself in the dirty set.
|
||||
*
|
||||
* @param dag - The workflow DAG
|
||||
* @param startBlockId - The block to start execution from
|
||||
* @returns Object containing dirtySet, upstreamSet, and reachableUpstreamSet
|
||||
*/
|
||||
export function computeExecutionSets(dag: DAG, startBlockId: string): ExecutionSets {
|
||||
const dirty = new Set<string>([startBlockId])
|
||||
const upstream = new Set<string>()
|
||||
const sentinelStartId = resolveContainerToSentinelStart(startBlockId, dag)
|
||||
const traversalStartId = sentinelStartId ?? startBlockId
|
||||
|
||||
if (sentinelStartId) {
|
||||
dirty.add(sentinelStartId)
|
||||
}
|
||||
|
||||
// BFS downstream for dirty set
|
||||
const downstreamQueue = [traversalStartId]
|
||||
while (downstreamQueue.length > 0) {
|
||||
const nodeId = downstreamQueue.shift()!
|
||||
const node = dag.nodes.get(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
for (const [, edge] of node.outgoingEdges) {
|
||||
if (!dirty.has(edge.target)) {
|
||||
dirty.add(edge.target)
|
||||
downstreamQueue.push(edge.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BFS upstream from start block for upstream set
|
||||
const upstreamQueue = [traversalStartId]
|
||||
while (upstreamQueue.length > 0) {
|
||||
const nodeId = upstreamQueue.shift()!
|
||||
const node = dag.nodes.get(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
for (const sourceId of node.incomingEdges) {
|
||||
if (!upstream.has(sourceId)) {
|
||||
upstream.add(sourceId)
|
||||
upstreamQueue.push(sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute reachable upstream: all non-dirty blocks upstream of ANY dirty block
|
||||
// This handles the case where a dirty block (like C in A->C, B->C) may reference
|
||||
// sibling branches (like B when running from A)
|
||||
const reachableUpstream = new Set<string>()
|
||||
for (const dirtyNodeId of dirty) {
|
||||
const node = dag.nodes.get(dirtyNodeId)
|
||||
if (!node) continue
|
||||
|
||||
// BFS upstream from this dirty node
|
||||
const queue = [...node.incomingEdges]
|
||||
while (queue.length > 0) {
|
||||
const sourceId = queue.shift()!
|
||||
if (reachableUpstream.has(sourceId) || dirty.has(sourceId)) continue
|
||||
|
||||
reachableUpstream.add(sourceId)
|
||||
const sourceNode = dag.nodes.get(sourceId)
|
||||
if (sourceNode) {
|
||||
queue.push(...sourceNode.incomingEdges)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dirtySet: dirty, upstreamSet: upstream, reachableUpstreamSet: reachableUpstream }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a block can be used as a run-from-block starting point.
|
||||
*
|
||||
* Validation rules:
|
||||
* - Block must exist in the DAG (or be a loop/parallel container)
|
||||
* - Block cannot be inside a loop (but loop containers are allowed)
|
||||
* - Block cannot be inside a parallel (but parallel containers are allowed)
|
||||
* - Block cannot be a sentinel node
|
||||
* - All upstream dependencies must have been executed (have cached outputs)
|
||||
*
|
||||
* @param blockId - The block ID to validate
|
||||
* @param dag - The workflow DAG
|
||||
* @param executedBlocks - Set of blocks that were executed in the source run
|
||||
* @returns Validation result with error message if invalid
|
||||
*/
|
||||
export function validateRunFromBlock(
|
||||
blockId: string,
|
||||
dag: DAG,
|
||||
executedBlocks: Set<string>
|
||||
): RunFromBlockValidation {
|
||||
const node = dag.nodes.get(blockId)
|
||||
const isLoopContainer = dag.loopConfigs.has(blockId)
|
||||
const isParallelContainer = dag.parallelConfigs.has(blockId)
|
||||
const isContainer = isLoopContainer || isParallelContainer
|
||||
|
||||
if (!node && !isContainer) {
|
||||
return { valid: false, error: `Block not found in workflow: ${blockId}` }
|
||||
}
|
||||
|
||||
if (isContainer) {
|
||||
const sentinelStartId = resolveContainerToSentinelStart(blockId, dag)
|
||||
if (!sentinelStartId || !dag.nodes.has(sentinelStartId)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Container sentinel not found for: ${blockId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node) {
|
||||
if (node.metadata.isLoopNode) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot run from block inside loop: ${node.metadata.loopId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.metadata.isParallelBranch) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot run from block inside parallel: ${node.metadata.parallelId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.metadata.isSentinel) {
|
||||
return { valid: false, error: 'Cannot run from sentinel node' }
|
||||
}
|
||||
|
||||
// Check immediate upstream dependencies were executed
|
||||
for (const sourceId of node.incomingEdges) {
|
||||
const sourceNode = dag.nodes.get(sourceId)
|
||||
// Skip sentinel nodes - they're internal and not in executedBlocks
|
||||
if (sourceNode?.metadata.isSentinel) continue
|
||||
|
||||
// Skip trigger nodes - they're entry points and don't need prior execution
|
||||
// A trigger node has no incoming edges
|
||||
if (sourceNode && sourceNode.incomingEdges.size === 0) continue
|
||||
|
||||
if (!executedBlocks.has(sourceId)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Upstream dependency not executed: ${sourceId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { fetchDeploymentVersionState } from './workflows'
|
||||
|
||||
const logger = createLogger('DeploymentQueries')
|
||||
|
||||
@@ -348,6 +349,173 @@ export function useUndeployWorkflow() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for update deployment version mutation
|
||||
*/
|
||||
interface UpdateDeploymentVersionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
name?: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from update deployment version mutation
|
||||
*/
|
||||
interface UpdateDeploymentVersionResult {
|
||||
name: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for updating a deployment version's name or description.
|
||||
* Invalidates versions query on success.
|
||||
*/
|
||||
export function useUpdateDeploymentVersion() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
version,
|
||||
name,
|
||||
description,
|
||||
}: UpdateDeploymentVersionVariables): Promise<UpdateDeploymentVersionResult> => {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, description }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update deployment version')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
logger.info('Deployment version updated', {
|
||||
workflowId: variables.workflowId,
|
||||
version: variables.version,
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.versions(variables.workflowId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update deployment version', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for generating a version description
|
||||
*/
|
||||
interface GenerateVersionDescriptionVariables {
|
||||
workflowId: string
|
||||
version: number
|
||||
onStreamChunk?: (accumulated: string) => void
|
||||
}
|
||||
|
||||
const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are a technical writer generating concise deployment version descriptions.
|
||||
|
||||
Given a diff of changes between two workflow versions, write a brief, factual description (1-2 sentences, under 300 characters) that states ONLY what changed.
|
||||
|
||||
RULES:
|
||||
- State specific values when provided (e.g. "model changed from X to Y")
|
||||
- Do NOT wrap your response in quotes
|
||||
- Do NOT add filler phrases like "streamlining the workflow", "for improved efficiency"
|
||||
- Do NOT use markdown formatting
|
||||
- Do NOT include version numbers
|
||||
- Do NOT start with "This version" or similar phrases
|
||||
|
||||
Good examples:
|
||||
- Changes model in Agent 1 from gpt-4o to claude-sonnet-4-20250514.
|
||||
- Adds Slack notification block. Updates webhook URL to production endpoint.
|
||||
- Removes Function block and its connection to Router.
|
||||
|
||||
Bad examples:
|
||||
- "Changes model..." (NO - don't wrap in quotes)
|
||||
- Changes model, streamlining the workflow. (NO - don't add filler)
|
||||
|
||||
Respond with ONLY the plain text description.`
|
||||
|
||||
/**
|
||||
* Hook for generating a version description using AI based on workflow diff
|
||||
*/
|
||||
export function useGenerateVersionDescription() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workflowId,
|
||||
version,
|
||||
onStreamChunk,
|
||||
}: GenerateVersionDescriptionVariables): Promise<string> => {
|
||||
const { generateWorkflowDiffSummary, formatDiffSummaryForDescription } = await import(
|
||||
'@/lib/workflows/comparison/compare'
|
||||
)
|
||||
|
||||
const currentState = await fetchDeploymentVersionState(workflowId, version)
|
||||
|
||||
let previousState = null
|
||||
if (version > 1) {
|
||||
try {
|
||||
previousState = await fetchDeploymentVersionState(workflowId, version - 1)
|
||||
} catch {
|
||||
// Previous version may not exist, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
const diffSummary = generateWorkflowDiffSummary(currentState, previousState)
|
||||
const diffText = formatDiffSummaryForDescription(diffSummary)
|
||||
|
||||
const wandResponse = await fetch('/api/wand', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: `Generate a deployment version description based on these changes:\n\n${diffText}`,
|
||||
systemPrompt: VERSION_DESCRIPTION_SYSTEM_PROMPT,
|
||||
stream: true,
|
||||
workflowId,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!wandResponse.ok) {
|
||||
const errorText = await wandResponse.text()
|
||||
throw new Error(errorText || 'Failed to generate description')
|
||||
}
|
||||
|
||||
if (!wandResponse.body) {
|
||||
throw new Error('Response body is null')
|
||||
}
|
||||
|
||||
const { readSSEStream } = await import('@/lib/core/utils/sse')
|
||||
const accumulatedContent = await readSSEStream(wandResponse.body, {
|
||||
onAccumulated: onStreamChunk,
|
||||
})
|
||||
|
||||
if (!accumulatedContent) {
|
||||
throw new Error('Failed to generate description')
|
||||
}
|
||||
|
||||
return accumulatedContent.trim()
|
||||
},
|
||||
onSuccess: (content) => {
|
||||
logger.info('Generated version description', { length: content.length })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to generate version description', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for activate version mutation
|
||||
*/
|
||||
|
||||
@@ -210,6 +210,7 @@ export interface ExecutionSnapshotData {
|
||||
executionId: string
|
||||
workflowId: string
|
||||
workflowState: Record<string, unknown>
|
||||
childWorkflowSnapshots?: Record<string, Record<string, unknown>>
|
||||
executionMetadata: {
|
||||
trigger: string
|
||||
startedAt: string
|
||||
|
||||
@@ -411,7 +411,11 @@ interface DeploymentVersionStateResponse {
|
||||
deployedState: WorkflowState
|
||||
}
|
||||
|
||||
async function fetchDeploymentVersionState(
|
||||
/**
|
||||
* Fetches the deployed state for a specific deployment version.
|
||||
* Exported for reuse in other query hooks.
|
||||
*/
|
||||
export async function fetchDeploymentVersionState(
|
||||
workflowId: string,
|
||||
version: number
|
||||
): Promise<WorkflowState> {
|
||||
|
||||
@@ -1,10 +1,85 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
import type { SubflowType } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('useExecutionStream')
|
||||
|
||||
/**
|
||||
* Processes SSE events from a response body and invokes appropriate callbacks.
|
||||
*/
|
||||
async function processSSEStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
callbacks: ExecutionStreamCallbacks,
|
||||
logPrefix: string
|
||||
): Promise<void> {
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) continue
|
||||
|
||||
const data = line.substring(6).trim()
|
||||
if (data === '[DONE]') {
|
||||
logger.info(`${logPrefix} stream completed`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data) as ExecutionEvent
|
||||
|
||||
switch (event.type) {
|
||||
case 'execution:started':
|
||||
callbacks.onExecutionStarted?.(event.data)
|
||||
break
|
||||
case 'execution:completed':
|
||||
callbacks.onExecutionCompleted?.(event.data)
|
||||
break
|
||||
case 'execution:error':
|
||||
callbacks.onExecutionError?.(event.data)
|
||||
break
|
||||
case 'execution:cancelled':
|
||||
callbacks.onExecutionCancelled?.(event.data)
|
||||
break
|
||||
case 'block:started':
|
||||
callbacks.onBlockStarted?.(event.data)
|
||||
break
|
||||
case 'block:completed':
|
||||
callbacks.onBlockCompleted?.(event.data)
|
||||
break
|
||||
case 'block:error':
|
||||
callbacks.onBlockError?.(event.data)
|
||||
break
|
||||
case 'stream:chunk':
|
||||
callbacks.onStreamChunk?.(event.data)
|
||||
break
|
||||
case 'stream:done':
|
||||
callbacks.onStreamDone?.(event.data)
|
||||
break
|
||||
default:
|
||||
logger.warn('Unknown event type:', (event as any).type)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse SSE event:', error, { data })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecutionStreamCallbacks {
|
||||
onExecutionStarted?: (data: { startTime: string }) => void
|
||||
onExecutionCompleted?: (data: {
|
||||
@@ -68,6 +143,15 @@ export interface ExecuteStreamOptions {
|
||||
loops?: Record<string, any>
|
||||
parallels?: Record<string, any>
|
||||
}
|
||||
stopAfterBlockId?: string
|
||||
callbacks?: ExecutionStreamCallbacks
|
||||
}
|
||||
|
||||
export interface ExecuteFromBlockOptions {
|
||||
workflowId: string
|
||||
startBlockId: string
|
||||
sourceSnapshot: SerializableExecutionState
|
||||
input?: any
|
||||
callbacks?: ExecutionStreamCallbacks
|
||||
}
|
||||
|
||||
@@ -119,91 +203,7 @@ export function useExecutionStream() {
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n\n')
|
||||
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || !line.startsWith('data: ')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const data = line.substring(6).trim()
|
||||
|
||||
if (data === '[DONE]') {
|
||||
logger.info('Stream completed')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data) as ExecutionEvent
|
||||
|
||||
logger.info('📡 SSE Event received:', {
|
||||
type: event.type,
|
||||
executionId: event.executionId,
|
||||
data: event.data,
|
||||
})
|
||||
|
||||
switch (event.type) {
|
||||
case 'execution:started':
|
||||
logger.info('🚀 Execution started')
|
||||
callbacks.onExecutionStarted?.(event.data)
|
||||
break
|
||||
case 'execution:completed':
|
||||
logger.info('✅ Execution completed')
|
||||
callbacks.onExecutionCompleted?.(event.data)
|
||||
break
|
||||
case 'execution:error':
|
||||
logger.error('❌ Execution error')
|
||||
callbacks.onExecutionError?.(event.data)
|
||||
break
|
||||
case 'execution:cancelled':
|
||||
logger.warn('🛑 Execution cancelled')
|
||||
callbacks.onExecutionCancelled?.(event.data)
|
||||
break
|
||||
case 'block:started':
|
||||
logger.info('🔷 Block started:', event.data.blockId)
|
||||
callbacks.onBlockStarted?.(event.data)
|
||||
break
|
||||
case 'block:completed':
|
||||
logger.info('✓ Block completed:', event.data.blockId)
|
||||
callbacks.onBlockCompleted?.(event.data)
|
||||
break
|
||||
case 'block:error':
|
||||
logger.error('✗ Block error:', event.data.blockId)
|
||||
callbacks.onBlockError?.(event.data)
|
||||
break
|
||||
case 'stream:chunk':
|
||||
callbacks.onStreamChunk?.(event.data)
|
||||
break
|
||||
case 'stream:done':
|
||||
logger.info('Stream done:', event.data.blockId)
|
||||
callbacks.onStreamDone?.(event.data)
|
||||
break
|
||||
default:
|
||||
logger.warn('Unknown event type:', (event as any).type)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse SSE event:', error, { data })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
await processSSEStream(reader, callbacks, 'Execution')
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
logger.info('Execution stream cancelled')
|
||||
@@ -222,6 +222,70 @@ export function useExecutionStream() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const executeFromBlock = useCallback(async (options: ExecuteFromBlockOptions) => {
|
||||
const { workflowId, startBlockId, sourceSnapshot, input, callbacks = {} } = options
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
currentExecutionRef.current = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/execute-from-block`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ startBlockId, sourceSnapshot, input }),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorResponse: any
|
||||
try {
|
||||
errorResponse = await response.json()
|
||||
} catch {
|
||||
throw new Error(`Server error (${response.status}): ${response.statusText}`)
|
||||
}
|
||||
const error = new Error(errorResponse.error || 'Failed to start execution')
|
||||
if (errorResponse && typeof errorResponse === 'object') {
|
||||
Object.assign(error, { executionResult: errorResponse })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body')
|
||||
}
|
||||
|
||||
const executionId = response.headers.get('X-Execution-Id')
|
||||
if (executionId) {
|
||||
currentExecutionRef.current = { workflowId, executionId }
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
await processSSEStream(reader, callbacks, 'Run-from-block')
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
logger.info('Run-from-block execution cancelled')
|
||||
callbacks.onExecutionCancelled?.({ duration: 0 })
|
||||
} else {
|
||||
logger.error('Run-from-block execution error:', error)
|
||||
callbacks.onExecutionError?.({
|
||||
error: error.message || 'Unknown error',
|
||||
duration: 0,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
abortControllerRef.current = null
|
||||
currentExecutionRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
const execution = currentExecutionRef.current
|
||||
if (execution) {
|
||||
@@ -239,6 +303,7 @@ export function useExecutionStream() {
|
||||
|
||||
return {
|
||||
execute,
|
||||
executeFromBlock,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,7 +931,7 @@ export async function secureFetchWithPinnedIP(
|
||||
method: options.method || 'GET',
|
||||
headers: sanitizedHeaders,
|
||||
agent,
|
||||
timeout: options.timeout || 30000,
|
||||
timeout: options.timeout || 300000, // Default 5 minutes
|
||||
}
|
||||
|
||||
const protocol = isHttps ? https : http
|
||||
@@ -1011,7 +1011,7 @@ export async function secureFetchWithPinnedIP(
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
reject(new Error('Request timeout'))
|
||||
reject(new Error(`Request timed out after ${requestOptions.timeout}ms`))
|
||||
})
|
||||
|
||||
if (options.body) {
|
||||
|
||||
@@ -19,3 +19,68 @@ export const SSE_HEADERS = {
|
||||
export function encodeSSE(data: any): Uint8Array {
|
||||
return new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for reading SSE stream
|
||||
*/
|
||||
export interface ReadSSEStreamOptions {
|
||||
onChunk?: (chunk: string) => void
|
||||
onAccumulated?: (accumulated: string) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses an SSE stream from a Response body.
|
||||
* Handles the wand API SSE format with data chunks and done signals.
|
||||
*
|
||||
* @param body - The ReadableStream body from a fetch Response
|
||||
* @param options - Callbacks for handling stream data
|
||||
* @returns The accumulated content from the stream
|
||||
*/
|
||||
export async function readSSEStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
options: ReadSSEStreamOptions = {}
|
||||
): Promise<string> {
|
||||
const { onChunk, onAccumulated, signal } = options
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const lineData = line.substring(6)
|
||||
if (lineData === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const data = JSON.parse(lineData)
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (data.chunk) {
|
||||
accumulatedContent += data.chunk
|
||||
onChunk?.(data.chunk)
|
||||
onAccumulated?.(accumulatedContent)
|
||||
}
|
||||
if (data.done) break
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
return accumulatedContent
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ function prepareLogData(
|
||||
|
||||
export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): Promise<void> {
|
||||
try {
|
||||
if (!log.workflowId) return
|
||||
|
||||
const workflowData = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
|
||||
@@ -293,7 +293,10 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
|
||||
try {
|
||||
const [wf] = await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId))
|
||||
// Skip workflow lookup if workflow was deleted
|
||||
const wf = updatedLog.workflowId
|
||||
? (await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId)))[0]
|
||||
: undefined
|
||||
if (wf) {
|
||||
const [usr] = await db
|
||||
.select({ id: userTable.id, email: userTable.email, name: userTable.name })
|
||||
@@ -461,7 +464,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
* Maintains same logic as original execution logger for billing consistency
|
||||
*/
|
||||
private async updateUserStats(
|
||||
workflowId: string,
|
||||
workflowId: string | null,
|
||||
costSummary: {
|
||||
totalCost: number
|
||||
totalInputCost: number
|
||||
@@ -494,6 +497,11 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
logger.debug('Workflow was deleted, skipping user stats update')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the workflow record to get the userId
|
||||
const [workflowRecord] = await db
|
||||
|
||||
@@ -86,7 +86,13 @@ describe('SnapshotService', () => {
|
||||
type: 'agent',
|
||||
position: { x: 100, y: 200 },
|
||||
|
||||
subBlocks: {},
|
||||
subBlocks: {
|
||||
prompt: {
|
||||
id: 'prompt',
|
||||
type: 'short-input',
|
||||
value: 'Hello world',
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
@@ -104,8 +110,14 @@ describe('SnapshotService', () => {
|
||||
blocks: {
|
||||
block1: {
|
||||
...baseState.blocks.block1,
|
||||
// Different block state - we can change outputs to make it different
|
||||
outputs: { response: { type: 'string', description: 'different result' } },
|
||||
// Different subBlock value - this is a meaningful change
|
||||
subBlocks: {
|
||||
prompt: {
|
||||
id: 'prompt',
|
||||
type: 'short-input',
|
||||
value: 'Different prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionSnapshots } from '@sim/db/schema'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, lt } from 'drizzle-orm'
|
||||
import { and, eq, lt, notExists } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type {
|
||||
SnapshotService as ISnapshotService,
|
||||
@@ -11,12 +11,7 @@ import type {
|
||||
WorkflowExecutionSnapshotInsert,
|
||||
WorkflowState,
|
||||
} from '@/lib/logs/types'
|
||||
import {
|
||||
normalizedStringify,
|
||||
normalizeEdge,
|
||||
normalizeValue,
|
||||
sortEdges,
|
||||
} from '@/lib/workflows/comparison'
|
||||
import { normalizedStringify, normalizeWorkflowState } from '@/lib/workflows/comparison'
|
||||
|
||||
const logger = createLogger('SnapshotService')
|
||||
|
||||
@@ -38,7 +33,9 @@ export class SnapshotService implements ISnapshotService {
|
||||
|
||||
const existingSnapshot = await this.getSnapshotByHash(workflowId, stateHash)
|
||||
if (existingSnapshot) {
|
||||
logger.debug(`Reusing existing snapshot for workflow ${workflowId} with hash ${stateHash}`)
|
||||
logger.info(
|
||||
`Reusing existing snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}...)`
|
||||
)
|
||||
return {
|
||||
snapshot: existingSnapshot,
|
||||
isNew: false,
|
||||
@@ -59,8 +56,9 @@ export class SnapshotService implements ISnapshotService {
|
||||
.values(snapshotData)
|
||||
.returning()
|
||||
|
||||
logger.debug(`Created new snapshot for workflow ${workflowId} with hash ${stateHash}`)
|
||||
logger.debug(`Stored full state with ${Object.keys(state.blocks || {}).length} blocks`)
|
||||
logger.info(
|
||||
`Created new snapshot for workflow ${workflowId} (hash: ${stateHash.slice(0, 12)}..., blocks: ${Object.keys(state.blocks || {}).length})`
|
||||
)
|
||||
return {
|
||||
snapshot: {
|
||||
...newSnapshot,
|
||||
@@ -112,7 +110,7 @@ export class SnapshotService implements ISnapshotService {
|
||||
}
|
||||
|
||||
computeStateHash(state: WorkflowState): string {
|
||||
const normalizedState = this.normalizeStateForHashing(state)
|
||||
const normalizedState = normalizeWorkflowState(state)
|
||||
const stateString = normalizedStringify(normalizedState)
|
||||
return createHash('sha256').update(stateString).digest('hex')
|
||||
}
|
||||
@@ -123,76 +121,23 @@ export class SnapshotService implements ISnapshotService {
|
||||
|
||||
const deletedSnapshots = await db
|
||||
.delete(workflowExecutionSnapshots)
|
||||
.where(lt(workflowExecutionSnapshots.createdAt, cutoffDate))
|
||||
.where(
|
||||
and(
|
||||
lt(workflowExecutionSnapshots.createdAt, cutoffDate),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: workflowExecutionLogs.id })
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id))
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning({ id: workflowExecutionSnapshots.id })
|
||||
|
||||
const deletedCount = deletedSnapshots.length
|
||||
logger.info(`Cleaned up ${deletedCount} orphaned snapshots older than ${olderThanDays} days`)
|
||||
return deletedCount
|
||||
}
|
||||
|
||||
private normalizeStateForHashing(state: WorkflowState): any {
|
||||
// 1. Normalize and sort edges
|
||||
const normalizedEdges = sortEdges((state.edges || []).map(normalizeEdge))
|
||||
|
||||
// 2. Normalize blocks
|
||||
const normalizedBlocks: Record<string, any> = {}
|
||||
|
||||
for (const [blockId, block] of Object.entries(state.blocks || {})) {
|
||||
const { position, layout, height, ...blockWithoutLayoutFields } = block
|
||||
|
||||
// Also exclude width/height from data object (container dimensions from autolayout)
|
||||
const {
|
||||
width: _dataWidth,
|
||||
height: _dataHeight,
|
||||
...dataRest
|
||||
} = blockWithoutLayoutFields.data || {}
|
||||
|
||||
// Normalize subBlocks
|
||||
const subBlocks = blockWithoutLayoutFields.subBlocks || {}
|
||||
const normalizedSubBlocks: Record<string, any> = {}
|
||||
|
||||
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
|
||||
const value = subBlock.value ?? null
|
||||
|
||||
normalizedSubBlocks[subBlockId] = {
|
||||
type: subBlock.type,
|
||||
value: normalizeValue(value),
|
||||
...Object.fromEntries(
|
||||
Object.entries(subBlock).filter(([key]) => key !== 'value' && key !== 'type')
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
normalizedBlocks[blockId] = {
|
||||
...blockWithoutLayoutFields,
|
||||
data: dataRest,
|
||||
subBlocks: normalizedSubBlocks,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Normalize loops and parallels
|
||||
const normalizedLoops: Record<string, any> = {}
|
||||
for (const [loopId, loop] of Object.entries(state.loops || {})) {
|
||||
normalizedLoops[loopId] = normalizeValue(loop)
|
||||
}
|
||||
|
||||
const normalizedParallels: Record<string, any> = {}
|
||||
for (const [parallelId, parallel] of Object.entries(state.parallels || {})) {
|
||||
normalizedParallels[parallelId] = normalizeValue(parallel)
|
||||
}
|
||||
|
||||
// 4. Normalize variables (if present)
|
||||
const normalizedVariables = state.variables ? normalizeValue(state.variables) : undefined
|
||||
|
||||
return {
|
||||
blocks: normalizedBlocks,
|
||||
edges: normalizedEdges,
|
||||
loops: normalizedLoops,
|
||||
parallels: normalizedParallels,
|
||||
...(normalizedVariables !== undefined && { variables: normalizedVariables }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const snapshotService = new SnapshotService()
|
||||
|
||||