Compare commits

..

13 Commits

Author SHA1 Message Date
Vikhyath Mondreti
526b7a64f6 update templates routes to use helper 2026-01-19 16:20:26 -08:00
Siddharth Ganesan
9da689bc8e Fix 2026-01-19 15:58:07 -08:00
Siddharth Ganesan
e1bea05de0 Superuser debug 2026-01-19 15:42:55 -08:00
Siddharth Ganesan
5f45db4343 improvement(copilot): variables, conditions, router (#2887)
* Temp

* Condition and router copilot syntax updates

* Plan respond plan
2026-01-19 15:24:50 -08:00
Waleed
81cbfe7af4 feat(browseruse): upgraded browseruse endpoints to v2 (#2890) 2026-01-19 14:47:19 -08:00
Waleed
739341b08e improvement(router): add resizable textareas for router conditions (#2888) 2026-01-19 13:59:13 -08:00
Waleed
3c43779ba3 feat(search): added operations to search modal in main app, updated retrieval in docs to use RRF (#2889) 2026-01-19 13:57:56 -08:00
Waleed
1861f77283 feat(terminal): add fix in copilot for errors (#2885) 2026-01-19 13:42:34 -08:00
Vikhyath Mondreti
72c2ba7443 fix(linear): team selector in tool input (#2886) 2026-01-19 12:40:45 -08:00
Waleed
037dad6975 fix(undo-redo): preserve subblock values during undo/redo cycles (#2884)
* fix(undo-redo): preserve subblock values during undo/redo cycles

* added tests
2026-01-19 12:19:51 -08:00
Waleed
408597e12b feat(notifs): added block name to error notifications (#2883) 2026-01-19 09:54:19 -08:00
Waleed
932f8fd654 feat(mcp): updated mcp subblocks for mcp tools to match subblocks (#2882)
* feat(mcp): updated mcp subblocks for mcp tools to match subblocks

* updated trigger descriptions
2026-01-19 09:50:03 -08:00
Waleed
b4c2294e67 improvement(emails): update unsub page, standardize unsub process (#2881) 2026-01-18 20:42:04 -08:00
71 changed files with 2041 additions and 1608 deletions

View File

@@ -86,27 +86,112 @@ export async function GET(request: NextRequest) {
) )
.limit(candidateLimit) .limit(candidateLimit)
const seenIds = new Set<string>() const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']
const mergedResults = []
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) { const vectorRankMap = new Map<string, number>()
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) { vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
mergedResults.push(vectorResults[i])
seenIds.add(vectorResults[i].chunkId) const keywordRankMap = new Map<string, number>()
} keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
mergedResults.push(keywordResults[i]) const allChunkIds = new Set([
seenIds.add(keywordResults[i].chunkId) ...vectorResults.map((r) => r.chunkId),
...keywordResults.map((r) => r.chunkId),
])
const k = 60
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
const scoredResults: ResultWithRRF[] = []
for (const chunkId of allChunkIds) {
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
const result =
vectorResults.find((r) => r.chunkId === chunkId) ||
keywordResults.find((r) => r.chunkId === chunkId)
if (result) {
scoredResults.push({ ...result, rrfScore })
} }
} }
const filteredResults = mergedResults.slice(0, limit) scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
const searchResults = filteredResults.map((result) => {
const localeFilteredResults = scoredResults.filter((result) => {
const firstPart = result.sourceDocument.split('/')[0]
if (knownLocales.includes(firstPart)) {
return firstPart === locale
}
return locale === 'en'
})
const queryLower = query.toLowerCase()
const getTitleBoost = (result: ResultWithRRF): number => {
const fileName = result.sourceDocument
.replace('.mdx', '')
.split('/')
.pop()
?.toLowerCase()
?.replace(/_/g, ' ')
if (fileName === queryLower) return 0.01
if (fileName?.includes(queryLower)) return 0.005
return 0
}
localeFilteredResults.sort((a, b) => {
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
})
const pageMap = new Map<string, ResultWithRRF>()
for (const result of localeFilteredResults) {
const pageKey = result.sourceDocument
const existing = pageMap.get(pageKey)
if (!existing || result.rrfScore > existing.rrfScore) {
pageMap.set(pageKey, result)
}
}
const deduplicatedResults = Array.from(pageMap.values())
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
.slice(0, limit)
const searchResults = deduplicatedResults.map((result) => {
const title = result.headerText || result.sourceDocument.replace('.mdx', '') const title = result.headerText || result.sourceDocument.replace('.mdx', '')
const pathParts = result.sourceDocument const pathParts = result.sourceDocument
.replace('.mdx', '') .replace('.mdx', '')
.split('/') .split('/')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .filter((part) => part !== 'index' && !knownLocales.includes(part))
.map((part) => {
return part
.replace(/_/g, ' ')
.split(' ')
.map((word) => {
const acronyms = [
'api',
'mcp',
'sdk',
'url',
'http',
'json',
'xml',
'html',
'css',
'ai',
]
if (acronyms.includes(word.toLowerCase())) {
return word.toUpperCase()
}
return word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
})
return { return {
id: result.chunkId, id: result.chunkId,

View File

@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
{...props} {...props}
version='1.0' version='1.0'
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
width='150pt' width='28'
height='150pt' height='28'
viewBox='0 0 150 150' viewBox='0 0 150 150'
preserveAspectRatio='xMidYMid meet' preserveAspectRatio='xMidYMid meet'
> >
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'> <g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
<path <path
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666 d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111 l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard <BlockInfoCard
type="browser_use" type="browser_use"
color="#E0E0E0" color="#181C1E"
/> />
{/* MANUAL-CONTENT-START:intro */} {/* MANUAL-CONTENT-START:intro */}

View File

@@ -52,6 +52,15 @@ Read content from a Google Slides presentation
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `slides` | json | Array of slides with their content | | `slides` | json | Array of slides with their content |
| `metadata` | json | Presentation metadata including ID, title, and URL | | `metadata` | json | Presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `pageSize` | object | Presentation page size |
| ↳ `width` | json | Page width as a Dimension object |
| ↳ `height` | json | Page height as a Dimension object |
| ↳ `width` | json | Page width as a Dimension object |
| ↳ `height` | json | Page height as a Dimension object |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_write` ### `google_slides_write`
@@ -71,6 +80,10 @@ Write or update content in a Google Slides presentation
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `updatedContent` | boolean | Indicates if presentation content was updated successfully | | `updatedContent` | boolean | Indicates if presentation content was updated successfully |
| `metadata` | json | Updated presentation metadata including ID, title, and URL | | `metadata` | json | Updated presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_create` ### `google_slides_create`
@@ -90,6 +103,10 @@ Create a new Google Slides presentation
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `metadata` | json | Created presentation metadata including ID, title, and URL | | `metadata` | json | Created presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_replace_all_text` ### `google_slides_replace_all_text`
@@ -111,6 +128,10 @@ Find and replace all occurrences of text throughout a Google Slides presentation
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `occurrencesChanged` | number | Number of text occurrences that were replaced | | `occurrencesChanged` | number | Number of text occurrences that were replaced |
| `metadata` | json | Operation metadata including presentation ID and URL | | `metadata` | json | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `findText` | string | The text that was searched for |
| ↳ `replaceText` | string | The text that replaced the matches |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_add_slide` ### `google_slides_add_slide`
@@ -131,6 +152,10 @@ Add a new slide to a Google Slides presentation with a specified layout
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `slideId` | string | The object ID of the newly created slide | | `slideId` | string | The object ID of the newly created slide |
| `metadata` | json | Operation metadata including presentation ID, layout, and URL | | `metadata` | json | Operation metadata including presentation ID, layout, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `layout` | string | The layout used for the new slide |
| ↳ `insertionIndex` | number | The zero-based index where the slide was inserted |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_add_image` ### `google_slides_add_image`
@@ -154,6 +179,10 @@ Insert an image into a specific slide in a Google Slides presentation
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `imageId` | string | The object ID of the newly created image | | `imageId` | string | The object ID of the newly created image |
| `metadata` | json | Operation metadata including presentation ID and image URL | | `metadata` | json | Operation metadata including presentation ID and image URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID where the image was inserted |
| ↳ `imageUrl` | string | The source image URL |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_get_thumbnail` ### `google_slides_get_thumbnail`
@@ -176,6 +205,10 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
| `width` | number | Width of the thumbnail in pixels | | `width` | number | Width of the thumbnail in pixels |
| `height` | number | Height of the thumbnail in pixels | | `height` | number | Height of the thumbnail in pixels |
| `metadata` | json | Operation metadata including presentation ID and page object ID | | `metadata` | json | Operation metadata including presentation ID and page object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID for the thumbnail |
| ↳ `thumbnailSize` | string | The requested thumbnail size |
| ↳ `mimeType` | string | The thumbnail MIME type |
### `google_slides_get_page` ### `google_slides_get_page`

View File

@@ -1,10 +1,11 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { templateCreators, user } from '@sim/db/schema' import { templateCreators } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('CreatorVerificationAPI') const logger = createLogger('CreatorVerificationAPI')
@@ -23,9 +24,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
} }
// Check if user is a super user // Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`) logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 }) return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
} }
@@ -76,9 +76,8 @@ export async function DELETE(
} }
// Check if user is a super user // Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`) logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 }) return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
} }

View File

@@ -0,0 +1,193 @@
import { db } from '@sim/db'
import { copilotChats, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
const logger = createLogger('SuperUserImportWorkflow')
interface ImportWorkflowRequest {
workflowId: string
targetWorkspaceId: string
}
/**
* POST /api/superuser/import-workflow
*
* Superuser endpoint to import a workflow by ID along with its copilot chats.
* This creates a copy of the workflow in the target workspace with new IDs.
* Only the workflow structure and copilot chats are copied - no deployments,
* webhooks, triggers, or other sensitive data.
*
* Requires both isSuperUser flag AND superUserModeEnabled setting.
*/
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { effectiveSuperUser, isSuperUser, superUserModeEnabled } =
await verifyEffectiveSuperUser(session.user.id)
if (!effectiveSuperUser) {
logger.warn('Non-effective-superuser attempted to access import-workflow endpoint', {
userId: session.user.id,
isSuperUser,
superUserModeEnabled,
})
return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 })
}
const body: ImportWorkflowRequest = await request.json()
const { workflowId, targetWorkspaceId } = body
if (!workflowId) {
return NextResponse.json({ error: 'workflowId is required' }, { status: 400 })
}
if (!targetWorkspaceId) {
return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 })
}
// Verify target workspace exists
const [targetWorkspace] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, targetWorkspaceId))
.limit(1)
if (!targetWorkspace) {
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
}
// Get the source workflow
const [sourceWorkflow] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!sourceWorkflow) {
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
}
// Load the workflow state from normalized tables
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return NextResponse.json(
{ error: 'Workflow has no normalized data - cannot import' },
{ status: 400 }
)
}
// Use existing export logic to create export format
const workflowState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: sourceWorkflow.name,
description: sourceWorkflow.description ?? undefined,
color: sourceWorkflow.color,
},
}
const exportData = sanitizeForExport(workflowState)
// Use existing import logic (parseWorkflowJson regenerates IDs automatically)
const { data: importedData, errors } = parseWorkflowJson(JSON.stringify(exportData))
if (!importedData || errors.length > 0) {
return NextResponse.json(
{ error: `Failed to parse workflow: ${errors.join(', ')}` },
{ status: 400 }
)
}
// Create new workflow record
const newWorkflowId = crypto.randomUUID()
const now = new Date()
await db.insert(workflow).values({
id: newWorkflowId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
folderId: null, // Don't copy folder association
name: `[Debug Import] ${sourceWorkflow.name}`,
description: sourceWorkflow.description,
color: sourceWorkflow.color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false, // Never copy deployment status
runCount: 0,
variables: sourceWorkflow.variables || {},
})
// Save using existing persistence logic
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData)
if (!saveResult.success) {
// Clean up the workflow record if save failed
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
return NextResponse.json(
{ error: `Failed to save workflow state: ${saveResult.error}` },
{ status: 500 }
)
}
// Copy copilot chats associated with the source workflow
const sourceCopilotChats = await db
.select()
.from(copilotChats)
.where(eq(copilotChats.workflowId, workflowId))
let copilotChatsImported = 0
for (const chat of sourceCopilotChats) {
await db.insert(copilotChats).values({
userId: session.user.id,
workflowId: newWorkflowId,
title: chat.title ? `[Import] ${chat.title}` : null,
messages: chat.messages,
model: chat.model,
conversationId: null, // Don't copy conversation ID
previewYaml: chat.previewYaml,
planArtifact: chat.planArtifact,
config: chat.config,
createdAt: new Date(),
updatedAt: new Date(),
})
copilotChatsImported++
}
logger.info('Superuser imported workflow', {
userId: session.user.id,
sourceWorkflowId: workflowId,
newWorkflowId,
targetWorkspaceId,
copilotChatsImported,
})
return NextResponse.json({
success: true,
newWorkflowId,
copilotChatsImported,
})
} catch (error) {
logger.error('Error importing workflow', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { verifySuperUser } from '@/lib/templates/permissions' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateApprovalAPI') const logger = createLogger('TemplateApprovalAPI')
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { isSuperUser } = await verifySuperUser(session.user.id) const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!isSuperUser) { if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`) logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 }) return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
} }
@@ -71,8 +71,8 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { isSuperUser } = await verifySuperUser(session.user.id) const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!isSuperUser) { if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`) logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 }) return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
} }

View File

@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { verifySuperUser } from '@/lib/templates/permissions' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
const logger = createLogger('TemplateRejectionAPI') const logger = createLogger('TemplateRejectionAPI')
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { isSuperUser } = await verifySuperUser(session.user.id) const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
if (!isSuperUser) { if (!effectiveSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`) logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 }) return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
} }

View File

@@ -3,7 +3,6 @@ import {
templateCreators, templateCreators,
templateStars, templateStars,
templates, templates,
user,
workflow, workflow,
workflowDeploymentVersion, workflowDeploymentVersion,
} from '@sim/db/schema' } from '@sim/db/schema'
@@ -14,6 +13,7 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
import { import {
extractRequiredCredentials, extractRequiredCredentials,
sanitizeCredentials, sanitizeCredentials,
@@ -70,8 +70,8 @@ export async function GET(request: NextRequest) {
logger.debug(`[${requestId}] Fetching templates with params:`, params) logger.debug(`[${requestId}] Fetching templates with params:`, params)
// Check if user is a super user // Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
const isSuperUser = currentUser[0]?.isSuperUser || false const isSuperUser = effectiveSuperUser
// Build query conditions // Build query conditions
const conditions = [] const conditions = []

View File

@@ -1,10 +1,13 @@
'use client' 'use client'
import { Suspense, useEffect, useState } from 'react' import { Suspense, useEffect, useState } from 'react'
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' import { inter } from '@/app/_styles/fonts/inter/inter'
import { useBrandConfig } from '@/lib/branding/branding' import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import { InviteLayout } from '@/app/invite/components'
interface UnsubscribeData { interface UnsubscribeData {
success: boolean success: boolean
@@ -27,7 +30,6 @@ function UnsubscribeContent() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false) const [unsubscribed, setUnsubscribed] = useState(false)
const brand = useBrandConfig()
const email = searchParams.get('email') const email = searchParams.get('email')
const token = searchParams.get('token') const token = searchParams.get('token')
@@ -109,7 +111,7 @@ function UnsubscribeContent() {
} else { } else {
setError(result.error || 'Failed to unsubscribe') setError(result.error || 'Failed to unsubscribe')
} }
} catch (error) { } catch {
setError('Failed to process unsubscribe request') setError('Failed to process unsubscribe request')
} finally { } finally {
setProcessing(false) setProcessing(false)
@@ -118,272 +120,171 @@ function UnsubscribeContent() {
if (loading) { if (loading) {
return ( return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'> <InviteLayout>
<Card className='w-full max-w-md border shadow-sm'> <div className='space-y-1 text-center'>
<CardContent className='flex items-center justify-center p-8'> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' /> Loading
</CardContent> </h1>
</Card> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
</div> Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
) )
} }
if (error) { if (error) {
return ( return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'> <InviteLayout>
<Card className='w-full max-w-md border shadow-sm'> <div className='space-y-1 text-center'>
<CardHeader className='text-center'> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' /> Invalid Unsubscribe Link
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle> </h1>
<CardDescription className='text-muted-foreground'> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
This unsubscribe link is invalid or has expired {error}
</CardDescription> </p>
</CardHeader> </div>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-red-50 p-4'>
<p className='text-red-800 text-sm'>
<strong>Error:</strong> {error}
</p>
</div>
<div className='space-y-3'> <div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<p className='text-muted-foreground text-sm'>This could happen if:</p> <BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'> </div>
<li>The link is missing required parameters</li>
<li>The link has expired or been used already</li>
<li>The link was copied incorrectly</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'> <SupportFooter position='absolute' />
<Button </InviteLayout>
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
'_blank'
)
}
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
>
Contact Support
</Button>
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
Go Back
</Button>
</div>
<div className='mt-4 text-center'>
<p className='text-muted-foreground text-xs'>
Need immediate help? Email us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
) )
} }
if (data?.isTransactional) { if (data?.isTransactional) {
return ( return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'> <InviteLayout>
<Card className='w-full max-w-md border shadow-sm'> <div className='space-y-1 text-center'>
<CardHeader className='text-center'> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' /> Important Account Emails
<CardTitle className='text-foreground'>Important Account Emails</CardTitle> </h1>
<CardDescription className='text-muted-foreground'> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
This email contains important information about your account Transactional emails like password resets, account confirmations, and security alerts
</CardDescription> cannot be unsubscribed from as they contain essential information for your account.
</CardHeader> </p>
<CardContent className='space-y-4'> </div>
<div className='rounded-lg border bg-blue-50 p-4'>
<p className='text-blue-800 text-sm'>
<strong>Transactional emails</strong> like password resets, account confirmations,
and security alerts cannot be unsubscribed from as they contain essential
information for your account security and functionality.
</p>
</div>
<div className='space-y-3'> <div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<p className='text-foreground text-sm'> <BrandedButton onClick={() => window.close()}>Close</BrandedButton>
If you no longer wish to receive these emails, you can: </div>
</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>Close your account entirely</li>
<li>Contact our support team for assistance</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'> <SupportFooter position='absolute' />
<Button </InviteLayout>
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
'_blank'
)
}
className='w-full bg-blue-600 text-white hover:bg-blue-700'
>
Contact Support
</Button>
<Button onClick={() => window.close()} variant='outline' className='w-full'>
Close
</Button>
</div>
</CardContent>
</Card>
</div>
) )
} }
if (unsubscribed) { if (unsubscribed) {
return ( return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'> <InviteLayout>
<Card className='w-full max-w-md border shadow-sm'> <div className='space-y-1 text-center'>
<CardHeader className='text-center'> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' /> Successfully Unsubscribed
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle> </h1>
<CardDescription className='text-muted-foreground'> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
You have been unsubscribed from our emails. You will stop receiving emails within 48 You have been unsubscribed from our emails. You will stop receiving emails within 48
hours. hours.
</CardDescription> </p>
</CardHeader> </div>
<CardContent className='text-center'>
<p className='text-muted-foreground text-sm'> <div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
If you change your mind, you can always update your email preferences in your account <BrandedButton onClick={() => window.close()}>Close</BrandedButton>
settings or contact us at{' '} </div>
<a
href={`mailto:${brand.supportEmail}`} <SupportFooter position='absolute' />
className='text-muted-foreground hover:underline' </InviteLayout>
>
{brand.supportEmail}
</a>
</p>
</CardContent>
</Card>
</div>
) )
} }
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
return ( return (
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'> <InviteLayout>
<Card className='w-full max-w-md border shadow-sm'> <div className='space-y-1 text-center'>
<CardHeader className='text-center'> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' /> Email Preferences
<CardTitle className='text-foreground'>We&apos;re sorry to see you go!</CardTitle> </h1>
<CardDescription className='text-muted-foreground'> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
We understand email preferences are personal. Choose which emails you&apos;d like to Choose which emails you'd like to stop receiving.
stop receiving from Sim. </p>
</CardDescription> <p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
<div className='mt-2 rounded-lg border bg-muted/50 p-3'> {data?.email}
<p className='text-muted-foreground text-xs'> </p>
Email: <span className='font-medium text-foreground'>{data?.email}</span> </div>
</p>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-3'>
<Button
onClick={() => handleUnsubscribe('all')}
disabled={processing || data?.currentPreferences.unsubscribeAll}
variant='destructive'
className='w-full'
>
{data?.currentPreferences.unsubscribeAll ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{processing
? 'Unsubscribing...'
: data?.currentPreferences.unsubscribeAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</Button>
<div className='text-center text-muted-foreground text-sm'> <div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
or choose specific types: <BrandedButton
</div> onClick={() => handleUnsubscribe('all')}
disabled={processing || isAlreadyUnsubscribedFromAll}
loading={processing}
loadingText='Unsubscribing'
>
{isAlreadyUnsubscribedFromAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</BrandedButton>
<Button <div className='py-2 text-center'>
onClick={() => handleUnsubscribe('marketing')} <span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
disabled={ or choose specific types
processing || </span>
data?.currentPreferences.unsubscribeAll || </div>
data?.currentPreferences.unsubscribeMarketing
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeMarketing ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</Button>
<Button <BrandedButton
onClick={() => handleUnsubscribe('updates')} onClick={() => handleUnsubscribe('marketing')}
disabled={ disabled={
processing || processing ||
data?.currentPreferences.unsubscribeAll || isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeUpdates data?.currentPreferences.unsubscribeMarketing
} }
variant='outline' >
className='w-full' {data?.currentPreferences.unsubscribeMarketing
> ? 'Unsubscribed from Marketing'
{data?.currentPreferences.unsubscribeUpdates ? ( : 'Unsubscribe from Marketing Emails'}
<CheckCircle className='mr-2 h-4 w-4' /> </BrandedButton>
) : null}
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</Button>
<Button <BrandedButton
onClick={() => handleUnsubscribe('notifications')} onClick={() => handleUnsubscribe('updates')}
disabled={ disabled={
processing || processing ||
data?.currentPreferences.unsubscribeAll || isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeNotifications data?.currentPreferences.unsubscribeUpdates
} }
variant='outline' >
className='w-full' {data?.currentPreferences.unsubscribeUpdates
> ? 'Unsubscribed from Updates'
{data?.currentPreferences.unsubscribeNotifications ? ( : 'Unsubscribe from Product Updates'}
<CheckCircle className='mr-2 h-4 w-4' /> </BrandedButton>
) : null}
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</Button>
</div>
<div className='mt-6 space-y-3'> <BrandedButton
<div className='rounded-lg border bg-muted/50 p-3'> onClick={() => handleUnsubscribe('notifications')}
<p className='text-center text-muted-foreground text-xs'> disabled={
<strong>Note:</strong> You&apos;ll continue receiving important account emails like processing ||
password resets and security alerts. isAlreadyUnsubscribedFromAll ||
</p> data?.currentPreferences.unsubscribeNotifications
</div> }
>
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</BrandedButton>
</div>
<p className='text-center text-muted-foreground text-xs'> <div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
Questions? Contact us at{' '} <p className='font-[380] text-[13px] text-muted-foreground'>
<a You'll continue receiving important account emails like password resets and security
href={`mailto:${brand.supportEmail}`} alerts.
className='text-muted-foreground hover:underline' </p>
> </div>
{brand.supportEmail}
</a> <SupportFooter position='absolute' />
</p> </InviteLayout>
</div>
</CardContent>
</Card>
</div>
) )
} }
@@ -391,13 +292,20 @@ export default function Unsubscribe() {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'> <InviteLayout>
<Card className='w-full max-w-md border shadow-sm'> <div className='space-y-1 text-center'>
<CardContent className='flex items-center justify-center p-8'> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' /> Loading
</CardContent> </h1>
</Card> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
</div> Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
} }
> >
<UnsubscribeContent /> <UnsubscribeContent />

View File

@@ -26,9 +26,6 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types' import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Parse special tags from content
*/
/** /**
* Plan step can be either a string or an object with title and plan * Plan step can be either a string or an object with title and plan
*/ */
@@ -47,6 +44,56 @@ interface ParsedTags {
cleanContent: string cleanContent: string
} }
/**
* Extract plan steps from plan_respond tool calls in subagent blocks.
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
*/
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
steps: Record<string, PlanStep> | undefined
isComplete: boolean
} {
if (!blocks) return { steps: undefined, isComplete: false }
// Find the plan_respond tool call
const planRespondBlock = blocks.find(
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
)
if (!planRespondBlock?.toolCall) {
return { steps: undefined, isComplete: false }
}
// Tool call arguments can be in different places depending on the source
// Also handle nested data.arguments structure from the schema
const tc = planRespondBlock.toolCall as any
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
const stepsArray = args.steps
if (!Array.isArray(stepsArray) || stepsArray.length === 0) {
return { steps: undefined, isComplete: false }
}
// Convert array format to Record<string, PlanStep> format
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
// To: { "1": "...", "2": "..." }
const steps: Record<string, PlanStep> = {}
for (const step of stepsArray) {
if (step.number !== undefined && step.title) {
steps[String(step.number)] = step.title
}
}
// Check if the tool call is complete (not pending/executing)
const isComplete =
planRespondBlock.toolCall.state === ClientToolCallState.success ||
planRespondBlock.toolCall.state === ClientToolCallState.error
return {
steps: Object.keys(steps).length > 0 ? steps : undefined,
isComplete,
}
}
/** /**
* Try to parse partial JSON for streaming options. * Try to parse partial JSON for streaming options.
* Attempts to extract complete key-value pairs from incomplete JSON. * Attempts to extract complete key-value pairs from incomplete JSON.
@@ -654,11 +701,20 @@ function SubAgentThinkingContent({
} }
} }
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(blocks)
const allParsed = parseSpecialTags(allRawText) const allParsed = parseSpecialTags(allRawText)
if (!cleanText.trim() && !allParsed.plan) return null // Prefer plan_respond tool data over <plan> tags
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0) if (!cleanText.trim() && !hasPlan) return null
const hasSpecialTags = hasPlan
return ( return (
<div className='space-y-1.5'> <div className='space-y-1.5'>
@@ -670,9 +726,7 @@ function SubAgentThinkingContent({
hasSpecialTags={hasSpecialTags} hasSpecialTags={hasSpecialTags}
/> />
)} )}
{allParsed.plan && Object.keys(allParsed.plan).length > 0 && ( {hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
<PlanSteps steps={allParsed.plan} streaming={isStreaming} />
)}
</div> </div>
) )
} }
@@ -744,8 +798,19 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
} }
const allParsed = parseSpecialTags(allRawText) const allParsed = parseSpecialTags(allRawText)
// Extract plan from plan_respond tool call (preferred) or fall back to <plan> tags
const { steps: planSteps, isComplete: planComplete } = extractPlanFromBlocks(
toolCall.subAgentBlocks
)
const hasPlan =
!!(planSteps && Object.keys(planSteps).length > 0) ||
!!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
const planToRender = planSteps || allParsed.plan
const isPlanStreaming = planSteps ? !planComplete : isStreaming
const hasSpecialTags = !!( const hasSpecialTags = !!(
(allParsed.plan && Object.keys(allParsed.plan).length > 0) || hasPlan ||
(allParsed.options && Object.keys(allParsed.options).length > 0) (allParsed.options && Object.keys(allParsed.options).length > 0)
) )
@@ -757,8 +822,6 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const outerLabel = getSubagentCompletionLabel(toolCall.name) const outerLabel = getSubagentCompletionLabel(toolCall.name)
const durationText = `${outerLabel} for ${formatDuration(duration)}` const durationText = `${outerLabel} for ${formatDuration(duration)}`
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
const renderCollapsibleContent = () => ( const renderCollapsibleContent = () => (
<> <>
{segments.map((segment, index) => { {segments.map((segment, index) => {
@@ -800,7 +863,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
return ( return (
<div className='w-full space-y-1.5'> <div className='w-full space-y-1.5'>
{renderCollapsibleContent()} {renderCollapsibleContent()}
{hasPlan && <PlanSteps steps={allParsed.plan!} streaming={isStreaming} />} {hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
</div> </div>
) )
} }
@@ -832,7 +895,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
</div> </div>
{/* Plan stays outside the collapsible */} {/* Plan stays outside the collapsible */}
{hasPlan && <PlanSteps steps={allParsed.plan!} />} {hasPlan && planToRender && <PlanSteps steps={planToRender} />}
</div> </div>
) )
}) })
@@ -1412,7 +1475,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
if ( if (
toolCall.name === 'checkoff_todo' || toolCall.name === 'checkoff_todo' ||
toolCall.name === 'mark_todo_in_progress' || toolCall.name === 'mark_todo_in_progress' ||
toolCall.name === 'tool_search_tool_regex' toolCall.name === 'tool_search_tool_regex' ||
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
) )
return null return null

View File

@@ -1,7 +1,7 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronUp, Plus } from 'lucide-react' import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import Editor from 'react-simple-code-editor' import Editor from 'react-simple-code-editor'
import { useUpdateNodeInternals } from 'reactflow' import { useUpdateNodeInternals } from 'reactflow'
@@ -39,6 +39,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('ConditionInput') const logger = createLogger('ConditionInput')
/**
* Default height for router textareas in pixels
*/
const ROUTER_DEFAULT_HEIGHT_PX = 100
/**
* Minimum height for router textareas in pixels
*/
const ROUTER_MIN_HEIGHT_PX = 80
/** /**
* Represents a single conditional block (if/else if/else). * Represents a single conditional block (if/else if/else).
*/ */
@@ -743,6 +753,61 @@ export function ConditionInput({
} }
}, [conditionalBlocks, isRouterMode]) }, [conditionalBlocks, isRouterMode])
// State for tracking individual router textarea heights
const [routerHeights, setRouterHeights] = useState<{ [key: string]: number }>({})
const isResizing = useRef(false)
/**
* Gets the height for a specific router block, returning default if not set.
*
* @param blockId - ID of the router block
* @returns Height in pixels
*/
const getRouterHeight = (blockId: string): number => {
return routerHeights[blockId] ?? ROUTER_DEFAULT_HEIGHT_PX
}
/**
* Handles mouse-based resize for router textareas.
*
* @param e - Mouse event from the resize handle
* @param blockId - ID of the block being resized
*/
const startRouterResize = (e: React.MouseEvent, blockId: string) => {
if (isPreview || disabled) return
e.preventDefault()
e.stopPropagation()
isResizing.current = true
const startY = e.clientY
const startHeight = getRouterHeight(blockId)
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return
const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(ROUTER_MIN_HEIGHT_PX, startHeight + deltaY)
// Update the textarea height directly for smooth resizing
const textarea = inputRefs.current.get(blockId)
if (textarea) {
textarea.style.height = `${newHeight}px`
}
// Update state to keep track
setRouterHeights((prev) => ({ ...prev, [blockId]: newHeight }))
}
const handleMouseUp = () => {
isResizing.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// Show loading or empty state if not ready or no blocks // Show loading or empty state if not ready or no blocks
if (!isReady || conditionalBlocks.length === 0) { if (!isReady || conditionalBlocks.length === 0) {
return ( return (
@@ -907,10 +972,24 @@ export function ConditionInput({
}} }}
placeholder='Describe when this route should be taken...' placeholder='Describe when this route should be taken...'
disabled={disabled || isPreview} disabled={disabled || isPreview}
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0' className='min-h-[100px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={2} rows={4}
style={{ height: `${getRouterHeight(block.id)}px` }}
/> />
{/* Custom resize handle */}
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => startRouterResize(e, block.id)}
onDragStart={(e) => {
e.preventDefault()
}}
>
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
</div>
)}
{block.showEnvVars && ( {block.showEnvVars && (
<EnvVarDropdown <EnvVarDropdown
visible={block.showEnvVars} visible={block.showEnvVars}

View File

@@ -234,48 +234,45 @@ export function LongInput({
}, [value]) }, [value])
// Handle resize functionality // Handle resize functionality
const startResize = useCallback( const startResize = (e: React.MouseEvent) => {
(e: React.MouseEvent) => { e.preventDefault()
e.preventDefault() e.stopPropagation()
e.stopPropagation() isResizing.current = true
isResizing.current = true
const startY = e.clientY const startY = e.clientY
const startHeight = height const startHeight = height
const handleMouseMove = (moveEvent: MouseEvent) => { const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return if (!isResizing.current) return
const deltaY = moveEvent.clientY - startY const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY) const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
if (textareaRef.current && overlayRef.current) { if (textareaRef.current && overlayRef.current) {
textareaRef.current.style.height = `${newHeight}px` textareaRef.current.style.height = `${newHeight}px`
overlayRef.current.style.height = `${newHeight}px` overlayRef.current.style.height = `${newHeight}px`
} }
if (containerRef.current) { if (containerRef.current) {
containerRef.current.style.height = `${newHeight}px` containerRef.current.style.height = `${newHeight}px`
} }
// Keep React state in sync so parent layouts (e.g., Editor) update during drag // Keep React state in sync so parent layouts (e.g., Editor) update during drag
setHeight(newHeight) setHeight(newHeight)
}
const handleMouseUp = () => {
if (textareaRef.current) {
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
setHeight(finalHeight)
} }
const handleMouseUp = () => { isResizing.current = false
if (textareaRef.current) { document.removeEventListener('mousemove', handleMouseMove)
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height document.removeEventListener('mouseup', handleMouseUp)
setHeight(finalHeight) }
}
isResizing.current = false document.addEventListener('mousemove', handleMouseMove)
document.removeEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp)
document.removeEventListener('mouseup', handleMouseUp) }
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[height]
)
// Expose wand control handlers to parent via ref // Expose wand control handlers to parent via ref
useImperativeHandle( useImperativeHandle(

View File

@@ -1,281 +1,17 @@
import type { RefObject } from 'react' import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components' import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
import { import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { formatParameterLabel } from '@/tools/params' import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('McpDynamicArgs') const logger = createLogger('McpDynamicArgs')
interface McpInputWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
isPassword?: boolean
blockId: string
accessiblePrefixes?: Set<string>
}
function McpInputWithTags({
value,
onChange,
placeholder,
disabled,
isPassword,
blockId,
accessiblePrefixes,
}: McpInputWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const inputNameRef = useRef(`mcp_input_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = dropPosition + 1
inputRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<div className='relative'>
<Input
ref={inputRef}
type={isPassword ? 'password' : 'text'}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
placeholder={placeholder}
disabled={disabled}
name={inputNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
readOnly
onFocus={(e) => {
e.currentTarget.removeAttribute('readOnly')
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
className={cn(!isPassword && 'text-transparent caret-foreground')}
/>
{!isPassword && (
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value?.toString() || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
)}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={inputRef as RefObject<HTMLInputElement>}
/>
</div>
)
}
interface McpTextareaWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
blockId: string
accessiblePrefixes?: Set<string>
rows?: number
}
function McpTextareaWithTags({
value,
onChange,
placeholder,
disabled,
blockId,
accessiblePrefixes,
rows = 4,
}: McpTextareaWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const textareaNameRef = useRef(`mcp_textarea_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = dropPosition + 1
textareaRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<Textarea
ref={textareaRef}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
onFocus={() => {
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
placeholder={placeholder}
disabled={disabled}
rows={rows}
name={textareaNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
className={cn('min-h-[80px] resize-none text-transparent caret-foreground')}
/>
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words px-[8px] py-[8px] font-medium font-sans text-sm'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
/>
</div>
)
}
interface McpDynamicArgsProps { interface McpDynamicArgsProps {
blockId: string blockId: string
subBlockId: string subBlockId: string
@@ -284,6 +20,27 @@ interface McpDynamicArgsProps {
previewValue?: any previewValue?: any
} }
/**
* Creates a minimal SubBlockConfig for MCP tool parameters
*/
function createParamConfig(
paramName: string,
paramSchema: any,
inputType: 'long-input' | 'short-input'
): SubBlockConfig {
const placeholder =
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}`
return {
id: paramName,
type: inputType,
title: formatParameterLabel(paramName),
placeholder,
}
}
export function McpDynamicArgs({ export function McpDynamicArgs({
blockId, blockId,
subBlockId, subBlockId,
@@ -297,7 +54,6 @@ export function McpDynamicArgs({
const [selectedTool] = useSubBlockValue(blockId, 'tool') const [selectedTool] = useSubBlockValue(blockId, 'tool')
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema') const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId) const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool) const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
@@ -308,7 +64,7 @@ export function McpDynamicArgs({
try { try {
return JSON.parse(previewValue) return JSON.parse(previewValue)
} catch (error) { } catch (error) {
console.warn('Failed to parse preview value as JSON:', error) logger.warn('Failed to parse preview value as JSON:', { error })
return previewValue return previewValue
} }
} }
@@ -318,7 +74,7 @@ export function McpDynamicArgs({
try { try {
return JSON.parse(toolArgs) return JSON.parse(toolArgs)
} catch (error) { } catch (error) {
console.warn('Failed to parse toolArgs as JSON:', error) logger.warn('Failed to parse toolArgs as JSON:', { error })
return {} return {}
} }
} }
@@ -460,24 +216,23 @@ export function McpDynamicArgs({
) )
} }
case 'long-input': case 'long-input': {
const config = createParamConfig(paramName, paramSchema, 'long-input')
return ( return (
<McpTextareaWithTags <LongInput
key={`${paramName}-long`} key={`${paramName}-long`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
rows={4}
value={value || ''} value={value || ''}
onChange={(newValue) => updateParameter(paramName, newValue)} onChange={(newValue) => updateParameter(paramName, newValue)}
placeholder={ isPreview={isPreview}
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled} disabled={disabled}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
rows={4}
/> />
) )
}
default: { default: {
const isPassword = const isPassword =
@@ -485,10 +240,16 @@ export function McpDynamicArgs({
paramName.toLowerCase().includes('password') || paramName.toLowerCase().includes('password') ||
paramName.toLowerCase().includes('token') paramName.toLowerCase().includes('token')
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer' const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
const config = createParamConfig(paramName, paramSchema, 'short-input')
return ( return (
<McpInputWithTags <ShortInput
key={`${paramName}-short`} key={`${paramName}-short`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
password={isPassword}
value={value?.toString() || ''} value={value?.toString() || ''}
onChange={(newValue) => { onChange={(newValue) => {
let processedValue: any = newValue let processedValue: any = newValue
@@ -506,16 +267,8 @@ export function McpDynamicArgs({
} }
updateParameter(paramName, processedValue) updateParameter(paramName, processedValue)
}} }}
placeholder={ isPreview={isPreview}
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled} disabled={disabled}
isPassword={isPassword}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
/> />
) )
} }
@@ -578,26 +331,40 @@ export function McpDynamicArgs({
tabIndex={-1} tabIndex={-1}
readOnly readOnly
/> />
<div className='space-y-4'> <div>
{toolSchema.properties && {toolSchema.properties &&
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => { Object.entries(toolSchema.properties).map(([paramName, paramSchema], index, entries) => {
const inputType = getInputType(paramSchema as any) const inputType = getInputType(paramSchema as any)
const showLabel = inputType !== 'switch' const showLabel = inputType !== 'switch'
const showDivider = index < entries.length - 1
return ( return (
<div key={paramName} className='space-y-2'> <div key={paramName} className='subblock-row'>
{showLabel && ( <div className='subblock-content flex flex-col gap-[10px]'>
<Label {showLabel && (
className={cn( <Label
'font-medium text-sm', className={cn(
toolSchema.required?.includes(paramName) && 'font-medium text-sm',
'after:ml-1 after:text-red-500 after:content-["*"]' toolSchema.required?.includes(paramName) &&
)} 'after:ml-1 after:text-red-500 after:content-["*"]'
> )}
{formatParameterLabel(paramName)} >
</Label> {formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)} )}
{renderParameterInput(paramName, paramSchema as any)}
</div> </div>
) )
})} })}

View File

@@ -2069,6 +2069,7 @@ export const ToolInput = memo(function ToolInput({
placeholder: uiComponent.placeholder, placeholder: uiComponent.placeholder,
requiredScopes: uiComponent.requiredScopes, requiredScopes: uiComponent.requiredScopes,
dependsOn: uiComponent.dependsOn, dependsOn: uiComponent.dependsOn,
canonicalParamId: uiComponent.canonicalParamId ?? param.id,
}} }}
onProjectSelect={onChange} onProjectSelect={onChange}
disabled={disabled} disabled={disabled}

View File

@@ -34,6 +34,7 @@ interface LogRowContextMenuProps {
onCopyRunId: (runId: string) => void onCopyRunId: (runId: string) => void
onClearFilters: () => void onClearFilters: () => void
onClearConsole: () => void onClearConsole: () => void
onFixInCopilot: (entry: ConsoleEntry) => void
hasActiveFilters: boolean hasActiveFilters: boolean
} }
@@ -54,6 +55,7 @@ export function LogRowContextMenu({
onCopyRunId, onCopyRunId,
onClearFilters, onClearFilters,
onClearConsole, onClearConsole,
onFixInCopilot,
hasActiveFilters, hasActiveFilters,
}: LogRowContextMenuProps) { }: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null const hasRunId = entry?.executionId != null
@@ -96,6 +98,21 @@ export function LogRowContextMenu({
</> </>
)} )}
{/* Fix in Copilot - only for error rows */}
{entry && !entry.success && (
<>
<PopoverItem
onClick={() => {
onFixInCopilot(entry)
onClose()
}}
>
Fix in Copilot
</PopoverItem>
<PopoverDivider />
</>
)}
{/* Filter actions */} {/* Filter actions */}
{entry && ( {entry && (
<> <>

View File

@@ -54,6 +54,7 @@ import { useShowTrainingControls } from '@/hooks/queries/general-settings'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store' import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { openCopilotWithMessage } from '@/stores/notifications/utils'
import type { ConsoleEntry } from '@/stores/terminal' import type { ConsoleEntry } from '@/stores/terminal'
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal' import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -226,7 +227,6 @@ const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
return false return false
} }
// Check target and walk up ancestors in case editors render nested elements
let el: HTMLElement | null = target let el: HTMLElement | null = target
while (el) { while (el) {
if (isEditable(el)) return true if (isEditable(el)) return true
@@ -1159,6 +1159,17 @@ export const Terminal = memo(function Terminal() {
clearCurrentWorkflowConsole() clearCurrentWorkflowConsole()
}, [clearCurrentWorkflowConsole]) }, [clearCurrentWorkflowConsole])
const handleFixInCopilot = useCallback(
(entry: ConsoleEntry) => {
const errorMessage = entry.error ? String(entry.error) : 'Unknown error'
const blockName = entry.blockName || 'Unknown Block'
const message = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
openCopilotWithMessage(message)
closeLogRowMenu()
},
[closeLogRowMenu]
)
const handleTrainingClick = useCallback( const handleTrainingClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@@ -1949,6 +1960,7 @@ export const Terminal = memo(function Terminal() {
closeLogRowMenu() closeLogRowMenu()
}} }}
onClearConsole={handleClearConsoleFromMenu} onClearConsole={handleClearConsoleFromMenu}
onFixInCopilot={handleFixInCopilot}
hasActiveFilters={hasActiveFilters} hasActiveFilters={hasActiveFilters}
/> />
</> </>

View File

@@ -692,7 +692,8 @@ const WorkflowContent = React.memo(() => {
parentId?: string, parentId?: string,
extent?: 'parent', extent?: 'parent',
autoConnectEdge?: Edge, autoConnectEdge?: Edge,
triggerMode?: boolean triggerMode?: boolean,
presetSubBlockValues?: Record<string, unknown>
) => { ) => {
setPendingSelection([id]) setPendingSelection([id])
setSelectedEdges(new Map()) setSelectedEdges(new Map())
@@ -722,6 +723,14 @@ const WorkflowContent = React.memo(() => {
} }
} }
// Apply preset subblock values (e.g., from tool-operation search)
if (presetSubBlockValues) {
if (!subBlockValues[id]) {
subBlockValues[id] = {}
}
Object.assign(subBlockValues[id], presetSubBlockValues)
}
collaborativeBatchAddBlocks( collaborativeBatchAddBlocks(
[block], [block],
autoConnectEdge ? [autoConnectEdge] : [], autoConnectEdge ? [autoConnectEdge] : [],
@@ -1489,7 +1498,7 @@ const WorkflowContent = React.memo(() => {
return return
} }
const { type, enableTriggerMode } = event.detail const { type, enableTriggerMode, presetOperation } = event.detail
if (!type) return if (!type) return
if (type === 'connectionBlock') return if (type === 'connectionBlock') return
@@ -1552,7 +1561,8 @@ const WorkflowContent = React.memo(() => {
undefined, undefined,
undefined, undefined,
autoConnectEdge, autoConnectEdge,
enableTriggerMode enableTriggerMode,
presetOperation ? { operation: presetOperation } : undefined
) )
} }

View File

@@ -8,6 +8,7 @@ import { useParams, useRouter } from 'next/navigation'
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
import { useBrandConfig } from '@/lib/branding/branding' import { useBrandConfig } from '@/lib/branding/branding'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils' import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -81,10 +82,12 @@ type SearchItem = {
color?: string color?: string
href?: string href?: string
shortcut?: string shortcut?: string
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc' type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
isCurrent?: boolean isCurrent?: boolean
blockType?: string blockType?: string
config?: any config?: any
operationId?: string
aliases?: string[]
} }
interface SearchResultItemProps { interface SearchResultItemProps {
@@ -101,7 +104,11 @@ const SearchResultItem = memo(function SearchResultItem({
onItemClick, onItemClick,
}: SearchResultItemProps) { }: SearchResultItemProps) {
const Icon = item.icon const Icon = item.icon
const showColoredIcon = item.type === 'block' || item.type === 'trigger' || item.type === 'tool' const showColoredIcon =
item.type === 'block' ||
item.type === 'trigger' ||
item.type === 'tool' ||
item.type === 'tool-operation'
const isWorkflow = item.type === 'workflow' const isWorkflow = item.type === 'workflow'
const isWorkspace = item.type === 'workspace' const isWorkspace = item.type === 'workspace'
@@ -278,6 +285,24 @@ export const SearchModal = memo(function SearchModal({
) )
}, [open, isOnWorkflowPage, filterBlocks]) }, [open, isOnWorkflowPage, filterBlocks])
const toolOperations = useMemo(() => {
if (!open || !isOnWorkflowPage) return []
const allowedBlockTypes = new Set(tools.map((t) => t.type))
return getToolOperationsIndex()
.filter((op) => allowedBlockTypes.has(op.blockType))
.map((op) => ({
id: op.id,
name: `${op.serviceName}: ${op.operationName}`,
icon: op.icon,
bgColor: op.bgColor,
blockType: op.blockType,
operationId: op.operationId,
aliases: op.aliases,
}))
}, [open, isOnWorkflowPage, tools])
const pages = useMemo( const pages = useMemo(
(): PageItem[] => [ (): PageItem[] => [
{ {
@@ -396,6 +421,19 @@ export const SearchModal = memo(function SearchModal({
}) })
}) })
toolOperations.forEach((op) => {
items.push({
id: op.id,
name: op.name,
icon: op.icon,
bgColor: op.bgColor,
type: 'tool-operation',
blockType: op.blockType,
operationId: op.operationId,
aliases: op.aliases,
})
})
docs.forEach((doc) => { docs.forEach((doc) => {
items.push({ items.push({
id: doc.id, id: doc.id,
@@ -407,10 +445,10 @@ export const SearchModal = memo(function SearchModal({
}) })
return items return items
}, [workspaces, workflows, pages, blocks, triggers, tools, docs]) }, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
const sectionOrder = useMemo<SearchItem['type'][]>( const sectionOrder = useMemo<SearchItem['type'][]>(
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'], () => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
[] []
) )
@@ -457,6 +495,7 @@ export const SearchModal = memo(function SearchModal({
page: [], page: [],
trigger: [], trigger: [],
block: [], block: [],
'tool-operation': [],
tool: [], tool: [],
doc: [], doc: [],
} }
@@ -512,6 +551,17 @@ export const SearchModal = memo(function SearchModal({
window.dispatchEvent(event) window.dispatchEvent(event)
} }
break break
case 'tool-operation':
if (item.blockType && item.operationId) {
const event = new CustomEvent('add-block-from-toolbar', {
detail: {
type: item.blockType,
presetOperation: item.operationId,
},
})
window.dispatchEvent(event)
}
break
case 'workspace': case 'workspace':
if (item.isCurrent) { if (item.isCurrent) {
break break
@@ -592,6 +642,7 @@ export const SearchModal = memo(function SearchModal({
page: 'Pages', page: 'Pages',
trigger: 'Triggers', trigger: 'Triggers',
block: 'Blocks', block: 'Blocks',
'tool-operation': 'Tool Operations',
tool: 'Tools', tool: 'Tools',
doc: 'Docs', doc: 'Docs',
} }

View File

@@ -8,17 +8,19 @@ export interface SearchableItem {
name: string name: string
description?: string description?: string
type: string type: string
aliases?: string[]
[key: string]: any [key: string]: any
} }
export interface SearchResult<T extends SearchableItem> { export interface SearchResult<T extends SearchableItem> {
item: T item: T
score: number score: number
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description' matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
} }
const SCORE_EXACT_MATCH = 10000 const SCORE_EXACT_MATCH = 10000
const SCORE_PREFIX_MATCH = 5000 const SCORE_PREFIX_MATCH = 5000
const SCORE_ALIAS_MATCH = 3000
const SCORE_WORD_BOUNDARY = 1000 const SCORE_WORD_BOUNDARY = 1000
const SCORE_SUBSTRING_MATCH = 100 const SCORE_SUBSTRING_MATCH = 100
const DESCRIPTION_WEIGHT = 0.3 const DESCRIPTION_WEIGHT = 0.3
@@ -67,6 +69,39 @@ function calculateFieldScore(
return { score: 0, matchType: null } return { score: 0, matchType: null }
} }
/**
* Check if query matches any alias in the item's aliases array
* Returns the alias score if a match is found, 0 otherwise
*/
function calculateAliasScore(
query: string,
aliases?: string[]
): { score: number; matchType: 'alias' | null } {
if (!aliases || aliases.length === 0) {
return { score: 0, matchType: null }
}
const normalizedQuery = query.toLowerCase().trim()
for (const alias of aliases) {
const normalizedAlias = alias.toLowerCase().trim()
if (normalizedAlias === normalizedQuery) {
return { score: SCORE_ALIAS_MATCH, matchType: 'alias' }
}
if (normalizedAlias.startsWith(normalizedQuery)) {
return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' }
}
if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) {
return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' }
}
}
return { score: 0, matchType: null }
}
/** /**
* Search items using tiered matching algorithm * Search items using tiered matching algorithm
* Returns items sorted by relevance (highest score first) * Returns items sorted by relevance (highest score first)
@@ -90,15 +125,20 @@ export function searchItems<T extends SearchableItem>(
? calculateFieldScore(normalizedQuery, item.description) ? calculateFieldScore(normalizedQuery, item.description)
: { score: 0, matchType: null } : { score: 0, matchType: null }
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
const nameScore = nameMatch.score const nameScore = nameMatch.score
const descScore = descMatch.score * DESCRIPTION_WEIGHT const descScore = descMatch.score * DESCRIPTION_WEIGHT
const aliasScore = aliasMatch.score
const bestScore = Math.max(nameScore, descScore) const bestScore = Math.max(nameScore, descScore, aliasScore)
if (bestScore > 0) { if (bestScore > 0) {
let matchType: SearchResult<T>['matchType'] = 'substring' let matchType: SearchResult<T>['matchType'] = 'substring'
if (nameScore >= descScore) { if (nameScore >= descScore && nameScore >= aliasScore) {
matchType = nameMatch.matchType || 'substring' matchType = nameMatch.matchType || 'substring'
} else if (aliasScore >= descScore) {
matchType = 'alias'
} else { } else {
matchType = 'description' matchType = 'description'
} }
@@ -125,6 +165,8 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
return 'Exact match' return 'Exact match'
case 'prefix': case 'prefix':
return 'Starts with' return 'Starts with'
case 'alias':
return 'Similar to'
case 'word-boundary': case 'word-boundary':
return 'Word match' return 'Word match'
case 'substring': case 'substring':

View File

@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { Button, Input as EmcnInput } from '@/components/emcn'
import { workflowKeys } from '@/hooks/queries/workflows'
const logger = createLogger('DebugSettings')
/**
* Debug settings component for superusers.
* Allows importing workflows by ID for debugging purposes.
*/
export function Debug() {
const params = useParams()
const queryClient = useQueryClient()
const workspaceId = params?.workspaceId as string
const [workflowId, setWorkflowId] = useState('')
const [isImporting, setIsImporting] = useState(false)
const handleImport = async () => {
if (!workflowId.trim()) return
setIsImporting(true)
try {
const response = await fetch('/api/superuser/import-workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: workflowId.trim(),
targetWorkspaceId: workspaceId,
}),
})
const data = await response.json()
if (response.ok) {
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
setWorkflowId('')
logger.info('Workflow imported successfully', {
originalWorkflowId: workflowId.trim(),
newWorkflowId: data.newWorkflowId,
copilotChatsImported: data.copilotChatsImported,
})
}
} catch (error) {
logger.error('Failed to import workflow', error)
} finally {
setIsImporting(false)
}
}
return (
<div className='flex h-full flex-col gap-[16px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<div className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
placeholder='Enter workflow ID'
disabled={isImporting}
/>
<Button
variant='tertiary'
onClick={handleImport}
disabled={isImporting || !workflowId.trim()}
>
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot' export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets' export { CredentialSets } from './credential-sets/credential-sets'
export { CustomTools } from './custom-tools/custom-tools' export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment' export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files' export { Files as FileUploads } from './files/files'
export { General } from './general/general' export { General } from './general/general'

View File

@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden' import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { import {
Bug,
Files, Files,
KeySquare, KeySquare,
LogIn, LogIn,
@@ -46,6 +47,7 @@ import {
Copilot, Copilot,
CredentialSets, CredentialSets,
CustomTools, CustomTools,
Debug,
EnvironmentVariables, EnvironmentVariables,
FileUploads, FileUploads,
General, General,
@@ -91,8 +93,15 @@ type SettingsSection =
| 'mcp' | 'mcp'
| 'custom-tools' | 'custom-tools'
| 'workflow-mcp-servers' | 'workflow-mcp-servers'
| 'debug'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise' type NavigationSection =
| 'account'
| 'subscription'
| 'tools'
| 'system'
| 'enterprise'
| 'superuser'
type NavigationItem = { type NavigationItem = {
id: SettingsSection id: SettingsSection
@@ -104,6 +113,7 @@ type NavigationItem = {
requiresEnterprise?: boolean requiresEnterprise?: boolean
requiresHosted?: boolean requiresHosted?: boolean
selfHostedOverride?: boolean selfHostedOverride?: boolean
requiresSuperUser?: boolean
} }
const sectionConfig: { key: NavigationSection; title: string }[] = [ const sectionConfig: { key: NavigationSection; title: string }[] = [
@@ -112,6 +122,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'subscription', title: 'Subscription' }, { key: 'subscription', title: 'Subscription' },
{ key: 'system', title: 'System' }, { key: 'system', title: 'System' },
{ key: 'enterprise', title: 'Enterprise' }, { key: 'enterprise', title: 'Enterprise' },
{ key: 'superuser', title: 'Superuser' },
] ]
const allNavigationItems: NavigationItem[] = [ const allNavigationItems: NavigationItem[] = [
@@ -180,15 +191,24 @@ const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true, requiresEnterprise: true,
selfHostedOverride: isSSOEnabled, selfHostedOverride: isSSOEnabled,
}, },
{
id: 'debug',
label: 'Debug',
icon: Bug,
section: 'superuser',
requiresSuperUser: true,
},
] ]
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general') const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore() const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null) const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const [isSuperUser, setIsSuperUser] = useState(false)
const { data: session } = useSession() const { data: session } = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations() const { data: organizationsData } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders() const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
@@ -209,6 +229,23 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const hasEnterprisePlan = subscriptionStatus.isEnterprise const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id const hasOrganization = !!activeOrganization?.id
// Fetch superuser status
useEffect(() => {
const fetchSuperUserStatus = async () => {
if (!userId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser)
}
} catch {
setIsSuperUser(false)
}
}
fetchSuperUserStatus()
}, [userId])
// Memoize SSO provider ownership check // Memoize SSO provider ownership check
const isSSOProviderOwner = useMemo(() => { const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null if (isHosted) return null
@@ -268,6 +305,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false return false
} }
// requiresSuperUser: only show if user is a superuser AND has superuser mode enabled
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
if (item.requiresSuperUser && !effectiveSuperUser) {
return false
}
return true return true
}) })
}, [ }, [
@@ -280,6 +324,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
isOwner, isOwner,
isAdmin, isAdmin,
permissionConfig, permissionConfig,
isSuperUser,
generalSettings?.superUserModeEnabled,
]) ])
// Memoized callbacks to prevent infinite loops in child components // Memoized callbacks to prevent infinite loops in child components
@@ -308,9 +354,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
[activeSection] [activeSection]
) )
// React Query hook automatically loads and syncs settings
useGeneralSettings()
// Apply initial section from store when modal opens // Apply initial section from store when modal opens
useEffect(() => { useEffect(() => {
if (open && initialSection) { if (open && initialSection) {
@@ -523,6 +566,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />} {activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />} {activeSection === 'custom-tools' && <CustomTools />}
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />} {activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{activeSection === 'debug' && <Debug />}
</SModalMainBody> </SModalMainBody>
</SModalMain> </SModalMain>
</SModalContent> </SModalContent>

View File

@@ -11,7 +11,7 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
'Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser.', 'Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser.',
docsLink: 'https://docs.sim.ai/tools/browser_use', docsLink: 'https://docs.sim.ai/tools/browser_use',
category: 'tools', category: 'tools',
bgColor: '#E0E0E0', bgColor: '#181C1E',
icon: BrowserUseIcon, icon: BrowserUseIcon,
subBlocks: [ subBlocks: [
{ {

View File

@@ -34,7 +34,7 @@ export function OTPVerificationEmail({
const brand = getBrandConfig() const brand = getBrandConfig()
return ( return (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)}> <EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Your verification code:</Text> <Text style={baseStyles.paragraph}>Your verification code:</Text>
<Section style={baseStyles.codeContainer}> <Section style={baseStyles.codeContainer}>

View File

@@ -12,7 +12,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw
const brand = getBrandConfig() const brand = getBrandConfig()
return ( return (
<EmailLayout preview={`Reset your ${brand.name} password`}> <EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello {username},</Text> <Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>
A password reset was requested for your {brand.name} account. Click below to set a new A password reset was requested for your {brand.name} account. Click below to set a new

View File

@@ -13,7 +13,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
return ( return (
<EmailLayout preview={`Welcome to ${brand.name}`}> <EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hey ${userName},` : 'Hey,'} {userName ? `Hey ${userName},` : 'Hey,'}
</Text> </Text>

View File

@@ -23,7 +23,7 @@ export function CreditPurchaseEmail({
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account` const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
return ( return (
<EmailLayout preview={previewText}> <EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'} {userName ? `Hi ${userName},` : 'Hi,'}
</Text> </Text>

View File

@@ -18,7 +18,10 @@ export function EnterpriseSubscriptionEmail({
const effectiveLoginLink = loginLink || `${baseUrl}/login` const effectiveLoginLink = loginLink || `${baseUrl}/login`
return ( return (
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}> <EmailLayout
preview={`Your Enterprise Plan is now active on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello {userName},</Text> <Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced

View File

@@ -31,7 +31,7 @@ export function FreeTierUpgradeEmail({
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits` const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
return ( return (
<EmailLayout preview={previewText}> <EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'} {userName ? `Hi ${userName},` : 'Hi,'}
</Text> </Text>

View File

@@ -25,7 +25,7 @@ export function PaymentFailedEmail({
const previewText = `${brand.name}: Payment Failed - Action Required` const previewText = `${brand.name}: Payment Failed - Action Required`
return ( return (
<EmailLayout preview={previewText}> <EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'} {userName ? `Hi ${userName},` : 'Hi,'}
</Text> </Text>

View File

@@ -18,7 +18,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
const previewText = `${brand.name}: Your ${planName} plan is active` const previewText = `${brand.name}: Your ${planName} plan is active`
return ( return (
<EmailLayout preview={previewText}> <EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'} {userName ? `Hi ${userName},` : 'Hi,'}
</Text> </Text>

View File

@@ -25,7 +25,7 @@ export function UsageThresholdEmail({
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget` const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return ( return (
<EmailLayout preview={previewText}> <EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'} {userName ? `Hi ${userName},` : 'Hi,'}
</Text> </Text>

View File

@@ -20,7 +20,10 @@ export function CareersConfirmationEmail({
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
return ( return (
<EmailLayout preview={`Your application to ${brand.name} has been received`}> <EmailLayout
preview={`Your application to ${brand.name} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello {name},</Text> <Text style={baseStyles.paragraph}>Hello {name},</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>
We've received your application for <strong>{position}</strong>. Our team reviews every We've received your application for <strong>{position}</strong>. Our team reviews every

View File

@@ -40,7 +40,7 @@ export function CareersSubmissionEmail({
submittedDate = new Date(), submittedDate = new Date(),
}: CareersSubmissionEmailProps) { }: CareersSubmissionEmailProps) {
return ( return (
<EmailLayout preview={`New Career Application from ${name}`} hideFooter> <EmailLayout preview={`New Career Application from ${name}`} hideFooter showUnsubscribe={false}>
<Text <Text
style={{ style={{
...baseStyles.paragraph, ...baseStyles.paragraph,

View File

@@ -4,22 +4,29 @@ import { getBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags' import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
interface UnsubscribeOptions {
unsubscribeToken?: string
email?: string
}
interface EmailFooterProps { interface EmailFooterProps {
baseUrl?: string baseUrl?: string
unsubscribe?: UnsubscribeOptions
messageId?: string messageId?: string
/**
* Whether to show unsubscribe link. Defaults to true.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe?: boolean
} }
/** /**
* Email footer component styled to match Stripe's email design. * Email footer component styled to match Stripe's email design.
* Sits in the gray area below the main white card. * Sits in the gray area below the main white card.
*
* For non-transactional emails, the unsubscribe link uses placeholders
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
* by the mailer when sending.
*/ */
export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) { export function EmailFooter({
baseUrl = getBaseUrl(),
messageId,
showUnsubscribe = true,
}: EmailFooterProps) {
const brand = getBrandConfig() const brand = getBrandConfig()
const footerLinkStyle = { const footerLinkStyle = {
@@ -181,19 +188,20 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
{' '} {' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'> <a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
Terms of Service Terms of Service
</a>{' '}
{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a> </a>
{showUnsubscribe && (
<>
{' '}
{' '}
<a
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</>
)}
</td> </td>
<td style={baseStyles.gutter} width={spacing.gutter}> <td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp; &nbsp;

View File

@@ -11,13 +11,23 @@ interface EmailLayoutProps {
children: React.ReactNode children: React.ReactNode
/** Optional: hide footer for internal emails */ /** Optional: hide footer for internal emails */
hideFooter?: boolean hideFooter?: boolean
/**
* Whether to show unsubscribe link in footer.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe: boolean
} }
/** /**
* Shared email layout wrapper providing consistent structure. * Shared email layout wrapper providing consistent structure.
* Includes Html, Head, Body, Container with logo header, and Footer. * Includes Html, Head, Body, Container with logo header, and Footer.
*/ */
export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) { export function EmailLayout({
preview,
children,
hideFooter = false,
showUnsubscribe,
}: EmailLayoutProps) {
const brand = getBrandConfig() const brand = getBrandConfig()
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
@@ -43,7 +53,7 @@ export function EmailLayout({ preview, children, hideFooter = false }: EmailLayo
</Container> </Container>
{/* Footer in gray section */} {/* Footer in gray section */}
{!hideFooter && <EmailFooter baseUrl={baseUrl} />} {!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
</Body> </Body>
</Html> </Html>
) )

View File

@@ -54,6 +54,7 @@ export function BatchInvitationEmail({
return ( return (
<EmailLayout <EmailLayout
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`} preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
showUnsubscribe={false}
> >
<Text style={baseStyles.paragraph}>Hello,</Text> <Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>

View File

@@ -36,7 +36,10 @@ export function InvitationEmail({
} }
return ( return (
<EmailLayout preview={`You've been invited to join ${organizationName} on ${brand.name}`}> <EmailLayout
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text> <Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '} <strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}

View File

@@ -22,7 +22,10 @@ export function PollingGroupInvitationEmail({
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook' const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'
return ( return (
<EmailLayout preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}> <EmailLayout
preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text> <Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to <strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to

View File

@@ -41,6 +41,7 @@ export function WorkspaceInvitationEmail({
return ( return (
<EmailLayout <EmailLayout
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`} preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
showUnsubscribe={false}
> >
<Text style={baseStyles.paragraph}>Hello,</Text> <Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>

View File

@@ -73,7 +73,7 @@ export function WorkflowNotificationEmail({
: 'Your workflow completed successfully.' : 'Your workflow completed successfully.'
return ( return (
<EmailLayout preview={previewText}> <EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
<Text style={baseStyles.paragraph}>{message}</Text> <Text style={baseStyles.paragraph}>{message}</Text>

View File

@@ -32,7 +32,10 @@ export function HelpConfirmationEmail({
const typeLabel = getTypeLabel(type) const typeLabel = getTypeLabel(type)
return ( return (
<EmailLayout preview={`Your ${typeLabel.toLowerCase()} has been received`}> <EmailLayout
preview={`Your ${typeLabel.toLowerCase()} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text> <Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}> <Text style={baseStyles.paragraph}>
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you

View File

@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
{...props} {...props}
version='1.0' version='1.0'
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
width='150pt' width='28'
height='150pt' height='28'
viewBox='0 0 150 150' viewBox='0 0 150 150'
preserveAspectRatio='xMidYMid meet' preserveAspectRatio='xMidYMid meet'
> >
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'> <g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
<path <path
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666 d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111 l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111

View File

@@ -1,599 +0,0 @@
/**
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/execution/cancellation', () => ({
isExecutionCancelled: vi.fn(),
isRedisCancellationEnabled: vi.fn(),
}))
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import type { DAG, DAGNode } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { ExecutionEngine } from './engine'
function createMockBlock(id: string): SerializedBlock {
return {
id,
metadata: { id: 'test', name: 'Test Block' },
position: { x: 0, y: 0 },
config: { tool: '', params: {} },
inputs: {},
outputs: {},
enabled: true,
}
}
function createMockNode(id: string, blockType = 'test'): DAGNode {
return {
id,
block: {
...createMockBlock(id),
metadata: { id: blockType, name: `Block ${id}` },
},
outgoingEdges: new Map(),
incomingEdges: new Set(),
metadata: {},
}
}
function createMockContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {
return {
workflowId: 'test-workflow',
workspaceId: 'test-workspace',
executionId: 'test-execution',
userId: 'test-user',
blockStates: new Map(),
executedBlocks: new Set(),
blockLogs: [],
loopExecutions: new Map(),
parallelExecutions: new Map(),
completedLoops: new Set(),
activeExecutionPath: new Set(),
metadata: {
executionId: 'test-execution',
startTime: new Date().toISOString(),
pendingBlocks: [],
},
envVars: {},
...overrides,
}
}
function createMockDAG(nodes: DAGNode[]): DAG {
const nodeMap = new Map<string, DAGNode>()
nodes.forEach((node) => nodeMap.set(node.id, node))
return {
nodes: nodeMap,
loopConfigs: new Map(),
parallelConfigs: new Map(),
}
}
interface MockEdgeManager extends EdgeManager {
processOutgoingEdges: ReturnType<typeof vi.fn>
}
function createMockEdgeManager(
processOutgoingEdgesImpl?: (node: DAGNode) => string[]
): MockEdgeManager {
const mockFn = vi.fn().mockImplementation(processOutgoingEdgesImpl || (() => []))
return {
processOutgoingEdges: mockFn,
isNodeReady: vi.fn().mockReturnValue(true),
deactivateEdgeAndDescendants: vi.fn(),
restoreIncomingEdge: vi.fn(),
clearDeactivatedEdges: vi.fn(),
clearDeactivatedEdgesForNodes: vi.fn(),
} as unknown as MockEdgeManager
}
interface MockNodeOrchestrator extends NodeExecutionOrchestrator {
executionCount: number
}
function createMockNodeOrchestrator(executeDelay = 0): MockNodeOrchestrator {
const mock = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async () => {
mock.executionCount++
if (executeDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, executeDelay))
}
return { nodeId: 'test', output: {}, isFinalOutput: false }
}),
handleNodeCompletion: vi.fn(),
}
return mock as unknown as MockNodeOrchestrator
}
describe('ExecutionEngine', () => {
beforeEach(() => {
vi.clearAllMocks()
;(isExecutionCancelled as Mock).mockResolvedValue(false)
;(isRedisCancellationEnabled as Mock).mockReturnValue(false)
})
afterEach(() => {
vi.useRealTimers()
})
describe('Normal execution', () => {
it('should execute a simple linear workflow', async () => {
const startNode = createMockNode('start', 'starter')
const endNode = createMockNode('end', 'function')
startNode.outgoingEdges.set('edge1', { target: 'end' })
endNode.incomingEdges.add('start')
const dag = createMockDAG([startNode, endNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['end']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(2)
})
it('should mark execution as successful when completed without cancellation', async () => {
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(result.status).toBeUndefined()
})
it('should execute all nodes in a multi-node workflow', async () => {
const nodes = [
createMockNode('start', 'starter'),
createMockNode('middle1', 'function'),
createMockNode('middle2', 'function'),
createMockNode('end', 'function'),
]
nodes[0].outgoingEdges.set('e1', { target: 'middle1' })
nodes[1].outgoingEdges.set('e2', { target: 'middle2' })
nodes[2].outgoingEdges.set('e3', { target: 'end' })
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['middle1']
if (node.id === 'middle1') return ['middle2']
if (node.id === 'middle2') return ['end']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(4)
})
})
describe('Cancellation via AbortSignal', () => {
it('should stop execution immediately when aborted before start', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBe(0)
})
it('should stop execution when aborted mid-workflow', async () => {
const abortController = new AbortController()
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext({ abortSignal: abortController.signal })
let callCount = 0
const edgeManager = createMockEdgeManager((node) => {
callCount++
if (callCount === 2) abortController.abort()
const idx = Number.parseInt(node.id.replace('node', ''))
if (idx < 4) return [`node${idx + 1}`]
return []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.success).toBe(false)
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBeLessThan(5)
})
it('should not wait for slow executions when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const slowNode = createMockNode('slow', 'function')
startNode.outgoingEdges.set('edge1', { target: 'slow' })
const dag = createMockDAG([startNode, slowNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['slow']
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(500)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 50)
const startTime = Date.now()
const result = await executionPromise
const duration = Date.now() - startTime
expect(result.status).toBe('cancelled')
expect(duration).toBeLessThan(400)
})
it('should return cancelled status even if error thrown during cancellation', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(result.success).toBe(false)
})
})
describe('Cancellation via Redis', () => {
it('should check Redis for cancellation when enabled', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(false)
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('start')
expect(isExecutionCancelled as Mock).toHaveBeenCalled()
})
it('should stop execution when Redis reports cancellation', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
let checkCount = 0
;(isExecutionCancelled as Mock).mockImplementation(async () => {
checkCount++
return checkCount > 1
})
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager((node) => {
const idx = Number.parseInt(node.id.replace('node', ''))
if (idx < 4) return [`node${idx + 1}`]
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(150)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.success).toBe(false)
expect(result.status).toBe('cancelled')
})
it('should respect cancellation check interval', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(false)
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('start')
expect((isExecutionCancelled as Mock).mock.calls.length).toBeGreaterThanOrEqual(1)
})
})
describe('Loop execution with cancellation', () => {
it('should break out of loop when cancelled mid-iteration', async () => {
const abortController = new AbortController()
const loopStartNode = createMockNode('loop-start', 'loop_sentinel')
loopStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId: 'loop1' }
const loopBodyNode = createMockNode('loop-body', 'function')
loopBodyNode.metadata = { isLoopNode: true, loopId: 'loop1' }
const loopEndNode = createMockNode('loop-end', 'loop_sentinel')
loopEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId: 'loop1' }
loopStartNode.outgoingEdges.set('edge1', { target: 'loop-body' })
loopBodyNode.outgoingEdges.set('edge2', { target: 'loop-end' })
loopEndNode.outgoingEdges.set('loop_continue', {
target: 'loop-start',
sourceHandle: 'loop_continue',
})
const dag = createMockDAG([loopStartNode, loopBodyNode, loopEndNode])
const context = createMockContext({ abortSignal: abortController.signal })
let iterationCount = 0
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'loop-start') return ['loop-body']
if (node.id === 'loop-body') return ['loop-end']
if (node.id === 'loop-end') {
iterationCount++
if (iterationCount === 3) abortController.abort()
return ['loop-start']
}
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(5)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('loop-start')
expect(result.status).toBe('cancelled')
expect(iterationCount).toBeLessThan(100)
})
})
describe('Parallel execution with cancellation', () => {
it('should stop queueing parallel branches when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const parallelNodes = Array.from({ length: 10 }, (_, i) =>
createMockNode(`parallel${i}`, 'function')
)
parallelNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `parallel${i}` })
})
const dag = createMockDAG([startNode, ...parallelNodes])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') {
return parallelNodes.map((_, i) => `parallel${i}`)
}
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(50)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 30)
const result = await executionPromise
expect(result.status).toBe('cancelled')
expect(nodeOrchestrator.executionCount).toBeLessThan(11)
})
it('should not wait for all parallel branches when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const slowNodes = Array.from({ length: 5 }, (_, i) => createMockNode(`slow${i}`, 'function'))
slowNodes.forEach((_, i) => {
startNode.outgoingEdges.set(`edge${i}`, { target: `slow${i}` })
})
const dag = createMockDAG([startNode, ...slowNodes])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return slowNodes.map((_, i) => `slow${i}`)
return []
})
const nodeOrchestrator = createMockNodeOrchestrator(200)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const executionPromise = engine.run('start')
setTimeout(() => abortController.abort(), 50)
const startTime = Date.now()
const result = await executionPromise
const duration = Date.now() - startTime
expect(result.status).toBe('cancelled')
expect(duration).toBeLessThan(500)
})
})
describe('Edge cases', () => {
it('should handle empty DAG gracefully', async () => {
const dag = createMockDAG([])
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run()
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(0)
})
it('should preserve partial output when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const endNode = createMockNode('end', 'function')
endNode.outgoingEdges = new Map()
startNode.outgoingEdges.set('edge1', { target: 'end' })
const dag = createMockDAG([startNode, endNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'start') return ['end']
return []
})
const nodeOrchestrator = {
executionCount: 0,
executeNode: vi.fn().mockImplementation(async (_ctx: ExecutionContext, nodeId: string) => {
if (nodeId === 'start') {
return { nodeId: 'start', output: { startData: 'value' }, isFinalOutput: false }
}
abortController.abort()
return { nodeId: 'end', output: { endData: 'value' }, isFinalOutput: true }
}),
handleNodeCompletion: vi.fn(),
} as unknown as MockNodeOrchestrator
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.status).toBe('cancelled')
expect(result.output).toBeDefined()
})
it('should populate metadata on cancellation', async () => {
const abortController = new AbortController()
abortController.abort()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.metadata).toBeDefined()
expect(result.metadata.endTime).toBeDefined()
expect(result.metadata.duration).toBeDefined()
})
it('should return logs even when cancelled', async () => {
const abortController = new AbortController()
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({ abortSignal: abortController.signal })
context.blockLogs.push({
blockId: 'test',
blockName: 'Test',
blockType: 'test',
startedAt: '',
endedAt: '',
durationMs: 0,
success: true,
})
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
abortController.abort()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('start')
expect(result.logs).toBeDefined()
expect(result.logs.length).toBeGreaterThan(0)
})
})
describe('Cancellation flag behavior', () => {
it('should set cancelledFlag when abort signal fires', async () => {
const abortController = new AbortController()
const nodes = Array.from({ length: 3 }, (_, i) => createMockNode(`node${i}`, 'function'))
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].outgoingEdges.set(`e${i}`, { target: `node${i + 1}` })
}
const dag = createMockDAG(nodes)
const context = createMockContext({ abortSignal: abortController.signal })
const edgeManager = createMockEdgeManager((node) => {
if (node.id === 'node0') {
abortController.abort()
return ['node1']
}
return node.id === 'node1' ? ['node2'] : []
})
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run('node0')
expect(result.status).toBe('cancelled')
})
it('should cache Redis cancellation result', async () => {
;(isRedisCancellationEnabled as Mock).mockReturnValue(true)
;(isExecutionCancelled as Mock).mockResolvedValue(true)
const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`, 'function'))
const dag = createMockDAG(nodes)
const context = createMockContext()
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
await engine.run('node0')
expect((isExecutionCancelled as Mock).mock.calls.length).toBeLessThanOrEqual(3)
})
})
})

View File

@@ -28,8 +28,6 @@ export class ExecutionEngine {
private lastCancellationCheck = 0 private lastCancellationCheck = 0
private readonly useRedisCancellation: boolean private readonly useRedisCancellation: boolean
private readonly CANCELLATION_CHECK_INTERVAL_MS = 500 private readonly CANCELLATION_CHECK_INTERVAL_MS = 500
private abortPromise: Promise<void> | null = null
private abortResolve: (() => void) | null = null
constructor( constructor(
private context: ExecutionContext, private context: ExecutionContext,
@@ -39,34 +37,6 @@ export class ExecutionEngine {
) { ) {
this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true this.allowResumeTriggers = this.context.metadata.resumeFromSnapshot === true
this.useRedisCancellation = isRedisCancellationEnabled() && !!this.context.executionId this.useRedisCancellation = isRedisCancellationEnabled() && !!this.context.executionId
this.initializeAbortHandler()
}
/**
* Sets up a single abort promise that can be reused throughout execution.
* This avoids creating multiple event listeners and potential memory leaks.
*/
private initializeAbortHandler(): void {
if (!this.context.abortSignal) return
if (this.context.abortSignal.aborted) {
this.cancelledFlag = true
this.abortPromise = Promise.resolve()
return
}
this.abortPromise = new Promise<void>((resolve) => {
this.abortResolve = resolve
})
this.context.abortSignal.addEventListener(
'abort',
() => {
this.cancelledFlag = true
this.abortResolve?.()
},
{ once: true }
)
} }
private async checkCancellation(): Promise<boolean> { private async checkCancellation(): Promise<boolean> {
@@ -103,15 +73,12 @@ export class ExecutionEngine {
this.initializeQueue(triggerBlockId) this.initializeQueue(triggerBlockId)
while (this.hasWork()) { while (this.hasWork()) {
if (await this.checkCancellation()) { if ((await this.checkCancellation()) && this.executing.size === 0) {
break break
} }
await this.processQueue() await this.processQueue()
} }
await this.waitForAllExecutions()
if (!this.cancelledFlag) {
await this.waitForAllExecutions()
}
if (this.pausedBlocks.size > 0) { if (this.pausedBlocks.size > 0) {
return this.buildPausedResult(startTime) return this.buildPausedResult(startTime)
@@ -197,7 +164,11 @@ export class ExecutionEngine {
private trackExecution(promise: Promise<void>): void { private trackExecution(promise: Promise<void>): void {
this.executing.add(promise) this.executing.add(promise)
promise.catch(() => {}) // Attach error handler to prevent unhandled rejection warnings
// The actual error handling happens in waitForAllExecutions/waitForAnyExecution
promise.catch(() => {
// Error will be properly handled by Promise.all/Promise.race in wait methods
})
promise.finally(() => { promise.finally(() => {
this.executing.delete(promise) this.executing.delete(promise)
}) })
@@ -205,30 +176,12 @@ export class ExecutionEngine {
private async waitForAnyExecution(): Promise<void> { private async waitForAnyExecution(): Promise<void> {
if (this.executing.size > 0) { if (this.executing.size > 0) {
const abortPromise = this.getAbortPromise() await Promise.race(this.executing)
if (abortPromise) {
await Promise.race([...this.executing, abortPromise])
} else {
await Promise.race(this.executing)
}
} }
} }
private async waitForAllExecutions(): Promise<void> { private async waitForAllExecutions(): Promise<void> {
const abortPromise = this.getAbortPromise() await Promise.all(Array.from(this.executing))
if (abortPromise) {
await Promise.race([Promise.all(this.executing), abortPromise])
} else {
await Promise.all(this.executing)
}
}
/**
* Returns the cached abort promise. This is safe to call multiple times
* as it reuses the same promise instance created during initialization.
*/
private getAbortPromise(): Promise<void> | null {
return this.abortPromise
} }
private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> { private async withQueueLock<T>(fn: () => Promise<T> | T): Promise<T> {
@@ -324,7 +277,7 @@ export class ExecutionEngine {
this.trackExecution(promise) this.trackExecution(promise)
} }
if (this.executing.size > 0 && !this.cancelledFlag) { if (this.executing.size > 0) {
await this.waitForAnyExecution() await this.waitForAnyExecution()
} }
} }
@@ -383,6 +336,7 @@ export class ExecutionEngine {
this.addMultipleToQueue(readyNodes) this.addMultipleToQueue(readyNodes)
// Check for dynamically added nodes (e.g., from parallel expansion)
if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) { if (this.context.pendingDynamicNodes && this.context.pendingDynamicNodes.length > 0) {
const dynamicNodes = this.context.pendingDynamicNodes const dynamicNodes = this.context.pendingDynamicNodes
this.context.pendingDynamicNodes = [] this.context.pendingDynamicNodes = []

View File

@@ -203,10 +203,11 @@ function resolveProjectSelector(
): SelectorResolution { ): SelectorResolution {
const serviceId = subBlock.serviceId const serviceId = subBlock.serviceId
const context = buildBaseContext(args) const context = buildBaseContext(args)
const selectorId = subBlock.canonicalParamId ?? subBlock.id
switch (serviceId) { switch (serviceId) {
case 'linear': { case 'linear': {
const key: SelectorKey = subBlock.id === 'teamId' ? 'linear.teams' : 'linear.projects' const key: SelectorKey = selectorId === 'teamId' ? 'linear.teams' : 'linear.projects'
return { key, context, allowSearch: true } return { key, context, allowSearch: true }
} }
case 'jira': case 'jira':

View File

@@ -21,6 +21,8 @@ import {
type BatchToggleEnabledOperation, type BatchToggleEnabledOperation,
type BatchToggleHandlesOperation, type BatchToggleHandlesOperation,
type BatchUpdateParentOperation, type BatchUpdateParentOperation,
captureLatestEdges,
captureLatestSubBlockValues,
createOperationEntry, createOperationEntry,
runWithUndoRedoRecordingSuspended, runWithUndoRedoRecordingSuspended,
type UpdateParentOperation, type UpdateParentOperation,
@@ -28,7 +30,6 @@ import {
} from '@/stores/undo-redo' } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types' import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -445,34 +446,19 @@ export function useUndoRedo() {
break break
} }
const latestEdges = useWorkflowStore const latestEdges = captureLatestEdges(
.getState() useWorkflowStore.getState().edges,
.edges.filter( existingBlockIds
(e) => existingBlockIds.includes(e.source) || existingBlockIds.includes(e.target) )
)
batchRemoveOp.data.edgeSnapshots = latestEdges batchRemoveOp.data.edgeSnapshots = latestEdges
const latestSubBlockValues: Record<string, Record<string, unknown>> = {} const latestSubBlockValues = captureLatestSubBlockValues(
existingBlockIds.forEach((blockId) => { useWorkflowStore.getState().blocks,
const merged = mergeSubblockState( activeWorkflowId,
useWorkflowStore.getState().blocks, existingBlockIds
activeWorkflowId, )
blockId
)
const block = merged[blockId]
if (block?.subBlocks) {
const values: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
values[subBlockId] = subBlock.value
}
})
if (Object.keys(values).length > 0) {
latestSubBlockValues[blockId] = values
}
}
})
batchRemoveOp.data.subBlockValues = latestSubBlockValues batchRemoveOp.data.subBlockValues = latestSubBlockValues
;(entry.operation as BatchAddBlocksOperation).data.subBlockValues = latestSubBlockValues
addToQueue({ addToQueue({
id: opId, id: opId,
@@ -1153,6 +1139,20 @@ export function useUndoRedo() {
break break
} }
const latestEdges = captureLatestEdges(
useWorkflowStore.getState().edges,
existingBlockIds
)
batchOp.data.edgeSnapshots = latestEdges
const latestSubBlockValues = captureLatestSubBlockValues(
useWorkflowStore.getState().blocks,
activeWorkflowId,
existingBlockIds
)
batchOp.data.subBlockValues = latestSubBlockValues
;(entry.inverse as BatchAddBlocksOperation).data.subBlockValues = latestSubBlockValues
addToQueue({ addToQueue({
id: opId, id: opId,
operation: { operation: {

View File

@@ -29,13 +29,11 @@ export class DocsChunker {
private readonly baseUrl: string private readonly baseUrl: string
constructor(options: DocsChunkerOptions = {}) { constructor(options: DocsChunkerOptions = {}) {
// Use the existing TextChunker for chunking logic
this.textChunker = new TextChunker({ this.textChunker = new TextChunker({
chunkSize: options.chunkSize ?? 300, // Max 300 tokens per chunk chunkSize: options.chunkSize ?? 300, // Max 300 tokens per chunk
minCharactersPerChunk: options.minCharactersPerChunk ?? 1, minCharactersPerChunk: options.minCharactersPerChunk ?? 1,
chunkOverlap: options.chunkOverlap ?? 50, chunkOverlap: options.chunkOverlap ?? 50,
}) })
// Use localhost docs in development, production docs otherwise
this.baseUrl = options.baseUrl ?? 'https://docs.sim.ai' this.baseUrl = options.baseUrl ?? 'https://docs.sim.ai'
} }
@@ -74,24 +72,18 @@ export class DocsChunker {
const content = await fs.readFile(filePath, 'utf-8') const content = await fs.readFile(filePath, 'utf-8')
const relativePath = path.relative(basePath, filePath) const relativePath = path.relative(basePath, filePath)
// Parse frontmatter and content
const { data: frontmatter, content: markdownContent } = this.parseFrontmatter(content) const { data: frontmatter, content: markdownContent } = this.parseFrontmatter(content)
// Extract headers from the content
const headers = this.extractHeaders(markdownContent) const headers = this.extractHeaders(markdownContent)
// Generate document URL
const documentUrl = this.generateDocumentUrl(relativePath) const documentUrl = this.generateDocumentUrl(relativePath)
// Split content into chunks
const textChunks = await this.splitContent(markdownContent) const textChunks = await this.splitContent(markdownContent)
// Generate embeddings for all chunks at once (batch processing)
logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`) logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`)
const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : [] const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : []
const embeddingModel = 'text-embedding-3-small' const embeddingModel = 'text-embedding-3-small'
// Convert to DocChunk objects with header context and embeddings
const chunks: DocChunk[] = [] const chunks: DocChunk[] = []
let currentPosition = 0 let currentPosition = 0
@@ -100,7 +92,6 @@ export class DocsChunker {
const chunkStart = currentPosition const chunkStart = currentPosition
const chunkEnd = currentPosition + chunkText.length const chunkEnd = currentPosition + chunkText.length
// Find the most relevant header for this chunk
const relevantHeader = this.findRelevantHeader(headers, chunkStart) const relevantHeader = this.findRelevantHeader(headers, chunkStart)
const chunk: DocChunk = { const chunk: DocChunk = {
@@ -186,11 +177,21 @@ export class DocsChunker {
/** /**
* Generate document URL from relative path * Generate document URL from relative path
* Handles index.mdx files specially - they are served at the parent directory path
*/ */
private generateDocumentUrl(relativePath: string): string { private generateDocumentUrl(relativePath: string): string {
// Convert file path to URL path // Convert file path to URL path
// e.g., "tools/knowledge.mdx" -> "/tools/knowledge" // e.g., "tools/knowledge.mdx" -> "/tools/knowledge"
const urlPath = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/') // Handle Windows paths // e.g., "triggers/index.mdx" -> "/triggers" (NOT "/triggers/index")
let urlPath = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/') // Handle Windows paths
// In fumadocs, index.mdx files are served at the parent directory path
// e.g., "triggers/index" -> "triggers"
if (urlPath.endsWith('/index')) {
urlPath = urlPath.slice(0, -6) // Remove "/index"
} else if (urlPath === 'index') {
urlPath = '' // Root index.mdx
}
return `${this.baseUrl}/${urlPath}` return `${this.baseUrl}/${urlPath}`
} }
@@ -201,7 +202,6 @@ export class DocsChunker {
private findRelevantHeader(headers: HeaderInfo[], position: number): HeaderInfo | null { private findRelevantHeader(headers: HeaderInfo[], position: number): HeaderInfo | null {
if (headers.length === 0) return null if (headers.length === 0) return null
// Find the last header that comes before this position
let relevantHeader: HeaderInfo | null = null let relevantHeader: HeaderInfo | null = null
for (const header of headers) { for (const header of headers) {
@@ -219,23 +219,18 @@ export class DocsChunker {
* Split content into chunks using the existing TextChunker with table awareness * Split content into chunks using the existing TextChunker with table awareness
*/ */
private async splitContent(content: string): Promise<string[]> { private async splitContent(content: string): Promise<string[]> {
// Clean the content first
const cleanedContent = this.cleanContent(content) const cleanedContent = this.cleanContent(content)
// Detect table boundaries to avoid splitting them
const tableBoundaries = this.detectTableBoundaries(cleanedContent) const tableBoundaries = this.detectTableBoundaries(cleanedContent)
// Use the existing TextChunker
const chunks = await this.textChunker.chunk(cleanedContent) const chunks = await this.textChunker.chunk(cleanedContent)
// Post-process chunks to ensure tables aren't split
const processedChunks = this.mergeTableChunks( const processedChunks = this.mergeTableChunks(
chunks.map((chunk) => chunk.text), chunks.map((chunk) => chunk.text),
tableBoundaries, tableBoundaries,
cleanedContent cleanedContent
) )
// Ensure no chunk exceeds 300 tokens
const finalChunks = this.enforceSizeLimit(processedChunks) const finalChunks = this.enforceSizeLimit(processedChunks)
return finalChunks return finalChunks
@@ -273,7 +268,6 @@ export class DocsChunker {
const [, frontmatterText, markdownContent] = match const [, frontmatterText, markdownContent] = match
const data: Frontmatter = {} const data: Frontmatter = {}
// Simple YAML parsing for title and description
const lines = frontmatterText.split('\n') const lines = frontmatterText.split('\n')
for (const line of lines) { for (const line of lines) {
const colonIndex = line.indexOf(':') const colonIndex = line.indexOf(':')
@@ -294,7 +288,6 @@ export class DocsChunker {
* Estimate token count (rough approximation) * Estimate token count (rough approximation)
*/ */
private estimateTokens(text: string): number { private estimateTokens(text: string): number {
// Rough approximation: 1 token ≈ 4 characters
return Math.ceil(text.length / 4) return Math.ceil(text.length / 4)
} }
@@ -311,17 +304,13 @@ export class DocsChunker {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim() const line = lines[i].trim()
// Detect table start (markdown table row with pipes)
if (line.includes('|') && line.split('|').length >= 3 && !inTable) { if (line.includes('|') && line.split('|').length >= 3 && !inTable) {
// Check if next line is table separator (contains dashes and pipes)
const nextLine = lines[i + 1]?.trim() const nextLine = lines[i + 1]?.trim()
if (nextLine?.includes('|') && nextLine.includes('-')) { if (nextLine?.includes('|') && nextLine.includes('-')) {
inTable = true inTable = true
tableStart = i tableStart = i
} }
} } else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
// Detect table end (empty line or non-table content)
else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
tables.push({ tables.push({
start: this.getCharacterPosition(lines, tableStart), start: this.getCharacterPosition(lines, tableStart),
end: this.getCharacterPosition(lines, i - 1) + lines[i - 1]?.length || 0, end: this.getCharacterPosition(lines, i - 1) + lines[i - 1]?.length || 0,
@@ -330,7 +319,6 @@ export class DocsChunker {
} }
} }
// Handle table at end of content
if (inTable && tableStart >= 0) { if (inTable && tableStart >= 0) {
tables.push({ tables.push({
start: this.getCharacterPosition(lines, tableStart), start: this.getCharacterPosition(lines, tableStart),
@@ -367,7 +355,6 @@ export class DocsChunker {
const chunkStart = originalContent.indexOf(chunk, currentPosition) const chunkStart = originalContent.indexOf(chunk, currentPosition)
const chunkEnd = chunkStart + chunk.length const chunkEnd = chunkStart + chunk.length
// Check if this chunk intersects with any table
const intersectsTable = tableBoundaries.some( const intersectsTable = tableBoundaries.some(
(table) => (table) =>
(chunkStart >= table.start && chunkStart <= table.end) || (chunkStart >= table.start && chunkStart <= table.end) ||
@@ -376,7 +363,6 @@ export class DocsChunker {
) )
if (intersectsTable) { if (intersectsTable) {
// Find which table(s) this chunk intersects with
const affectedTables = tableBoundaries.filter( const affectedTables = tableBoundaries.filter(
(table) => (table) =>
(chunkStart >= table.start && chunkStart <= table.end) || (chunkStart >= table.start && chunkStart <= table.end) ||
@@ -384,12 +370,10 @@ export class DocsChunker {
(chunkStart <= table.start && chunkEnd >= table.end) (chunkStart <= table.start && chunkEnd >= table.end)
) )
// Create a chunk that includes the complete table(s)
const minStart = Math.min(chunkStart, ...affectedTables.map((t) => t.start)) const minStart = Math.min(chunkStart, ...affectedTables.map((t) => t.start))
const maxEnd = Math.max(chunkEnd, ...affectedTables.map((t) => t.end)) const maxEnd = Math.max(chunkEnd, ...affectedTables.map((t) => t.end))
const completeChunk = originalContent.slice(minStart, maxEnd) const completeChunk = originalContent.slice(minStart, maxEnd)
// Only add if we haven't already included this content
if (!mergedChunks.some((existing) => existing.includes(completeChunk.trim()))) { if (!mergedChunks.some((existing) => existing.includes(completeChunk.trim()))) {
mergedChunks.push(completeChunk.trim()) mergedChunks.push(completeChunk.trim())
} }
@@ -400,7 +384,7 @@ export class DocsChunker {
currentPosition = chunkEnd currentPosition = chunkEnd
} }
return mergedChunks.filter((chunk) => chunk.length > 50) // Filter out tiny chunks return mergedChunks.filter((chunk) => chunk.length > 50)
} }
/** /**
@@ -413,10 +397,8 @@ export class DocsChunker {
const tokens = this.estimateTokens(chunk) const tokens = this.estimateTokens(chunk)
if (tokens <= 300) { if (tokens <= 300) {
// Chunk is within limit
finalChunks.push(chunk) finalChunks.push(chunk)
} else { } else {
// Chunk is too large - split it
const lines = chunk.split('\n') const lines = chunk.split('\n')
let currentChunk = '' let currentChunk = ''
@@ -426,7 +408,6 @@ export class DocsChunker {
if (this.estimateTokens(testChunk) <= 300) { if (this.estimateTokens(testChunk) <= 300) {
currentChunk = testChunk currentChunk = testChunk
} else { } else {
// Adding this line would exceed limit
if (currentChunk.trim()) { if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim()) finalChunks.push(currentChunk.trim())
} }
@@ -434,7 +415,6 @@ export class DocsChunker {
} }
} }
// Add final chunk if it has content
if (currentChunk.trim()) { if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim()) finalChunks.push(currentChunk.trim())
} }

View File

@@ -209,13 +209,17 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
} }
} }
const variablesArray = Object.values(byName) // Convert byName (keyed by name) to record keyed by ID for the API
const variablesRecord: Record<string, any> = {}
for (const v of Object.values(byName)) {
variablesRecord[v.id] = v
}
// POST full variables array to persist // POST full variables record to persist
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, { const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesArray }), body: JSON.stringify({ variables: variablesRecord }),
}) })
if (!res.ok) { if (!res.ok) {
const txt = await res.text().catch(() => '') const txt = await res.text().catch(() => '')

View File

@@ -817,6 +817,8 @@ function normalizeResponseFormat(value: any): string {
interface EdgeHandleValidationResult { interface EdgeHandleValidationResult {
valid: boolean valid: boolean
error?: string error?: string
/** The normalized handle to use (e.g., simple 'if' normalized to 'condition-{uuid}') */
normalizedHandle?: string
} }
/** /**
@@ -851,13 +853,6 @@ function validateSourceHandleForBlock(
} }
case 'condition': { case 'condition': {
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`,
}
}
const conditionsValue = sourceBlock?.subBlocks?.conditions?.value const conditionsValue = sourceBlock?.subBlocks?.conditions?.value
if (!conditionsValue) { if (!conditionsValue) {
return { return {
@@ -866,6 +861,8 @@ function validateSourceHandleForBlock(
} }
} }
// validateConditionHandle accepts simple format (if, else-if-0, else),
// legacy format (condition-{blockId}-if), and internal ID format (condition-{uuid})
return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue) return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue)
} }
@@ -879,13 +876,6 @@ function validateSourceHandleForBlock(
} }
case 'router_v2': { case 'router_v2': {
if (!sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for router_v2 block. Must start with "${EDGE.ROUTER_PREFIX}"`,
}
}
const routesValue = sourceBlock?.subBlocks?.routes?.value const routesValue = sourceBlock?.subBlocks?.routes?.value
if (!routesValue) { if (!routesValue) {
return { return {
@@ -894,6 +884,8 @@ function validateSourceHandleForBlock(
} }
} }
// validateRouterHandle accepts simple format (route-0, route-1),
// legacy format (router-{blockId}-route-1), and internal ID format (router-{uuid})
return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue) return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue)
} }
@@ -910,7 +902,12 @@ function validateSourceHandleForBlock(
/** /**
* Validates condition handle references a valid condition in the block. * Validates condition handle references a valid condition in the block.
* Accepts both internal IDs (condition-blockId-if) and semantic keys (condition-blockId-else-if) * Accepts multiple formats:
* - Simple format: "if", "else-if-0", "else-if-1", "else"
* - Legacy semantic format: "condition-{blockId}-if", "condition-{blockId}-else-if"
* - Internal ID format: "condition-{conditionId}"
*
* Returns the normalized handle (condition-{conditionId}) for storage.
*/ */
function validateConditionHandle( function validateConditionHandle(
sourceHandle: string, sourceHandle: string,
@@ -943,48 +940,80 @@ function validateConditionHandle(
} }
} }
const validHandles = new Set<string>() // Build a map of all valid handle formats -> normalized handle (condition-{conditionId})
const semanticPrefix = `condition-${blockId}-` const handleToNormalized = new Map<string, string>()
let elseIfCount = 0 const legacySemanticPrefix = `condition-${blockId}-`
let elseIfIndex = 0
for (const condition of conditions) { for (const condition of conditions) {
if (condition.id) { if (!condition.id) continue
validHandles.add(`condition-${condition.id}`)
}
const normalizedHandle = `condition-${condition.id}`
const title = condition.title?.toLowerCase()
// Always accept internal ID format
handleToNormalized.set(normalizedHandle, normalizedHandle)
if (title === 'if') {
// Simple format: "if"
handleToNormalized.set('if', normalizedHandle)
// Legacy format: "condition-{blockId}-if"
handleToNormalized.set(`${legacySemanticPrefix}if`, normalizedHandle)
} else if (title === 'else if') {
// Simple format: "else-if-0", "else-if-1", etc. (0-indexed)
handleToNormalized.set(`else-if-${elseIfIndex}`, normalizedHandle)
// Legacy format: "condition-{blockId}-else-if" for first, "condition-{blockId}-else-if-2" for second
if (elseIfIndex === 0) {
handleToNormalized.set(`${legacySemanticPrefix}else-if`, normalizedHandle)
} else {
handleToNormalized.set(
`${legacySemanticPrefix}else-if-${elseIfIndex + 1}`,
normalizedHandle
)
}
elseIfIndex++
} else if (title === 'else') {
// Simple format: "else"
handleToNormalized.set('else', normalizedHandle)
// Legacy format: "condition-{blockId}-else"
handleToNormalized.set(`${legacySemanticPrefix}else`, normalizedHandle)
}
}
const normalizedHandle = handleToNormalized.get(sourceHandle)
if (normalizedHandle) {
return { valid: true, normalizedHandle }
}
// Build list of valid simple format options for error message
const simpleOptions: string[] = []
elseIfIndex = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase() const title = condition.title?.toLowerCase()
if (title === 'if') { if (title === 'if') {
validHandles.add(`${semanticPrefix}if`) simpleOptions.push('if')
} else if (title === 'else if') { } else if (title === 'else if') {
elseIfCount++ simpleOptions.push(`else-if-${elseIfIndex}`)
validHandles.add( elseIfIndex++
elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}`
)
} else if (title === 'else') { } else if (title === 'else') {
validHandles.add(`${semanticPrefix}else`) simpleOptions.push('else')
} }
} }
if (validHandles.has(sourceHandle)) {
return { valid: true }
}
const validOptions = Array.from(validHandles).slice(0, 5)
const moreCount = validHandles.size - validOptions.length
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return { return {
valid: false, valid: false,
error: `Invalid condition handle "${sourceHandle}". Valid handles: ${validOptionsStr}`, error: `Invalid condition handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
} }
} }
/** /**
* Validates router handle references a valid route in the block. * Validates router handle references a valid route in the block.
* Accepts both internal IDs (router-{routeId}) and semantic keys (router-{blockId}-route-1) * Accepts multiple formats:
* - Simple format: "route-0", "route-1", "route-2" (0-indexed)
* - Legacy semantic format: "router-{blockId}-route-1" (1-indexed)
* - Internal ID format: "router-{routeId}"
*
* Returns the normalized handle (router-{routeId}) for storage.
*/ */
function validateRouterHandle( function validateRouterHandle(
sourceHandle: string, sourceHandle: string,
@@ -1017,47 +1046,48 @@ function validateRouterHandle(
} }
} }
const validHandles = new Set<string>() // Build a map of all valid handle formats -> normalized handle (router-{routeId})
const semanticPrefix = `router-${blockId}-` const handleToNormalized = new Map<string, string>()
const legacySemanticPrefix = `router-${blockId}-`
for (let i = 0; i < routes.length; i++) { for (let i = 0; i < routes.length; i++) {
const route = routes[i] const route = routes[i]
if (!route.id) continue
// Accept internal ID format: router-{uuid} const normalizedHandle = `router-${route.id}`
if (route.id) {
validHandles.add(`router-${route.id}`)
}
// Accept 1-indexed route number format: router-{blockId}-route-1, router-{blockId}-route-2, etc. // Always accept internal ID format: router-{uuid}
validHandles.add(`${semanticPrefix}route-${i + 1}`) handleToNormalized.set(normalizedHandle, normalizedHandle)
// Simple format: route-0, route-1, etc. (0-indexed)
handleToNormalized.set(`route-${i}`, normalizedHandle)
// Legacy 1-indexed route number format: router-{blockId}-route-1
handleToNormalized.set(`${legacySemanticPrefix}route-${i + 1}`, normalizedHandle)
// Accept normalized title format: router-{blockId}-{normalized-title} // Accept normalized title format: router-{blockId}-{normalized-title}
// Normalize: lowercase, replace spaces with dashes, remove special chars
if (route.title && typeof route.title === 'string') { if (route.title && typeof route.title === 'string') {
const normalizedTitle = route.title const normalizedTitle = route.title
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '') .replace(/[^a-z0-9-]/g, '')
if (normalizedTitle) { if (normalizedTitle) {
validHandles.add(`${semanticPrefix}${normalizedTitle}`) handleToNormalized.set(`${legacySemanticPrefix}${normalizedTitle}`, normalizedHandle)
} }
} }
} }
if (validHandles.has(sourceHandle)) { const normalizedHandle = handleToNormalized.get(sourceHandle)
return { valid: true } if (normalizedHandle) {
return { valid: true, normalizedHandle }
} }
const validOptions = Array.from(validHandles).slice(0, 5) // Build list of valid simple format options for error message
const moreCount = validHandles.size - validOptions.length const simpleOptions = routes.map((_, i) => `route-${i}`)
let validOptionsStr = validOptions.join(', ')
if (moreCount > 0) {
validOptionsStr += `, ... and ${moreCount} more`
}
return { return {
valid: false, valid: false,
error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`, error: `Invalid router handle "${sourceHandle}". Valid handles: ${simpleOptions.join(', ')}`,
} }
} }
@@ -1172,10 +1202,13 @@ function createValidatedEdge(
return false return false
} }
// Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}')
const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle
modifiedState.edges.push({ modifiedState.edges.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
source: sourceBlockId, source: sourceBlockId,
sourceHandle, sourceHandle: finalSourceHandle,
target: targetBlockId, target: targetBlockId,
targetHandle, targetHandle,
type: 'default', type: 'default',
@@ -1184,7 +1217,11 @@ function createValidatedEdge(
} }
/** /**
* Adds connections as edges for a block * Adds connections as edges for a block.
* Supports multiple target formats:
* - String: "target-block-id"
* - Object: { block: "target-block-id", handle?: "custom-target-handle" }
* - Array of strings or objects
*/ */
function addConnectionsAsEdges( function addConnectionsAsEdges(
modifiedState: any, modifiedState: any,
@@ -1194,19 +1231,34 @@ function addConnectionsAsEdges(
skippedItems?: SkippedItem[] skippedItems?: SkippedItem[]
): void { ): void {
Object.entries(connections).forEach(([sourceHandle, targets]) => { Object.entries(connections).forEach(([sourceHandle, targets]) => {
const targetArray = Array.isArray(targets) ? targets : [targets] if (targets === null) return
targetArray.forEach((targetId: string) => {
const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => {
createValidatedEdge( createValidatedEdge(
modifiedState, modifiedState,
blockId, blockId,
targetId, targetBlock,
sourceHandle, sourceHandle,
'target', targetHandle || 'target',
'add_edge', 'add_edge',
logger, logger,
skippedItems skippedItems
) )
}) }
if (typeof targets === 'string') {
addEdgeForTarget(targets)
} else if (Array.isArray(targets)) {
targets.forEach((target: any) => {
if (typeof target === 'string') {
addEdgeForTarget(target)
} else if (target?.block) {
addEdgeForTarget(target.block, target.handle)
}
})
} else if (typeof targets === 'object' && targets?.block) {
addEdgeForTarget(targets.block, targets.handle)
}
}) })
} }

View File

@@ -326,32 +326,32 @@ export const env = createEnv({
NEXT_PUBLIC_E2B_ENABLED: z.string().optional(), NEXT_PUBLIC_E2B_ENABLED: z.string().optional(),
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(), NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(),
NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
// Theme Customization // Theme Customization
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand color (hex format, e.g., "#701ffc") NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand color (hex format, e.g., "#701ffc")
NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format) NEXT_PUBLIC_BRAND_PRIMARY_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Primary brand hover state (hex format)
NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand color (hex format) NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand color (hex format)
NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand hover state (hex format) NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Accent brand hover state (hex format)
NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Brand background color (hex format) NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), // Brand background color (hex format)
// Feature Flags // Feature Flags
NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI NEXT_PUBLIC_TRIGGER_DEV_ENABLED: z.boolean().optional(), // Client-side gate for async executions UI
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
}, },
// Variables available on both server and client // Variables available on both server and client
shared: { shared: {
NODE_ENV: z.enum(['development', 'test', 'production']).optional(), // Runtime environment NODE_ENV: z.enum(['development', 'test', 'production']).optional(), // Runtime environment
NEXT_TELEMETRY_DISABLED: z.string().optional(), // Disable Next.js telemetry collection NEXT_TELEMETRY_DISABLED: z.string().optional(), // Disable Next.js telemetry collection
}, },
experimental__runtimeEnv: { experimental__runtimeEnv: {

View File

@@ -152,15 +152,20 @@ function addUnsubscribeData(
): UnsubscribeData { ): UnsubscribeData {
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType) const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(recipientEmail)}` const encodedEmail = encodeURIComponent(recipientEmail)
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodedEmail}`
return { return {
headers: { headers: {
'List-Unsubscribe': `<${unsubscribeUrl}>`, 'List-Unsubscribe': `<${unsubscribeUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}, },
html: html?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken), html: html
text: text?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken), ?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
text: text
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
} }
} }
@@ -361,15 +366,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
subject: email.subject, subject: email.subject,
} }
if (email.html) emailData.html = email.html
if (email.text) emailData.text = email.text
if (includeUnsubscribe && emailType !== 'transactional') { if (includeUnsubscribe && emailType !== 'transactional') {
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text) const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
emailData.headers = unsubData.headers emailData.headers = unsubData.headers
if (unsubData.html) emailData.html = unsubData.html if (unsubData.html) emailData.html = unsubData.html
if (unsubData.text) emailData.text = unsubData.text if (unsubData.text) emailData.text = unsubData.text
} else {
if (email.html) emailData.html = email.html
if (email.text) emailData.text = email.text
} }
batchEmails.push(emailData) batchEmails.push(emailData)

View File

@@ -114,17 +114,15 @@ describe('unsubscribe utilities', () => {
}) })
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => { it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
// Generate a real legacy token using the actual hashing logic to ensure backward compatibility
const salt = 'abc123' const salt = 'abc123'
const secret = 'test-secret-key' const secret = 'test-secret-key'
const { createHash } = require('crypto') const { createHash } = require('crypto')
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex') const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
const legacyToken = `${salt}:${hash}` const legacyToken = `${salt}:${hash}`
// This should return valid since we're using the actual legacy format properly
const result = verifyUnsubscribeToken(testEmail, legacyToken) const result = verifyUnsubscribeToken(testEmail, legacyToken)
expect(result.valid).toBe(true) expect(result.valid).toBe(true)
expect(result.emailType).toBe('marketing') // Should default to marketing for legacy tokens expect(result.emailType).toBe('marketing')
}) })
it.concurrent('should reject malformed tokens', () => { it.concurrent('should reject malformed tokens', () => {
@@ -226,7 +224,6 @@ describe('unsubscribe utilities', () => {
it('should update email preferences for existing user', async () => { it('should update email preferences for existing user', async () => {
const userId = 'user-123' const userId = 'user-123'
// Mock finding the user
mockDb.select.mockReturnValueOnce({ mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
@@ -235,7 +232,6 @@ describe('unsubscribe utilities', () => {
}), }),
}) })
// Mock getting existing settings
mockDb.select.mockReturnValueOnce({ mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
@@ -244,7 +240,6 @@ describe('unsubscribe utilities', () => {
}), }),
}) })
// Mock insert with upsert
mockDb.insert.mockReturnValue({ mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
@@ -300,7 +295,6 @@ describe('unsubscribe utilities', () => {
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true }) await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
// Verify that the merged preferences are passed
expect(mockInsertValues).toHaveBeenCalledWith( expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
emailPreferences: { emailPreferences: {

View File

@@ -38,7 +38,6 @@ export function verifyUnsubscribeToken(
const parts = token.split(':') const parts = token.split(':')
if (parts.length < 2) return { valid: false } if (parts.length < 2) return { valid: false }
// Handle legacy tokens (without email type)
if (parts.length === 2) { if (parts.length === 2) {
const [salt, expectedHash] = parts const [salt, expectedHash] = parts
const hash = createHash('sha256') const hash = createHash('sha256')
@@ -48,7 +47,6 @@ export function verifyUnsubscribeToken(
return { valid: hash === expectedHash, emailType: 'marketing' } return { valid: hash === expectedHash, emailType: 'marketing' }
} }
// Handle new tokens (with email type)
const [salt, expectedHash, emailType] = parts const [salt, expectedHash, emailType] = parts
if (!salt || !expectedHash || !emailType) return { valid: false } if (!salt || !expectedHash || !emailType) return { valid: false }
@@ -101,7 +99,6 @@ export async function updateEmailPreferences(
preferences: EmailPreferences preferences: EmailPreferences
): Promise<boolean> { ): Promise<boolean> {
try { try {
// First, find the user
const userResult = await db const userResult = await db
.select({ id: user.id }) .select({ id: user.id })
.from(user) .from(user)
@@ -115,7 +112,6 @@ export async function updateEmailPreferences(
const userId = userResult[0].id const userId = userResult[0].id
// Get existing email preferences
const existingSettings = await db const existingSettings = await db
.select({ emailPreferences: settings.emailPreferences }) .select({ emailPreferences: settings.emailPreferences })
.from(settings) .from(settings)
@@ -127,13 +123,11 @@ export async function updateEmailPreferences(
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {} currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
} }
// Merge email preferences
const updatedEmailPreferences = { const updatedEmailPreferences = {
...currentEmailPreferences, ...currentEmailPreferences,
...preferences, ...preferences,
} }
// Upsert settings
await db await db
.insert(settings) .insert(settings)
.values({ .values({
@@ -168,10 +162,8 @@ export async function isUnsubscribed(
const preferences = await getEmailPreferences(email) const preferences = await getEmailPreferences(email)
if (!preferences) return false if (!preferences) return false
// Check unsubscribe all first
if (preferences.unsubscribeAll) return true if (preferences.unsubscribeAll) return true
// Check specific type
switch (emailType) { switch (emailType) {
case 'marketing': case 'marketing':
return preferences.unsubscribeMarketing || false return preferences.unsubscribeMarketing || false

View File

@@ -0,0 +1,193 @@
import type { ComponentType } from 'react'
import { getAllBlocks } from '@/blocks'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
/**
* Represents a searchable tool operation extracted from block configurations.
* Each operation maps to a specific tool that can be invoked when the block
* is configured with that operation selected.
*/
export interface ToolOperationItem {
/** Unique identifier combining block type and operation ID (e.g., "slack_send") */
id: string
/** The block type this operation belongs to (e.g., "slack") */
blockType: string
/** The operation dropdown value (e.g., "send") */
operationId: string
/** Human-readable service name from the block (e.g., "Slack") */
serviceName: string
/** Human-readable operation name from the dropdown label (e.g., "Send Message") */
operationName: string
/** The block's icon component */
icon: ComponentType<{ className?: string }>
/** The block's background color */
bgColor: string
/** Search aliases for common synonyms */
aliases: string[]
}
/**
* Maps common action verbs to their synonyms for better search matching.
* When a user searches for "post message", it should match "send message".
* Based on analysis of 1000+ tool operations in the codebase.
*/
const ACTION_VERB_ALIASES: Record<string, string[]> = {
get: ['read', 'fetch', 'retrieve', 'load', 'obtain'],
read: ['get', 'fetch', 'retrieve', 'load'],
create: ['make', 'new', 'add', 'generate', 'insert'],
add: ['create', 'insert', 'append', 'include'],
update: ['edit', 'modify', 'change', 'patch', 'set'],
set: ['update', 'configure', 'assign'],
delete: ['remove', 'trash', 'destroy', 'erase'],
remove: ['delete', 'clear', 'drop', 'unset'],
list: ['show', 'display', 'view', 'browse', 'enumerate'],
search: ['find', 'query', 'lookup', 'locate'],
query: ['search', 'find', 'lookup'],
send: ['post', 'write', 'deliver', 'transmit', 'publish'],
write: ['send', 'post', 'compose'],
download: ['export', 'save', 'pull', 'fetch'],
upload: ['import', 'push', 'transfer', 'attach'],
execute: ['run', 'invoke', 'trigger', 'perform', 'start'],
check: ['verify', 'validate', 'test', 'inspect'],
cancel: ['abort', 'stop', 'terminate', 'revoke'],
archive: ['store', 'backup', 'preserve'],
copy: ['duplicate', 'clone', 'replicate'],
move: ['transfer', 'relocate', 'migrate'],
share: ['publish', 'distribute', 'broadcast'],
}
/**
* Generates search aliases for an operation name by finding synonyms
* for action verbs in the operation name.
*/
function generateAliases(operationName: string): string[] {
const aliases: string[] = []
const lowerName = operationName.toLowerCase()
for (const [verb, synonyms] of Object.entries(ACTION_VERB_ALIASES)) {
if (lowerName.includes(verb)) {
for (const synonym of synonyms) {
aliases.push(lowerName.replace(verb, synonym))
}
}
}
return aliases
}
/**
* Extracts the operation dropdown subblock from a block's configuration.
* Returns null if no operation dropdown exists.
*/
function findOperationDropdown(block: BlockConfig): SubBlockConfig | null {
return (
block.subBlocks.find(
(sb) => sb.id === 'operation' && sb.type === 'dropdown' && Array.isArray(sb.options)
) ?? null
)
}
/**
* Resolves the tool ID for a given operation using the block's tool config.
* Falls back to checking tools.access if no config.tool function exists.
*/
function resolveToolId(block: BlockConfig, operationId: string): string | null {
if (!block.tools) return null
if (block.tools.config?.tool) {
try {
return block.tools.config.tool({ operation: operationId })
} catch {
return null
}
}
if (block.tools.access?.length === 1) {
return block.tools.access[0]
}
return null
}
/**
* Builds an index of all tool operations from the block registry.
* This index is used by the search modal to enable operation-level discovery.
*
* The function iterates through all blocks that have:
* 1. A tools.access array (indicating they use tools)
* 2. An "operation" dropdown subblock with options
*
* For each operation option, it creates a ToolOperationItem that maps
* the operation to its corresponding tool.
*/
export function buildToolOperationsIndex(): ToolOperationItem[] {
const operations: ToolOperationItem[] = []
const allBlocks = getAllBlocks()
for (const block of allBlocks) {
if (!block.tools?.access?.length || block.hideFromToolbar) {
continue
}
if (block.category !== 'tools') {
continue
}
const operationDropdown = findOperationDropdown(block)
if (!operationDropdown) {
continue
}
const options =
typeof operationDropdown.options === 'function'
? operationDropdown.options()
: operationDropdown.options
if (!options) continue
for (const option of options) {
if (!resolveToolId(block, option.id)) continue
const operationName = option.label
const aliases = generateAliases(operationName)
operations.push({
id: `${block.type}_${option.id}`,
blockType: block.type,
operationId: option.id,
serviceName: block.name,
operationName,
icon: block.icon,
bgColor: block.bgColor,
aliases,
})
}
}
return operations
}
/**
* Cached operations index to avoid rebuilding on every search.
* The index is built lazily on first access.
*/
let cachedOperations: ToolOperationItem[] | null = null
/**
* Returns the tool operations index, building it if necessary.
* The index is cached after first build since block registry is static.
*/
export function getToolOperationsIndex(): ToolOperationItem[] {
if (!cachedOperations) {
cachedOperations = buildToolOperationsIndex()
}
return cachedOperations
}
/**
* Clears the cached operations index.
* Useful for testing or if blocks are dynamically modified.
*/
export function clearToolOperationsCache(): void {
cachedOperations = null
}

View File

@@ -1,18 +1,41 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { member, templateCreators, templates, user } from '@sim/db/schema' import { member, settings, templateCreators, templates, user } from '@sim/db/schema'
import { and, eq, or } from 'drizzle-orm' import { and, eq, or } from 'drizzle-orm'
export type CreatorPermissionLevel = 'member' | 'admin' export type CreatorPermissionLevel = 'member' | 'admin'
/** /**
* Verifies if a user is a super user. * Verifies if a user is an effective super user (database flag AND settings toggle).
* This should be used for features that can be disabled by the user's settings toggle.
* *
* @param userId - The ID of the user to check * @param userId - The ID of the user to check
* @returns Object with isSuperUser boolean * @returns Object with effectiveSuperUser boolean and component values
*/ */
export async function verifySuperUser(userId: string): Promise<{ isSuperUser: boolean }> { export async function verifyEffectiveSuperUser(userId: string): Promise<{
const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1) effectiveSuperUser: boolean
return { isSuperUser: currentUser?.isSuperUser || false } isSuperUser: boolean
superUserModeEnabled: boolean
}> {
const [currentUser] = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, userId))
.limit(1)
const [userSettings] = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
const isSuperUser = currentUser?.isSuperUser || false
const superUserModeEnabled = userSettings?.superUserModeEnabled ?? false
return {
effectiveSuperUser: isSuperUser && superUserModeEnabled,
isSuperUser,
superUserModeEnabled,
}
} }
/** /**

View File

@@ -269,11 +269,12 @@ function sanitizeSubBlocks(
} }
/** /**
* Convert internal condition handle (condition-{uuid}) to semantic format (condition-{blockId}-if) * Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
* Uses 0-indexed numbering for else-if conditions
*/ */
function convertConditionHandleToSemantic( function convertConditionHandleToSimple(
handle: string, handle: string,
blockId: string, _blockId: string,
block: BlockState block: BlockState
): string { ): string {
if (!handle.startsWith('condition-')) { if (!handle.startsWith('condition-')) {
@@ -300,27 +301,24 @@ function convertConditionHandleToSemantic(
return handle return handle
} }
// Find the condition by ID and generate semantic handle // Find the condition by ID and generate simple handle
let elseIfCount = 0 let elseIfIndex = 0
for (const condition of conditions) { for (const condition of conditions) {
const title = condition.title?.toLowerCase() const title = condition.title?.toLowerCase()
if (condition.id === conditionId) { if (condition.id === conditionId) {
if (title === 'if') { if (title === 'if') {
return `condition-${blockId}-if` return 'if'
} }
if (title === 'else if') { if (title === 'else if') {
elseIfCount++ return `else-if-${elseIfIndex}`
return elseIfCount === 1
? `condition-${blockId}-else-if`
: `condition-${blockId}-else-if-${elseIfCount}`
} }
if (title === 'else') { if (title === 'else') {
return `condition-${blockId}-else` return 'else'
} }
} }
// Count else-ifs as we iterate // Count else-ifs as we iterate (for index tracking)
if (title === 'else if') { if (title === 'else if') {
elseIfCount++ elseIfIndex++
} }
} }
@@ -329,9 +327,10 @@ function convertConditionHandleToSemantic(
} }
/** /**
* Convert internal router handle (router-{uuid}) to semantic format (router-{blockId}-route-N) * Convert internal router handle (router-{uuid}) to simple format (route-0, route-1)
* Uses 0-indexed numbering for routes
*/ */
function convertRouterHandleToSemantic(handle: string, blockId: string, block: BlockState): string { function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string {
if (!handle.startsWith('router-')) { if (!handle.startsWith('router-')) {
return handle return handle
} }
@@ -356,10 +355,10 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
return handle return handle
} }
// Find the route by ID and generate semantic handle (1-indexed) // Find the route by ID and generate simple handle (0-indexed)
for (let i = 0; i < routes.length; i++) { for (let i = 0; i < routes.length; i++) {
if (routes[i].id === routeId) { if (routes[i].id === routeId) {
return `router-${blockId}-route-${i + 1}` return `route-${i}`
} }
} }
@@ -368,15 +367,16 @@ function convertRouterHandleToSemantic(handle: string, blockId: string, block: B
} }
/** /**
* Convert source handle to semantic format for condition and router blocks * Convert source handle to simple format for condition and router blocks
* Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers)
*/ */
function convertToSemanticHandle(handle: string, blockId: string, block: BlockState): string { function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string {
if (handle.startsWith('condition-') && block.type === 'condition') { if (handle.startsWith('condition-') && block.type === 'condition') {
return convertConditionHandleToSemantic(handle, blockId, block) return convertConditionHandleToSimple(handle, blockId, block)
} }
if (handle.startsWith('router-') && block.type === 'router_v2') { if (handle.startsWith('router-') && block.type === 'router_v2') {
return convertRouterHandleToSemantic(handle, blockId, block) return convertRouterHandleToSimple(handle, blockId, block)
} }
return handle return handle
@@ -400,12 +400,12 @@ function extractConnectionsForBlock(
return undefined return undefined
} }
// Group by source handle (converting to semantic format) // Group by source handle (converting to simple format)
for (const edge of outgoingEdges) { for (const edge of outgoingEdges) {
let handle = edge.sourceHandle || 'source' let handle = edge.sourceHandle || 'source'
// Convert internal UUID handles to semantic format // Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
handle = convertToSemanticHandle(handle, blockId, block) handle = convertToSimpleHandle(handle, blockId, block)
if (!connections[handle]) { if (!connections[handle]) {
connections[handle] = [] connections[handle] = []

View File

@@ -163,12 +163,13 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
try { try {
const errorMessage = String(newEntry.error) const errorMessage = String(newEntry.error)
const blockName = newEntry.blockName || 'Unknown Block' const blockName = newEntry.blockName || 'Unknown Block'
const displayMessage = `${blockName}: ${errorMessage}`
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.` const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
useNotificationStore.getState().addNotification({ useNotificationStore.getState().addNotification({
level: 'error', level: 'error',
message: errorMessage, message: displayMessage,
workflowId: entry.workflowId, workflowId: entry.workflowId,
action: { action: {
type: 'copilot', type: 'copilot',

View File

@@ -0,0 +1,394 @@
/**
* @vitest-environment node
*/
import type { Edge } from 'reactflow'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: vi.fn(),
}))
import { mergeSubblockState } from '@/stores/workflows/utils'
import { captureLatestEdges, captureLatestSubBlockValues } from './utils'
const mockMergeSubblockState = mergeSubblockState as Mock
describe('captureLatestEdges', () => {
const createEdge = (id: string, source: string, target: string): Edge => ({
id,
source,
target,
})
it('should return edges where blockId is the source', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-1'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges where blockId is the target', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
})
it('should return edges for multiple blocks', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
createEdge('edge-3', 'block-2', 'block-5'),
]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(2)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-3', 'block-2', 'block-5'))
})
it('should return empty array when no edges match', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, ['block-99'])
expect(result).toEqual([])
})
it('should return empty array when blockIds is empty', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-3', 'block-4'),
]
const result = captureLatestEdges(edges, [])
expect(result).toEqual([])
})
it('should return edge when block has both source and target edges', () => {
const edges = [
createEdge('edge-1', 'block-1', 'block-2'),
createEdge('edge-2', 'block-2', 'block-3'),
createEdge('edge-3', 'block-4', 'block-2'),
]
const result = captureLatestEdges(edges, ['block-2'])
expect(result).toHaveLength(3)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
expect(result).toContainEqual(createEdge('edge-2', 'block-2', 'block-3'))
expect(result).toContainEqual(createEdge('edge-3', 'block-4', 'block-2'))
})
it('should handle empty edges array', () => {
const result = captureLatestEdges([], ['block-1'])
expect(result).toEqual([])
})
it('should not duplicate edges when block appears in multiple blockIds', () => {
const edges = [createEdge('edge-1', 'block-1', 'block-2')]
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
expect(result).toHaveLength(1)
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
})
})
describe('captureLatestSubBlockValues', () => {
const workflowId = 'wf-test'
const createBlockState = (
id: string,
subBlocks: Record<string, { id: string; type: string; value: unknown }>
): BlockState =>
({
id,
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: Object.fromEntries(
Object.entries(subBlocks).map(([subId, sb]) => [
subId,
{ id: sb.id, type: sb.type, value: sb.value },
])
),
outputs: {},
enabled: true,
}) as BlockState
beforeEach(() => {
vi.clearAllMocks()
})
it('should capture single block with single subblock value', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'console.log("hello")' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'console.log("hello")' },
})
})
it('should capture single block with multiple subblock values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test code' },
model: { id: 'model', type: 'dropdown', value: 'gpt-4' },
temperature: { id: 'temperature', type: 'slider', value: 0.7 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
code: 'test code',
model: 'gpt-4',
temperature: 0.7,
},
})
})
it('should capture multiple blocks with values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
prompt: { id: 'prompt', type: 'long-input', value: 'hello world' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-2') return { 'block-2': blocks['block-2'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-2'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-2': { prompt: 'hello world' },
})
})
it('should skip null values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: null },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
expect(result['block-1']).not.toHaveProperty('empty')
})
it('should skip undefined values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'valid code' },
empty: { id: 'empty', type: 'short-input', value: undefined },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: 'valid code' },
})
})
it('should return empty object for block with no subBlocks', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should return empty object for non-existent blockId', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
mockMergeSubblockState.mockReturnValue({})
const result = captureLatestSubBlockValues(blocks, workflowId, ['non-existent'])
expect(result).toEqual({})
})
it('should return empty object when blockIds is empty', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'test' },
}),
}
const result = captureLatestSubBlockValues(blocks, workflowId, [])
expect(result).toEqual({})
expect(mockMergeSubblockState).not.toHaveBeenCalled()
})
it('should handle various value types (string, number, array)', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
text: { id: 'text', type: 'short-input', value: 'string value' },
number: { id: 'number', type: 'slider', value: 42 },
array: {
id: 'array',
type: 'table',
value: [
['a', 'b'],
['c', 'd'],
],
},
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': {
text: 'string value',
number: 42,
array: [
['a', 'b'],
['c', 'd'],
],
},
})
})
it('should only capture values for blockIds in the list', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: 'code 1' },
}),
'block-2': createBlockState('block-2', {
code: { id: 'code', type: 'code', value: 'code 2' },
}),
'block-3': createBlockState('block-3', {
code: { id: 'code', type: 'code', value: 'code 3' },
}),
}
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
if (blockId === 'block-3') return { 'block-3': blocks['block-3'] }
return {}
})
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-3'])
expect(result).toEqual({
'block-1': { code: 'code 1' },
'block-3': { code: 'code 3' },
})
expect(result).not.toHaveProperty('block-2')
})
it('should handle block without subBlocks property', () => {
const blocks: Record<string, BlockState> = {
'block-1': {
id: 'block-1',
type: 'function',
name: 'Test Block',
position: { x: 0, y: 0 },
outputs: {},
enabled: true,
} as BlockState,
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({})
})
it('should handle empty string values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
code: { id: 'code', type: 'code', value: '' },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { code: '' },
})
})
it('should handle zero numeric values', () => {
const blocks: Record<string, BlockState> = {
'block-1': createBlockState('block-1', {
temperature: { id: 'temperature', type: 'slider', value: 0 },
}),
}
mockMergeSubblockState.mockReturnValue(blocks)
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
expect(result).toEqual({
'block-1': { temperature: 0 },
})
})
})

View File

@@ -1,3 +1,4 @@
import type { Edge } from 'reactflow'
import { UNDO_REDO_OPERATIONS } from '@/socket/constants' import { UNDO_REDO_OPERATIONS } from '@/socket/constants'
import type { import type {
BatchAddBlocksOperation, BatchAddBlocksOperation,
@@ -9,6 +10,8 @@ import type {
Operation, Operation,
OperationEntry, OperationEntry,
} from '@/stores/undo-redo/types' } from '@/stores/undo-redo/types'
import { mergeSubblockState } from '@/stores/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry { export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
return { return {
@@ -170,3 +173,31 @@ export function createInverseOperation(operation: Operation): Operation {
} }
} }
} }
export function captureLatestEdges(edges: Edge[], blockIds: string[]): Edge[] {
return edges.filter((e) => blockIds.includes(e.source) || blockIds.includes(e.target))
}
export function captureLatestSubBlockValues(
blocks: Record<string, BlockState>,
workflowId: string,
blockIds: string[]
): Record<string, Record<string, unknown>> {
const values: Record<string, Record<string, unknown>> = {}
blockIds.forEach((blockId) => {
const merged = mergeSubblockState(blocks, workflowId, blockId)
const block = merged[blockId]
if (block?.subBlocks) {
const blockValues: Record<string, unknown> = {}
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (subBlock.value !== null && subBlock.value !== undefined) {
blockValues[subBlockId] = subBlock.value
}
})
if (Object.keys(blockValues).length > 0) {
values[blockId] = blockValues
}
}
})
return values
}

View File

@@ -46,11 +46,11 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
}, },
}, },
request: { request: {
url: 'https://api.browser-use.com/api/v1/run-task', url: 'https://api.browser-use.com/api/v2/tasks',
method: 'POST', method: 'POST',
headers: (params) => ({ headers: (params) => ({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${params.apiKey}`, 'X-Browser-Use-API-Key': params.apiKey,
}), }),
body: (params) => { body: (params) => {
const requestBody: Record<string, any> = { const requestBody: Record<string, any> = {
@@ -121,12 +121,15 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
let liveUrlLogged = false let liveUrlLogged = false
try { try {
const initialTaskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, { const initialTaskResponse = await fetch(
method: 'GET', `https://api.browser-use.com/api/v2/tasks/${taskId}`,
headers: { {
Authorization: `Bearer ${params.apiKey}`, method: 'GET',
}, headers: {
}) 'X-Browser-Use-API-Key': params.apiKey,
},
}
)
if (initialTaskResponse.ok) { if (initialTaskResponse.ok) {
const initialTaskData = await initialTaskResponse.json() const initialTaskData = await initialTaskResponse.json()
@@ -145,60 +148,36 @@ export const runTaskTool: ToolConfig<BrowserUseRunTaskParams, BrowserUseRunTaskR
while (elapsedTime < MAX_POLL_TIME_MS) { while (elapsedTime < MAX_POLL_TIME_MS) {
try { try {
const statusResponse = await fetch( const statusResponse = await fetch(`https://api.browser-use.com/api/v2/tasks/${taskId}`, {
`https://api.browser-use.com/api/v1/task/${taskId}/status`, method: 'GET',
{ headers: {
method: 'GET', 'X-Browser-Use-API-Key': params.apiKey,
headers: { },
Authorization: `Bearer ${params.apiKey}`, })
},
}
)
if (!statusResponse.ok) { if (!statusResponse.ok) {
throw new Error(`Failed to get task status: ${statusResponse.statusText}`) throw new Error(`Failed to get task status: ${statusResponse.statusText}`)
} }
const status = await statusResponse.json() const taskData = await statusResponse.json()
const status = taskData.status
logger.info(`BrowserUse task ${taskId} status: ${status}`) logger.info(`BrowserUse task ${taskId} status: ${status}`)
if (['finished', 'failed', 'stopped'].includes(status)) { if (['finished', 'failed', 'stopped'].includes(status)) {
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, { result.output = {
method: 'GET', id: taskId,
headers: { success: status === 'finished',
Authorization: `Bearer ${params.apiKey}`, output: taskData.output ?? null,
}, steps: taskData.steps || [],
})
if (taskResponse.ok) {
const taskData = await taskResponse.json()
result.output = {
id: taskId,
success: status === 'finished',
output: taskData.output,
steps: taskData.steps || [],
}
} }
return result return result
} }
if (!liveUrlLogged && status === 'running') { if (!liveUrlLogged && status === 'running' && taskData.live_url) {
const taskResponse = await fetch(`https://api.browser-use.com/api/v1/task/${taskId}`, { logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
method: 'GET', liveUrlLogged = true
headers: {
Authorization: `Bearer ${params.apiKey}`,
},
})
if (taskResponse.ok) {
const taskData = await taskResponse.json()
if (taskData.live_url) {
logger.info(`BrowserUse task ${taskId} running with live URL: ${taskData.live_url}`)
liveUrlLogged = true
}
}
} }
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))

View File

@@ -59,7 +59,7 @@ export const calendlyInviteeCanceledTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).', '<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.', 'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.', 'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.', 'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.', 'This webhook triggers when an invitee cancels an event. The payload includes cancellation details and reason.',
] ]
.map( .map(

View File

@@ -68,7 +68,7 @@ export const calendlyInviteeCreatedTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).', '<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.', 'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.', 'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.', 'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.', 'This webhook triggers when an invitee schedules a new event. Rescheduling triggers both cancellation and creation events.',
] ]
.map( .map(

View File

@@ -59,7 +59,7 @@ export const calendlyRoutingFormSubmittedTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).', '<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.', 'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.', 'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.', 'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook triggers when someone submits a routing form, regardless of whether they book an event.', 'This webhook triggers when someone submits a routing form, regardless of whether they book an event.',
] ]
.map( .map(

View File

@@ -58,7 +58,7 @@ export const calendlyWebhookTrigger: TriggerConfig = {
'<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).', '<strong>Note:</strong> This trigger requires a paid Calendly subscription (Professional, Teams, or Enterprise plan).',
'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.', 'Get your Personal Access Token from <strong>Settings > Integrations > API & Webhooks</strong> in your Calendly account.',
'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.', 'Use the "Get Current User" operation in a Calendly block to retrieve your Organization URI.',
'The webhook will be automatically created in Calendly when you save this trigger.', 'The webhook will be automatically created in Calendly when you deploy the workflow.',
'This webhook subscribes to all Calendly events (invitee created, invitee canceled, and routing form submitted). Use the <code>event</code> field in the payload to determine the event type.', 'This webhook subscribes to all Calendly events (invitee created, invitee canceled, and routing form submitted). Use the <code>event</code> field in the payload to determine the event type.',
] ]
.map( .map(

View File

@@ -186,7 +186,7 @@ export const stripeWebhookTrigger: TriggerConfig = {
'Click "Create Destination" to save', 'Click "Create Destination" to save',
'After creating the endpoint, click "Reveal" next to "Signing secret" and copy it', 'After creating the endpoint, click "Reveal" next to "Signing secret" and copy it',
'Paste the signing secret into the <strong>Webhook Signing Secret</strong> field above', 'Paste the signing secret into the <strong>Webhook Signing Secret</strong> field above',
'Click "Save" to activate your webhook trigger', 'Deploy your workflow to activate the webhook trigger',
] ]
.map( .map(
(instruction, index) => (instruction, index) =>

View File

@@ -46,7 +46,7 @@ export const telegramWebhookTrigger: TriggerConfig = {
defaultValue: [ defaultValue: [
'Message "/newbot" to <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">@BotFather</a> in Telegram to create a bot and copy its token.', 'Message "/newbot" to <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">@BotFather</a> in Telegram to create a bot and copy its token.',
'Enter your Bot Token above.', 'Enter your Bot Token above.',
'Save settings and any message sent to your bot will trigger the workflow.', 'Any message sent to your bot will trigger the workflow once deployed.',
] ]
.map( .map(
(instruction, index) => (instruction, index) =>

View File

@@ -79,7 +79,7 @@ export const typeformWebhookTrigger: TriggerConfig = {
'Find your Form ID in the URL when editing your form (e.g., <code>https://admin.typeform.com/form/ABC123/create</code> → Form ID is <code>ABC123</code>)', 'Find your Form ID in the URL when editing your form (e.g., <code>https://admin.typeform.com/form/ABC123/create</code> → Form ID is <code>ABC123</code>)',
'Fill in the form above with your Form ID and Personal Access Token', 'Fill in the form above with your Form ID and Personal Access Token',
'Optionally add a Webhook Secret for enhanced security - Sim will verify all incoming webhooks match this secret', 'Optionally add a Webhook Secret for enhanced security - Sim will verify all incoming webhooks match this secret',
'Click "Save" above - Sim will automatically register the webhook with Typeform', 'Sim will automatically register the webhook with Typeform when you deploy the workflow',
'<strong>Note:</strong> Requires a Typeform PRO or PRO+ account to use webhooks', '<strong>Note:</strong> Requires a Typeform PRO or PRO+ account to use webhooks',
] ]
.map( .map(

View File

@@ -10,6 +10,7 @@
"@octokit/rest": "^21.0.0", "@octokit/rest": "^21.0.0",
"@tailwindcss/typography": "0.5.19", "@tailwindcss/typography": "0.5.19",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"glob": "13.0.0",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "16.0.0", "lint-staged": "16.0.0",
"turbo": "2.7.4", "turbo": "2.7.4",
@@ -2237,7 +2238,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -2539,7 +2540,7 @@
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lru.min": ["lru.min@1.1.3", "", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="], "lru.min": ["lru.min@1.1.3", "", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="],
@@ -2699,7 +2700,7 @@
"minimal-polyfills": ["minimal-polyfills@2.2.3", "", {}, "sha512-oxdmJ9cL+xV72h0xYxp4tP2d5/fTBpP45H8DIOn9pASuF8a3IYTf+25fMGDYGiWW+MFsuog6KD6nfmhZJQ+uUw=="], "minimal-polyfills": ["minimal-polyfills@2.2.3", "", {}, "sha512-oxdmJ9cL+xV72h0xYxp4tP2d5/fTBpP45H8DIOn9pASuF8a3IYTf+25fMGDYGiWW+MFsuog6KD6nfmhZJQ+uUw=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -3691,6 +3692,8 @@
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@better-auth/sso/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "@better-auth/sso/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
@@ -3953,6 +3956,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"e2b/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"engine.io/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "engine.io/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
@@ -3993,8 +3998,6 @@
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"groq-sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "groq-sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
@@ -4043,8 +4046,6 @@
"log-update/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "log-update/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
@@ -4083,8 +4084,6 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"pino/thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], "pino/thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
@@ -4113,6 +4112,8 @@
"react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
@@ -4171,6 +4172,8 @@
"test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"thriftrw/long": ["long@2.4.0", "", {}, "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ=="], "thriftrw/long": ["long@2.4.0", "", {}, "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
@@ -4249,6 +4252,8 @@
"@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.969.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ=="], "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.969.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@browserbasehq/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "@browserbasehq/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -4579,6 +4584,8 @@
"rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"sim/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "sim/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],

View File

@@ -39,6 +39,7 @@
"@octokit/rest": "^21.0.0", "@octokit/rest": "^21.0.0",
"@tailwindcss/typography": "0.5.19", "@tailwindcss/typography": "0.5.19",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"glob": "13.0.0",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "16.0.0", "lint-staged": "16.0.0",
"turbo": "2.7.4" "turbo": "2.7.4"