mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 16:08:04 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e55a0e044 | ||
|
|
e142753d64 | ||
|
|
61deb02959 | ||
|
|
e52862166d | ||
|
|
8f71684dcb | ||
|
|
92fe353f44 | ||
|
|
4c6c7272c5 | ||
|
|
55a9adfdda | ||
|
|
bdfe7e9b99 | ||
|
|
27c248a70c | ||
|
|
19ca9c78b4 | ||
|
|
b13f339327 | ||
|
|
aade4bf3ae |
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, permissions, user, workspace, workspaceMember } from '@/db/schema'
|
||||
import { member, permissions, user, workspace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationWorkspacesAPI')
|
||||
|
||||
@@ -116,10 +116,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
createdAt: workspace.createdAt,
|
||||
isOwner: eq(workspace.ownerId, memberId),
|
||||
permissionType: permissions.permissionType,
|
||||
joinedAt: workspaceMember.joinedAt,
|
||||
createdAt: permissions.createdAt,
|
||||
})
|
||||
.from(workspace)
|
||||
.leftJoin(
|
||||
@@ -130,10 +129,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
eq(permissions.userId, memberId)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
workspaceMember,
|
||||
and(eq(workspaceMember.workspaceId, workspace.id), eq(workspaceMember.userId, memberId))
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
// Member owns the workspace
|
||||
@@ -148,7 +143,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
name: workspace.name,
|
||||
isOwner: workspace.isOwner,
|
||||
permission: workspace.permissionType,
|
||||
joinedAt: workspace.joinedAt,
|
||||
joinedAt: workspace.createdAt,
|
||||
createdAt: workspace.createdAt,
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, permissions, workspaceInvitation, workspaceMember } from '@/db/schema'
|
||||
import { invitation, member, permissions, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationAcceptance')
|
||||
|
||||
@@ -135,18 +135,6 @@ export async function GET(req: NextRequest) {
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
// Check if user isn't already a member of the workspace
|
||||
const existingWorkspaceMember = await tx
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, wsInvitation.workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
// Check if user doesn't already have permissions on the workspace
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
@@ -160,17 +148,7 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) {
|
||||
// Add user as workspace member
|
||||
await tx.insert(workspaceMember).values({
|
||||
id: randomUUID(),
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: wsInvitation.role,
|
||||
joinedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
if (existingPermission.length === 0) {
|
||||
// Add workspace permissions
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
@@ -311,17 +289,6 @@ export async function POST(req: NextRequest) {
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
const existingWorkspaceMember = await tx
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, wsInvitation.workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
.from(permissions)
|
||||
@@ -334,16 +301,7 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) {
|
||||
await tx.insert(workspaceMember).values({
|
||||
id: randomUUID(),
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: wsInvitation.role,
|
||||
joinedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
if (existingPermission.length === 0) {
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
generateCronExpression,
|
||||
getScheduleTimeValues,
|
||||
getSubBlockValue,
|
||||
validateCronExpression,
|
||||
} from '@/lib/schedules/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflowSchedule } from '@/db/schema'
|
||||
@@ -192,6 +193,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
cronExpression = generateCronExpression(defaultScheduleType, scheduleValues)
|
||||
|
||||
// Additional validation for custom cron expressions
|
||||
if (defaultScheduleType === 'custom' && cronExpression) {
|
||||
const validation = validateCronExpression(cronExpression)
|
||||
if (!validation.isValid) {
|
||||
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid cron expression: ${validation.error}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nextRunAt = calculateNextRunTime(defaultScheduleType, scheduleValues)
|
||||
|
||||
logger.debug(
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
state: templates.state,
|
||||
color: templates.color,
|
||||
})
|
||||
.from(templates)
|
||||
.where(eq(templates.id, id))
|
||||
@@ -80,6 +81,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
name: `${templateData.name} (copy)`,
|
||||
description: templateData.description,
|
||||
state: templateData.state,
|
||||
color: templateData.color,
|
||||
userId: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
|
||||
import type { LoopConfig, ParallelConfig, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -24,15 +25,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body)
|
||||
|
||||
@@ -46,19 +45,43 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
// Duplicate workflow and all related data in a transaction
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// First verify the source workflow exists and user has access
|
||||
// First verify the source workflow exists
|
||||
const sourceWorkflow = await tx
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, sourceWorkflowId), eq(workflow.userId, session.user.id)))
|
||||
.where(eq(workflow.id, sourceWorkflowId))
|
||||
.limit(1)
|
||||
|
||||
if (sourceWorkflow.length === 0) {
|
||||
throw new Error('Source workflow not found or access denied')
|
||||
throw new Error('Source workflow not found')
|
||||
}
|
||||
|
||||
const source = sourceWorkflow[0]
|
||||
|
||||
// Check if user has permission to access the source workflow
|
||||
let canAccessSource = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (source.userId === session.user.id) {
|
||||
canAccessSource = true
|
||||
}
|
||||
|
||||
// Case 2: User has admin or write permission in the source workspace
|
||||
if (!canAccessSource && source.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
source.workspaceId
|
||||
)
|
||||
if (userPermission === 'admin' || userPermission === 'write') {
|
||||
canAccessSource = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canAccessSource) {
|
||||
throw new Error('Source workflow not found or access denied')
|
||||
}
|
||||
|
||||
// Create the new workflow first (required for foreign key constraints)
|
||||
await tx.insert(workflow).values({
|
||||
id: newWorkflowId,
|
||||
@@ -346,9 +369,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Source workflow not found or access denied') {
|
||||
logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found or access denied`)
|
||||
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Source workflow not found') {
|
||||
logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`)
|
||||
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (error.message === 'Source workflow not found or access denied') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied access to source workflow ${sourceWorkflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { permissions, type permissionTypeEnum, workspaceMember } from '@/db/schema'
|
||||
import { permissions, type permissionTypeEnum } from '@/db/schema'
|
||||
|
||||
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
@@ -33,18 +34,19 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
// Verify the current user has access to this workspace
|
||||
const userMembership = await db
|
||||
const userPermission = await db
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
eq(permissions.entityId, workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (userMembership.length === 0) {
|
||||
if (userPermission.length === 0) {
|
||||
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { workflow, workspaceMember } from '@/db/schema'
|
||||
import { workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkspaceByIdAPI')
|
||||
|
||||
@@ -126,9 +126,6 @@ export async function DELETE(
|
||||
// workflow_schedule, webhook, marketplace, chat, and memory records
|
||||
await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
// Delete workspace members
|
||||
await tx.delete(workspaceMember).where(eq(workspaceMember.workspaceId, workspaceId))
|
||||
|
||||
// Delete all permissions associated with this workspace
|
||||
await tx
|
||||
.delete(permissions)
|
||||
|
||||
241
apps/sim/app/api/workspaces/invitations/[id]/route.test.ts
Normal file
241
apps/sim/app/api/workspaces/invitations/[id]/route.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workspaceInvitation } from '@/db/schema'
|
||||
import { DELETE } from './route'
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/db/schema', () => ({
|
||||
workspaceInvitation: {
|
||||
id: 'id',
|
||||
workspaceId: 'workspaceId',
|
||||
email: 'email',
|
||||
inviterId: 'inviterId',
|
||||
status: 'status',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
|
||||
}))
|
||||
|
||||
describe('DELETE /api/workspaces/invitations/[id]', () => {
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: 'user123',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
image: null,
|
||||
stripeCustomerId: null,
|
||||
},
|
||||
session: {
|
||||
id: 'session123',
|
||||
token: 'token123',
|
||||
userId: 'user123',
|
||||
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
activeOrganizationId: null,
|
||||
},
|
||||
}
|
||||
|
||||
const mockInvitation = {
|
||||
id: 'invitation123',
|
||||
workspaceId: 'workspace456',
|
||||
email: 'invited@example.com',
|
||||
inviterId: 'inviter789',
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 404 when invitation does not exist', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation not found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Simulate empty rows array
|
||||
return Promise.resolve(callback([]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'non-existent' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toEqual({ error: 'Invitation not found' })
|
||||
})
|
||||
|
||||
it('should return 403 when user does not have admin access', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([mockInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user does not have admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'Insufficient permissions' })
|
||||
expect(hasWorkspaceAdminAccess).toHaveBeenCalledWith('user123', 'workspace456')
|
||||
})
|
||||
|
||||
it('should return 400 when trying to delete non-pending invitation', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation with accepted status
|
||||
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([acceptedInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user has admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: 'Can only delete pending invitations' })
|
||||
})
|
||||
|
||||
it('should successfully delete pending invitation when user has admin access', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([mockInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user has admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
|
||||
|
||||
// Mock successful deletion
|
||||
const mockDelete = {
|
||||
where: vi.fn().mockResolvedValue({ rowCount: 1 }),
|
||||
}
|
||||
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({ success: true })
|
||||
expect(db.delete).toHaveBeenCalledWith(workspaceInvitation)
|
||||
expect(mockDelete.where).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 500 when database error occurs', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock database error
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockRejectedValue(new Error('Database connection failed')),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toEqual({ error: 'Failed to delete invitation' })
|
||||
})
|
||||
})
|
||||
55
apps/sim/app/api/workspaces/invitations/[id]/route.ts
Normal file
55
apps/sim/app/api/workspaces/invitations/[id]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// DELETE /api/workspaces/invitations/[id] - Delete a workspace invitation
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the invitation to delete
|
||||
const invitation = await db
|
||||
.select({
|
||||
id: workspaceInvitation.id,
|
||||
workspaceId: workspaceInvitation.workspaceId,
|
||||
email: workspaceInvitation.email,
|
||||
inviterId: workspaceInvitation.inviterId,
|
||||
status: workspaceInvitation.status,
|
||||
})
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.id, id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if current user has admin access to the workspace
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Only allow deleting pending invitations
|
||||
if (invitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete the invitation
|
||||
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { db } from '@/db'
|
||||
import { permissions, user, workspace, workspaceInvitation, workspaceMember } from '@/db/schema'
|
||||
import { permissions, user, workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// Accept an invitation via token
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -126,20 +126,21 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMembership = await db
|
||||
// Check if user already has permissions for this workspace
|
||||
const existingPermission = await db
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, invitation.workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
eq(permissions.entityId, invitation.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingMembership) {
|
||||
// User is already a member, just mark the invitation as accepted and redirect
|
||||
if (existingPermission) {
|
||||
// User already has permissions, just mark the invitation as accepted and redirect
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
@@ -156,35 +157,19 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Add user to workspace, permissions, and mark invitation as accepted in a transaction
|
||||
// Add user permissions and mark invitation as accepted in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Add user to workspace
|
||||
await tx.insert(workspaceMember).values({
|
||||
// Create permissions for the user
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
workspaceId: invitation.workspaceId,
|
||||
entityType: 'workspace' as const,
|
||||
entityId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
role: invitation.role,
|
||||
joinedAt: new Date(),
|
||||
permissionType: invitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Create permissions for the user
|
||||
const permissionsToInsert = [
|
||||
{
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: invitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
if (permissionsToInsert.length > 0) {
|
||||
await tx.insert(permissions).values(permissionsToInsert)
|
||||
}
|
||||
|
||||
// Mark invitation as accepted
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
|
||||
324
apps/sim/app/api/workspaces/invitations/route.test.ts
Normal file
324
apps/sim/app/api/workspaces/invitations/route.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Workspace Invitations API Route', () => {
|
||||
const mockWorkspace = { id: 'workspace-1', name: 'Test Workspace' }
|
||||
const mockUser = { id: 'user-1', email: 'test@example.com' }
|
||||
const mockInvitation = { id: 'invitation-1', status: 'pending' }
|
||||
|
||||
let mockDbResults: any[] = []
|
||||
let mockGetSession: any
|
||||
let mockResendSend: any
|
||||
let mockInsertValues: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
mockDbResults = []
|
||||
mockConsoleLogger()
|
||||
mockAuth(mockUser)
|
||||
|
||||
vi.doMock('crypto', () => ({
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234'),
|
||||
}))
|
||||
|
||||
mockGetSession = vi.fn()
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
mockInsertValues = vi.fn().mockResolvedValue(undefined)
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockImplementation((callback: any) => {
|
||||
const result = mockDbResults.shift() || []
|
||||
return callback ? callback(result) : Promise.resolve(result)
|
||||
}),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: mockInsertValues,
|
||||
}
|
||||
|
||||
vi.doMock('@/db', () => ({
|
||||
db: mockDbChain,
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
user: { id: 'user_id', email: 'user_email', name: 'user_name', image: 'user_image' },
|
||||
workspace: { id: 'workspace_id', name: 'workspace_name', ownerId: 'owner_id' },
|
||||
permissions: {
|
||||
userId: 'user_id',
|
||||
entityId: 'entity_id',
|
||||
entityType: 'entity_type',
|
||||
permissionType: 'permission_type',
|
||||
},
|
||||
workspaceInvitation: {
|
||||
id: 'invitation_id',
|
||||
workspaceId: 'workspace_id',
|
||||
email: 'invitation_email',
|
||||
status: 'invitation_status',
|
||||
token: 'invitation_token',
|
||||
inviterId: 'inviter_id',
|
||||
role: 'invitation_role',
|
||||
permissions: 'invitation_permissions',
|
||||
expiresAt: 'expires_at',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
permissionTypeEnum: { enumValues: ['admin', 'write', 'read'] as const },
|
||||
}))
|
||||
|
||||
mockResendSend = vi.fn().mockResolvedValue({ id: 'email-id' })
|
||||
vi.doMock('resend', () => ({
|
||||
Resend: vi.fn().mockImplementation(() => ({
|
||||
emails: { send: mockResendSend },
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.doMock('@react-email/render', () => ({
|
||||
render: vi.fn().mockResolvedValue('<html>email content</html>'),
|
||||
}))
|
||||
|
||||
vi.doMock('@/components/emails/workspace-invitation', () => ({
|
||||
WorkspaceInvitationEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
RESEND_API_KEY: 'test-resend-key',
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.simstudio.ai',
|
||||
EMAIL_DOMAIN: 'test.simstudio.ai',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/urls/utils', () => ({
|
||||
getEmailDomain: vi.fn().mockReturnValue('simstudio.ai'),
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })),
|
||||
eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })),
|
||||
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('GET /api/workspaces/invitations', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return empty invitations when user has no workspaces', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
mockDbResults = [[], []] // No workspaces, no invitations
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({ invitations: [] })
|
||||
})
|
||||
|
||||
it('should return invitations for user workspaces', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
const mockWorkspaces = [{ id: 'workspace-1' }, { id: 'workspace-2' }]
|
||||
const mockInvitations = [
|
||||
{ id: 'invitation-1', workspaceId: 'workspace-1', email: 'test@example.com' },
|
||||
{ id: 'invitation-2', workspaceId: 'workspace-2', email: 'test2@example.com' },
|
||||
]
|
||||
mockDbResults = [mockWorkspaces, mockInvitations]
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = createMockRequest('GET')
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({ invitations: mockInvitations })
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/workspaces/invitations', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'workspace-1',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 400 when workspaceId is missing', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', { email: 'test@example.com' })
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: 'Workspace ID and email are required' })
|
||||
})
|
||||
|
||||
it('should return 400 when email is missing', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', { workspaceId: 'workspace-1' })
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: 'Workspace ID and email are required' })
|
||||
})
|
||||
|
||||
it('should return 400 when permission type is invalid', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'workspace-1',
|
||||
email: 'test@example.com',
|
||||
permission: 'invalid-permission',
|
||||
})
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({
|
||||
error: 'Invalid permission: must be one of admin, write, read',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 403 when user does not have admin permissions', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
mockDbResults = [[]] // No admin permissions found
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'workspace-1',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'You need admin permissions to invite users' })
|
||||
})
|
||||
|
||||
it('should return 404 when workspace is not found', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
mockDbResults = [
|
||||
[{ permissionType: 'admin' }], // User has admin permissions
|
||||
[], // Workspace not found
|
||||
]
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'workspace-1',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toEqual({ error: 'Workspace not found' })
|
||||
})
|
||||
|
||||
it('should return 400 when user already has workspace access', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
mockDbResults = [
|
||||
[{ permissionType: 'admin' }], // User has admin permissions
|
||||
[mockWorkspace], // Workspace exists
|
||||
[mockUser], // User exists
|
||||
[{ permissionType: 'read' }], // User already has access
|
||||
]
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'workspace-1',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({
|
||||
error: 'test@example.com already has access to this workspace',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 400 when invitation already exists', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
mockDbResults = [
|
||||
[{ permissionType: 'admin' }], // User has admin permissions
|
||||
[mockWorkspace], // Workspace exists
|
||||
[], // User doesn't exist
|
||||
[mockInvitation], // Invitation exists
|
||||
]
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'workspace-1',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({
|
||||
error: 'test@example.com has already been invited to this workspace',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('should successfully create invitation and send email', async () => {
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-123', name: 'Test User', email: 'sender@example.com' },
|
||||
})
|
||||
mockDbResults = [
|
||||
[{ permissionType: 'admin' }], // User has admin permissions
|
||||
[mockWorkspace], // Workspace exists
|
||||
[], // User doesn't exist
|
||||
[], // No existing invitation
|
||||
]
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = createMockRequest('POST', {
|
||||
workspaceId: 'workspace-1',
|
||||
email: 'test@example.com',
|
||||
permission: 'read',
|
||||
})
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.invitation).toBeDefined()
|
||||
expect(data.invitation.email).toBe('test@example.com')
|
||||
expect(data.invitation.permissions).toBe('read')
|
||||
expect(data.invitation.token).toBe('mock-uuid-1234')
|
||||
expect(mockInsertValues).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,11 +10,11 @@ import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
permissions,
|
||||
type permissionTypeEnum,
|
||||
user,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
workspaceMember,
|
||||
} from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -33,15 +33,16 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all workspaces where the user is a member (any role)
|
||||
// Get all workspaces where the user has permissions
|
||||
const userWorkspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.innerJoin(
|
||||
workspaceMember,
|
||||
permissions,
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, workspace.id),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
eq(permissions.entityId, workspace.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -89,20 +90,25 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is authorized to invite to this workspace (must be owner)
|
||||
const membership = await db
|
||||
// Check if user has admin permissions for this workspace
|
||||
const userPermission = await db
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id)
|
||||
eq(permissions.entityId, workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'You are not a member of this workspace' }, { status: 403 })
|
||||
if (!userPermission) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You need admin permissions to invite users' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the workspace details for the email
|
||||
@@ -125,22 +131,23 @@ export async function POST(req: NextRequest) {
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingUser) {
|
||||
// Check if the user is already a member of this workspace
|
||||
const existingMembership = await db
|
||||
// Check if the user already has permissions for this workspace
|
||||
const existingPermission = await db
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, workspaceId),
|
||||
eq(workspaceMember.userId, existingUser.id)
|
||||
eq(permissions.entityId, workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, existingUser.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingMembership) {
|
||||
if (existingPermission) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${email} is already a member of this workspace`,
|
||||
error: `${email} already has access to this workspace`,
|
||||
email,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -245,14 +252,19 @@ async function sendInvitationEmail({
|
||||
)
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: `noreply@${getEmailDomain()}`,
|
||||
const emailDomain = env.EMAIL_DOMAIN || getEmailDomain()
|
||||
const fromAddress = `noreply@${emailDomain}`
|
||||
|
||||
logger.info(`Attempting to send email from ${fromAddress} to ${to}`)
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: fromAddress,
|
||||
to,
|
||||
subject: `You've been invited to join "${workspaceName}" on Sim Studio`,
|
||||
html: emailHtml,
|
||||
})
|
||||
|
||||
logger.info(`Invitation email sent to ${to}`)
|
||||
logger.info(`Invitation email sent successfully to ${to}`, { result })
|
||||
} catch (error) {
|
||||
logger.error('Error sending invitation email:', error)
|
||||
// Continue even if email fails - the invitation is still created
|
||||
|
||||
@@ -1,79 +1,85 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workspaceMember } from '@/db/schema'
|
||||
import { permissions } from '@/db/schema'
|
||||
|
||||
// DELETE /api/workspaces/members/[id] - Remove a member from a workspace
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const { id: userId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const membershipId = id
|
||||
|
||||
try {
|
||||
// Get the membership to delete
|
||||
const membership = await db
|
||||
.select({
|
||||
id: workspaceMember.id,
|
||||
workspaceId: workspaceMember.workspaceId,
|
||||
userId: workspaceMember.userId,
|
||||
role: workspaceMember.role,
|
||||
})
|
||||
.from(workspaceMember)
|
||||
.where(eq(workspaceMember.id, membershipId))
|
||||
.then((rows) => rows[0])
|
||||
// Get the workspace ID from the request body or URL
|
||||
const body = await req.json()
|
||||
const workspaceId = body.workspaceId
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'Membership not found' }, { status: 404 })
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if current user is an owner of the workspace or the member being removed
|
||||
const isOwner = await db
|
||||
// Check if the user to be removed actually has permissions for this workspace
|
||||
const userPermission = await db
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, membership.workspaceId),
|
||||
eq(workspaceMember.userId, session.user.id),
|
||||
eq(workspaceMember.role, 'owner')
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.length > 0)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
const isSelf = membership.userId === session.user.id
|
||||
if (!userPermission) {
|
||||
return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!isOwner && !isSelf) {
|
||||
// Check if current user has admin access to this workspace
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
|
||||
const isSelf = userId === session.user.id
|
||||
|
||||
if (!hasAdminAccess && !isSelf) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Prevent removing yourself if you're the owner and the last owner
|
||||
if (isSelf && membership.role === 'owner') {
|
||||
const otherOwners = await db
|
||||
// Prevent removing yourself if you're the last admin
|
||||
if (isSelf && userPermission.permissionType === 'admin') {
|
||||
const otherAdmins = await db
|
||||
.select()
|
||||
.from(workspaceMember)
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceMember.workspaceId, membership.workspaceId),
|
||||
eq(workspaceMember.role, 'owner')
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.filter((row) => row.userId !== session.user.id))
|
||||
|
||||
if (otherOwners.length === 0) {
|
||||
if (otherAdmins.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot remove the last owner from a workspace' },
|
||||
{ error: 'Cannot remove the last admin from a workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the membership
|
||||
await db.delete(workspaceMember).where(eq(workspaceMember.id, membershipId))
|
||||
// Delete the user's permissions for this workspace
|
||||
await db
|
||||
.delete(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { permissions, type permissionTypeEnum, user, workspaceMember } from '@/db/schema'
|
||||
import { permissions, type permissionTypeEnum, user } from '@/db/schema'
|
||||
|
||||
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
@@ -71,28 +71,15 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Use a transaction to ensure data consistency
|
||||
await db.transaction(async (tx) => {
|
||||
// Add user to workspace members table (keeping for compatibility)
|
||||
await tx.insert(workspaceMember).values({
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId,
|
||||
userId: targetUser.id,
|
||||
role: 'member', // Default role for compatibility
|
||||
joinedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Create single permission for the new member
|
||||
await tx.insert(permissions).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: targetUser.id,
|
||||
entityType: 'workspace' as const,
|
||||
entityId: workspaceId,
|
||||
permissionType: permission,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
// Create single permission for the new member
|
||||
await db.insert(permissions).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: targetUser.id,
|
||||
entityType: 'workspace' as const,
|
||||
entityId: workspaceId,
|
||||
permissionType: permission,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -2,9 +2,8 @@ import crypto from 'crypto'
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { permissions, workflow, workflowBlocks, workspace, workspaceMember } from '@/db/schema'
|
||||
import { permissions, workflow, workflowBlocks, workspace } from '@/db/schema'
|
||||
|
||||
// Get all workspaces for the current user
|
||||
export async function GET() {
|
||||
@@ -14,19 +13,18 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get all workspaces where the user is a member with a single join query
|
||||
const memberWorkspaces = await db
|
||||
// Get all workspaces where the user has permissions
|
||||
const userWorkspaces = await db
|
||||
.select({
|
||||
workspace: workspace,
|
||||
role: workspaceMember.role,
|
||||
membershipId: workspaceMember.id,
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
.from(workspaceMember)
|
||||
.innerJoin(workspace, eq(workspaceMember.workspaceId, workspace.id))
|
||||
.where(eq(workspaceMember.userId, session.user.id))
|
||||
.orderBy(desc(workspaceMember.joinedAt))
|
||||
.from(permissions)
|
||||
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
|
||||
if (memberWorkspaces.length === 0) {
|
||||
if (userWorkspaces.length === 0) {
|
||||
// Create a default workspace for the user
|
||||
const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name)
|
||||
|
||||
@@ -37,23 +35,14 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// If user has workspaces but might have orphaned workflows, migrate them
|
||||
await ensureWorkflowsHaveWorkspace(session.user.id, memberWorkspaces[0].workspace.id)
|
||||
await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id)
|
||||
|
||||
// Get permissions for each workspace and format the response
|
||||
const workspacesWithPermissions = await Promise.all(
|
||||
memberWorkspaces.map(async ({ workspace: workspaceDetails, role, membershipId }) => {
|
||||
const userPermissions = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceDetails.id
|
||||
)
|
||||
|
||||
return {
|
||||
...workspaceDetails,
|
||||
role,
|
||||
membershipId,
|
||||
permissions: userPermissions,
|
||||
}
|
||||
// Format the response with permission information
|
||||
const workspacesWithPermissions = userWorkspaces.map(
|
||||
({ workspace: workspaceDetails, permissionType }) => ({
|
||||
...workspaceDetails,
|
||||
role: permissionType === 'admin' ? 'owner' : 'member', // Map admin to owner for compatibility
|
||||
permissions: permissionType,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -108,13 +97,14 @@ async function createWorkspace(userId: string, name: string) {
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Add the user as a member with owner role
|
||||
await tx.insert(workspaceMember).values({
|
||||
// Create admin permissions for the workspace owner
|
||||
await tx.insert(permissions).values({
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId,
|
||||
userId,
|
||||
role: 'owner',
|
||||
joinedAt: now,
|
||||
entityType: 'workspace' as const,
|
||||
entityId: workspaceId,
|
||||
userId: userId,
|
||||
permissionType: 'admin' as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
@@ -263,17 +253,6 @@ async function createWorkspace(userId: string, name: string) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Create default permissions for the workspace owner
|
||||
await db.insert(permissions).values({
|
||||
id: crypto.randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: workspaceId,
|
||||
userId: userId,
|
||||
permissionType: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Return the workspace data directly instead of querying again
|
||||
return {
|
||||
id: workspaceId,
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
const messageToSend = messageParam ?? inputValue
|
||||
if (!messageToSend.trim() || isLoading) return
|
||||
|
||||
logger.info('Sending message:', { messageToSend, isVoiceInput, conversationId })
|
||||
|
||||
// Reset userHasScrolled when sending a new message
|
||||
setUserHasScrolled(false)
|
||||
|
||||
@@ -305,6 +307,8 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
conversationId,
|
||||
}
|
||||
|
||||
logger.info('API payload:', payload)
|
||||
|
||||
const response = await fetch(`/api/chat/${subdomain}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -321,6 +325,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('API error response:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to get response')
|
||||
}
|
||||
|
||||
@@ -334,6 +339,8 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
? createAudioStreamHandler(streamTextToAudio, DEFAULT_VOICE_SETTINGS.voiceId)
|
||||
: undefined
|
||||
|
||||
logger.info('Starting to handle streamed response:', { shouldPlayAudio })
|
||||
|
||||
await handleStreamedResponse(
|
||||
response,
|
||||
setMessages,
|
||||
@@ -405,6 +412,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
// Handle voice transcript from voice-first interface
|
||||
const handleVoiceTranscript = useCallback(
|
||||
(transcript: string) => {
|
||||
logger.info('Received voice transcript:', transcript)
|
||||
handleSendMessage(transcript, true)
|
||||
},
|
||||
[handleSendMessage]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Mic, MicOff, Phone, X } from 'lucide-react'
|
||||
import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -68,132 +68,136 @@ export function VoiceInterface({
|
||||
messages = [],
|
||||
className,
|
||||
}: VoiceInterfaceProps) {
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
// Simple state machine
|
||||
const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [audioLevels, setAudioLevels] = useState<number[]>(new Array(200).fill(0))
|
||||
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>(
|
||||
const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>(
|
||||
'prompt'
|
||||
)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Current turn transcript (subtitle)
|
||||
const [currentTranscript, setCurrentTranscript] = useState('')
|
||||
|
||||
// State tracking
|
||||
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
currentStateRef.current = state
|
||||
}, [state])
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const localAudioContextRef = useRef<AudioContext | null>(null)
|
||||
const audioContextRef = sharedAudioContextRef || localAudioContextRef
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null)
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const isStartingRef = useRef(false)
|
||||
const isMutedRef = useRef(false)
|
||||
const compressorRef = useRef<DynamicsCompressorNode | null>(null)
|
||||
const gainNodeRef = useRef<GainNode | null>(null)
|
||||
const responseTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition)
|
||||
|
||||
// Update muted ref
|
||||
useEffect(() => {
|
||||
isMutedRef.current = isMuted
|
||||
}, [isMuted])
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
// Timeout to handle cases where agent doesn't provide audio response
|
||||
const setResponseTimeout = useCallback(() => {
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
}
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
|
||||
mediaStreamRef.current = null
|
||||
}
|
||||
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close()
|
||||
audioContextRef.current = null
|
||||
}
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
responseTimeoutRef.current = setTimeout(() => {
|
||||
if (currentStateRef.current === 'listening') {
|
||||
setState('idle')
|
||||
}
|
||||
recognitionRef.current = null
|
||||
}
|
||||
|
||||
analyserRef.current = null
|
||||
setAudioLevels(new Array(200).fill(0))
|
||||
setIsListening(false)
|
||||
}, 5000) // 5 second timeout (increased from 3)
|
||||
}, [])
|
||||
|
||||
const setupAudioVisualization = useCallback(async () => {
|
||||
const clearResponseTimeout = useCallback(() => {
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
responseTimeoutRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Sync with external state
|
||||
useEffect(() => {
|
||||
if (isPlayingAudio && state !== 'agent_speaking') {
|
||||
clearResponseTimeout() // Clear timeout since agent is responding
|
||||
setState('agent_speaking')
|
||||
setCurrentTranscript('')
|
||||
|
||||
// Mute microphone immediately
|
||||
setIsMuted(true)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = false
|
||||
})
|
||||
}
|
||||
|
||||
// Stop speech recognition completely
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort()
|
||||
} catch (error) {
|
||||
logger.debug('Error aborting speech recognition:', error)
|
||||
}
|
||||
}
|
||||
} else if (!isPlayingAudio && state === 'agent_speaking') {
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
// Re-enable microphone
|
||||
setIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isPlayingAudio, state, clearResponseTimeout])
|
||||
|
||||
// Audio setup
|
||||
const setupAudio = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
sampleRate: 44100,
|
||||
channelCount: 1,
|
||||
// Enhanced echo cancellation settings to prevent picking up speaker output
|
||||
suppressLocalAudioPlayback: true, // Modern browsers
|
||||
googEchoCancellation: true, // Chrome-specific
|
||||
googAutoGainControl: true,
|
||||
googNoiseSuppression: true,
|
||||
googHighpassFilter: true,
|
||||
googTypingNoiseDetection: true,
|
||||
} as any, // Type assertion for experimental properties
|
||||
},
|
||||
})
|
||||
|
||||
setPermissionStatus('granted')
|
||||
mediaStreamRef.current = stream
|
||||
|
||||
// Setup audio context for visualization
|
||||
if (!audioContextRef.current) {
|
||||
const AudioContextConstructor = window.AudioContext || window.webkitAudioContext
|
||||
if (!AudioContextConstructor) {
|
||||
throw new Error('AudioContext is not supported in this browser')
|
||||
}
|
||||
audioContextRef.current = new AudioContextConstructor()
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||
audioContextRef.current = new AudioContext()
|
||||
}
|
||||
const audioContext = audioContextRef.current
|
||||
|
||||
const audioContext = audioContextRef.current
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume()
|
||||
}
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream)
|
||||
|
||||
const gainNode = audioContext.createGain()
|
||||
gainNode.gain.setValueAtTime(1, audioContext.currentTime)
|
||||
|
||||
const compressor = audioContext.createDynamicsCompressor()
|
||||
compressor.threshold.setValueAtTime(-50, audioContext.currentTime)
|
||||
compressor.knee.setValueAtTime(40, audioContext.currentTime)
|
||||
compressor.ratio.setValueAtTime(12, audioContext.currentTime)
|
||||
compressor.attack.setValueAtTime(0, audioContext.currentTime)
|
||||
compressor.release.setValueAtTime(0.25, audioContext.currentTime)
|
||||
|
||||
const analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
analyser.smoothingTimeConstant = 0.5
|
||||
analyser.smoothingTimeConstant = 0.8
|
||||
|
||||
source.connect(gainNode)
|
||||
gainNode.connect(compressor)
|
||||
compressor.connect(analyser)
|
||||
|
||||
audioContextRef.current = audioContext
|
||||
source.connect(analyser)
|
||||
analyserRef.current = analyser
|
||||
compressorRef.current = compressor
|
||||
gainNodeRef.current = gainNode
|
||||
|
||||
// Start visualization loop
|
||||
// Start visualization
|
||||
const updateVisualization = () => {
|
||||
if (!analyserRef.current) return
|
||||
|
||||
if (isMutedRef.current) {
|
||||
setAudioLevels(new Array(200).fill(0))
|
||||
animationFrameRef.current = requestAnimationFrame(updateVisualization)
|
||||
return
|
||||
}
|
||||
|
||||
const bufferLength = analyserRef.current.frequencyBinCount
|
||||
const dataArray = new Uint8Array(bufferLength)
|
||||
analyserRef.current.getByteFrequencyData(dataArray)
|
||||
@@ -210,280 +214,354 @@ export function VoiceInterface({
|
||||
}
|
||||
|
||||
updateVisualization()
|
||||
setIsInitialized(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Error setting up audio:', error)
|
||||
setPermissionStatus('denied')
|
||||
return false
|
||||
}
|
||||
}, [isMuted])
|
||||
}, [])
|
||||
|
||||
// Start listening
|
||||
const startListening = useCallback(async () => {
|
||||
if (
|
||||
!isSupported ||
|
||||
!recognitionRef.current ||
|
||||
isListening ||
|
||||
isMuted ||
|
||||
isStartingRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isStartingRef.current = true
|
||||
|
||||
if (!mediaStreamRef.current) {
|
||||
await setupAudioVisualization()
|
||||
}
|
||||
|
||||
recognitionRef.current.start()
|
||||
} catch (error) {
|
||||
isStartingRef.current = false
|
||||
logger.error('Error starting voice input:', error)
|
||||
setIsListening(false)
|
||||
}
|
||||
}, [isSupported, isListening, setupAudioVisualization, isMuted])
|
||||
|
||||
const initializeSpeechRecognition = useCallback(() => {
|
||||
if (!isSupported || recognitionRef.current) return
|
||||
// Speech recognition setup
|
||||
const setupSpeechRecognition = useCallback(() => {
|
||||
if (!isSupported) return
|
||||
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
if (!SpeechRecognition) return
|
||||
|
||||
const recognition = new SpeechRecognition()
|
||||
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
recognition.lang = 'en-US'
|
||||
|
||||
recognition.onstart = () => {
|
||||
isStartingRef.current = false
|
||||
setIsListening(true)
|
||||
onVoiceStart?.()
|
||||
}
|
||||
recognition.onstart = () => {}
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
// Don't process results if muted
|
||||
if (isMutedRef.current) {
|
||||
const currentState = currentStateRef.current
|
||||
|
||||
if (isMutedRef.current || currentState !== 'listening') {
|
||||
return
|
||||
}
|
||||
|
||||
let finalTranscript = ''
|
||||
let interimTranscript = ''
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
const transcript = result[0].transcript
|
||||
|
||||
if (result.isFinal) {
|
||||
finalTranscript += result[0].transcript
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
if (isPlayingAudio) {
|
||||
const cleanTranscript = finalTranscript.trim().toLowerCase()
|
||||
const isSubstantialSpeech = cleanTranscript.length >= 10
|
||||
const hasMultipleWords = cleanTranscript.split(/\s+/).length >= 3
|
||||
|
||||
if (isSubstantialSpeech && hasMultipleWords) {
|
||||
onInterrupt?.()
|
||||
onVoiceTranscript?.(finalTranscript)
|
||||
}
|
||||
finalTranscript += transcript
|
||||
} else {
|
||||
onVoiceTranscript?.(finalTranscript)
|
||||
interimTranscript += transcript
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
isStartingRef.current = false
|
||||
logger.error('Speech recognition error:', event.error)
|
||||
// Update live transcript
|
||||
setCurrentTranscript(interimTranscript || finalTranscript)
|
||||
|
||||
if (event.error === 'not-allowed') {
|
||||
setPermissionStatus('denied')
|
||||
setIsListening(false)
|
||||
onVoiceEnd?.()
|
||||
return
|
||||
}
|
||||
// Send final transcript (but keep listening state until agent responds)
|
||||
if (finalTranscript.trim()) {
|
||||
setCurrentTranscript('') // Clear transcript
|
||||
|
||||
if (!isMutedRef.current && !isStartingRef.current) {
|
||||
setTimeout(() => {
|
||||
if (recognitionRef.current && !isMutedRef.current && !isStartingRef.current) {
|
||||
startListening()
|
||||
// Stop recognition to avoid interference while waiting for response
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// Start timeout in case agent doesn't provide audio response
|
||||
setResponseTimeout()
|
||||
|
||||
onVoiceTranscript?.(finalTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
isStartingRef.current = false
|
||||
setIsListening(false)
|
||||
onVoiceEnd?.()
|
||||
const currentState = currentStateRef.current
|
||||
|
||||
if (!isMutedRef.current && !isStartingRef.current) {
|
||||
// Only restart recognition if we're in listening state and not muted
|
||||
if (currentState === 'listening' && !isMutedRef.current) {
|
||||
// Add a delay to avoid immediate restart after sending transcript
|
||||
setTimeout(() => {
|
||||
if (recognitionRef.current && !isMutedRef.current && !isStartingRef.current) {
|
||||
startListening()
|
||||
// Double-check state hasn't changed during delay
|
||||
if (
|
||||
recognitionRef.current &&
|
||||
currentStateRef.current === 'listening' &&
|
||||
!isMutedRef.current
|
||||
) {
|
||||
try {
|
||||
recognitionRef.current.start()
|
||||
} catch (error) {
|
||||
logger.debug('Error restarting speech recognition:', error)
|
||||
}
|
||||
}
|
||||
}, 200)
|
||||
}, 1000) // Longer delay to give agent time to respond
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
// Filter out "aborted" errors - these are expected when we intentionally stop recognition
|
||||
if (event.error === 'aborted') {
|
||||
// Ignore
|
||||
return
|
||||
}
|
||||
|
||||
if (event.error === 'not-allowed') {
|
||||
setPermissionStatus('denied')
|
||||
}
|
||||
}
|
||||
|
||||
recognitionRef.current = recognition
|
||||
setIsInitialized(true)
|
||||
}, [
|
||||
isSupported,
|
||||
isPlayingAudio,
|
||||
isMuted,
|
||||
onVoiceStart,
|
||||
onVoiceEnd,
|
||||
onVoiceTranscript,
|
||||
onInterrupt,
|
||||
startListening,
|
||||
])
|
||||
}, [isSupported, onVoiceTranscript, setResponseTimeout])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const newMutedState = !isMuted
|
||||
// Start/stop listening
|
||||
const startListening = useCallback(() => {
|
||||
if (!isInitialized || isMuted || state !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
if (newMutedState) {
|
||||
isStartingRef.current = false
|
||||
setState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.start()
|
||||
} catch (error) {
|
||||
logger.error('Error starting recognition:', error)
|
||||
}
|
||||
}
|
||||
}, [isInitialized, isMuted, state])
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = false
|
||||
})
|
||||
const stopListening = useCallback(() => {
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
setIsListening(false)
|
||||
} else {
|
||||
// Handle interrupt
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
// Clear any subtitle timeouts and text
|
||||
// (No longer needed after removing subtitle system)
|
||||
|
||||
onInterrupt?.()
|
||||
setState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
// Unmute microphone for user input
|
||||
setIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
})
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!isMutedRef.current) {
|
||||
startListening()
|
||||
|
||||
// Start listening immediately
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.start()
|
||||
} catch (error) {
|
||||
logger.error('Could not start recognition after interrupt:', error)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}, [state, onInterrupt])
|
||||
|
||||
// Handle call end with proper cleanup
|
||||
const handleCallEnd = useCallback(() => {
|
||||
// Stop everything immediately
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
setIsMuted(false)
|
||||
|
||||
// Stop speech recognition
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort()
|
||||
} catch (error) {
|
||||
logger.error('Error stopping speech recognition:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMuted(newMutedState)
|
||||
}, [isMuted, isListening, startListening])
|
||||
// Clear timeouts
|
||||
clearResponseTimeout()
|
||||
|
||||
const handleEndCall = useCallback(() => {
|
||||
cleanup()
|
||||
// Stop audio playback and streaming immediately
|
||||
onInterrupt?.()
|
||||
|
||||
// Call the original onCallEnd
|
||||
onCallEnd?.()
|
||||
}, [cleanup, onCallEnd])
|
||||
}, [onCallEnd, onInterrupt, clearResponseTimeout])
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isStreaming) return 'Thinking...'
|
||||
if (isPlayingAudio) return 'Speaking...'
|
||||
if (isListening) return 'Listening...'
|
||||
return 'Ready'
|
||||
}
|
||||
// Keyboard handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault()
|
||||
handleInterrupt()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleInterrupt])
|
||||
|
||||
// Mute toggle
|
||||
const toggleMute = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
handleInterrupt()
|
||||
return
|
||||
}
|
||||
|
||||
const newMutedState = !isMuted
|
||||
setIsMuted(newMutedState)
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = !newMutedState
|
||||
})
|
||||
}
|
||||
|
||||
if (newMutedState) {
|
||||
stopListening()
|
||||
} else if (state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
}, [isMuted, state, handleInterrupt, stopListening, startListening])
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
if (isSupported) {
|
||||
initializeSpeechRecognition()
|
||||
setupSpeechRecognition()
|
||||
setupAudio()
|
||||
}
|
||||
}, [isSupported, initializeSpeechRecognition])
|
||||
}, [isSupported, setupSpeechRecognition, setupAudio])
|
||||
|
||||
// Auto-start listening when ready
|
||||
useEffect(() => {
|
||||
if (isInitialized && !isMuted && !isListening) {
|
||||
const startAudio = async () => {
|
||||
try {
|
||||
if (!mediaStreamRef.current) {
|
||||
const success = await setupAudioVisualization()
|
||||
if (!success) {
|
||||
logger.error('Failed to setup audio visualization')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isListening && !isMuted && !isStartingRef.current) {
|
||||
startListening()
|
||||
}
|
||||
}, 300)
|
||||
} catch (error) {
|
||||
logger.error('Error setting up audio:', error)
|
||||
}
|
||||
}
|
||||
|
||||
startAudio()
|
||||
if (isInitialized && !isMuted && state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
}, [isInitialized, isMuted, isListening, setupAudioVisualization, startListening])
|
||||
|
||||
// Gain ducking during audio playback
|
||||
useEffect(() => {
|
||||
if (gainNodeRef.current && audioContextRef.current) {
|
||||
const gainNode = gainNodeRef.current
|
||||
const audioContext = audioContextRef.current
|
||||
|
||||
if (isPlayingAudio) {
|
||||
gainNode.gain.setTargetAtTime(0.1, audioContext.currentTime, 0.1)
|
||||
} else {
|
||||
gainNode.gain.setTargetAtTime(1, audioContext.currentTime, 0.2)
|
||||
}
|
||||
}
|
||||
}, [isPlayingAudio])
|
||||
}, [isInitialized, isMuted, state, startListening])
|
||||
|
||||
// Cleanup when call ends or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup()
|
||||
// Stop speech recognition
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort()
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
recognitionRef.current = null
|
||||
}
|
||||
|
||||
// Stop media stream
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => {
|
||||
track.stop()
|
||||
})
|
||||
mediaStreamRef.current = null
|
||||
}
|
||||
|
||||
// Stop audio context
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close()
|
||||
audioContextRef.current = null
|
||||
}
|
||||
|
||||
// Cancel animation frame
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
|
||||
// Clear timeouts
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
responseTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [cleanup])
|
||||
}, [])
|
||||
|
||||
// Get status text
|
||||
const getStatusText = () => {
|
||||
switch (state) {
|
||||
case 'listening':
|
||||
return 'Listening...'
|
||||
case 'agent_speaking':
|
||||
return 'Press Space or tap to interrupt'
|
||||
default:
|
||||
return isInitialized ? 'Ready' : 'Initializing...'
|
||||
}
|
||||
}
|
||||
|
||||
// Get button content
|
||||
const getButtonContent = () => {
|
||||
if (state === 'agent_speaking') {
|
||||
return (
|
||||
<svg className='h-6 w-6' viewBox='0 0 24 24' fill='currentColor'>
|
||||
<rect x='6' y='6' width='12' height='12' rx='2' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return isMuted ? <MicOff className='h-6 w-6' /> : <Mic className='h-6 w-6' />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('fixed inset-0 z-[100] flex flex-col bg-white text-gray-900', className)}>
|
||||
{/* Header with close button */}
|
||||
<div className='flex justify-end p-4'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={handleEndCall}
|
||||
className='h-10 w-10 rounded-full hover:bg-gray-100'
|
||||
>
|
||||
<X className='h-5 w-5' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
{/* Main content */}
|
||||
<div className='flex flex-1 flex-col items-center justify-center px-8'>
|
||||
{/* Voice visualization */}
|
||||
<div className='relative mb-16'>
|
||||
<ParticlesVisualization
|
||||
audioLevels={audioLevels}
|
||||
isListening={isListening}
|
||||
isPlayingAudio={isPlayingAudio}
|
||||
isListening={state === 'listening'}
|
||||
isPlayingAudio={state === 'agent_speaking'}
|
||||
isStreaming={isStreaming}
|
||||
isMuted={isMuted}
|
||||
isProcessingInterruption={false}
|
||||
className='h-80 w-80 md:h-96 md:w-96'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<div className='mb-8 text-center'>
|
||||
<p className='font-light text-gray-600 text-lg'>
|
||||
{getStatusText()}
|
||||
{isMuted && <span className='ml-2 text-gray-400 text-sm'>(Muted)</span>}
|
||||
</p>
|
||||
{/* Live transcript - subtitle style */}
|
||||
<div className='mb-16 flex h-24 items-center justify-center'>
|
||||
{currentTranscript && (
|
||||
<div className='max-w-2xl px-8'>
|
||||
<p className='overflow-hidden text-center text-gray-700 text-xl leading-relaxed'>
|
||||
{currentTranscript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<p className='mb-8 text-center text-gray-600 text-lg'>
|
||||
{getStatusText()}
|
||||
{isMuted && <span className='ml-2 text-gray-400 text-sm'>(Muted)</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
{/* Controls */}
|
||||
<div className='px-8 pb-12'>
|
||||
<div className='flex items-center justify-center space-x-12'>
|
||||
{/* End call button */}
|
||||
{/* End call */}
|
||||
<Button
|
||||
onClick={handleEndCall}
|
||||
onClick={handleCallEnd}
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-14 w-14 rounded-full border-gray-300 hover:bg-gray-50'
|
||||
@@ -491,17 +569,18 @@ export function VoiceInterface({
|
||||
<Phone className='h-6 w-6 rotate-[135deg]' />
|
||||
</Button>
|
||||
|
||||
{/* Mute/unmute button */}
|
||||
{/* Mic/Stop button */}
|
||||
<Button
|
||||
onClick={toggleMute}
|
||||
variant='outline'
|
||||
size='icon'
|
||||
disabled={!isInitialized}
|
||||
className={cn(
|
||||
'h-14 w-14 rounded-full border-gray-300 bg-transparent text-gray-600 hover:bg-gray-50',
|
||||
isMuted && 'text-gray-400'
|
||||
'h-14 w-14 rounded-full border-gray-300 bg-transparent hover:bg-gray-50',
|
||||
isMuted ? 'text-gray-400' : 'text-gray-600'
|
||||
)}
|
||||
>
|
||||
{isMuted ? <MicOff className='h-6 w-6' /> : <Mic className='h-6 w-6' />}
|
||||
{getButtonContent()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Award,
|
||||
BarChart3,
|
||||
@@ -40,9 +41,13 @@ import {
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const logger = createLogger('TemplateCard')
|
||||
|
||||
// Icon mapping for template icons
|
||||
const iconMap = {
|
||||
// Content & Documentation
|
||||
@@ -120,10 +125,11 @@ interface TemplateCardProps {
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
// Add handlers for star and use actions
|
||||
onStar?: (templateId: string, isCurrentlyStarred: boolean) => Promise<void>
|
||||
onUse?: (templateId: string) => Promise<void>
|
||||
isStarred?: boolean
|
||||
// Optional callback when template is successfully used (for closing modals, etc.)
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
}
|
||||
|
||||
// Skeleton component for loading states
|
||||
@@ -225,10 +231,18 @@ export function TemplateCard({
|
||||
onClick,
|
||||
className,
|
||||
state,
|
||||
onStar,
|
||||
onUse,
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
|
||||
const [localStarCount, setLocalStarCount] = useState(stars)
|
||||
const [isStarLoading, setIsStarLoading] = useState(false)
|
||||
|
||||
// Extract block types from state if provided, otherwise use the blocks prop
|
||||
// Filter out starter blocks in both cases and sort for consistent rendering
|
||||
const blockTypes = state
|
||||
@@ -238,19 +252,98 @@ export function TemplateCard({
|
||||
// Get the icon component
|
||||
const iconComponent = getIconComponent(icon)
|
||||
|
||||
// Handle star toggle
|
||||
// Handle star toggle with optimistic updates
|
||||
const handleStarClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onStar) {
|
||||
await onStar(id, isStarred)
|
||||
|
||||
// Prevent multiple clicks while loading
|
||||
if (isStarLoading) return
|
||||
|
||||
setIsStarLoading(true)
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
const newIsStarred = !localIsStarred
|
||||
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
|
||||
|
||||
setLocalIsStarred(newIsStarred)
|
||||
setLocalStarCount(newStarCount)
|
||||
|
||||
// Notify parent component immediately for optimistic update
|
||||
if (onStarChange) {
|
||||
onStarChange(id, newIsStarred, newStarCount)
|
||||
}
|
||||
|
||||
try {
|
||||
const method = localIsStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${id}/star`, { method })
|
||||
|
||||
if (!response.ok) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Failed to toggle star:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalIsStarred(localIsStarred)
|
||||
setLocalStarCount(localStarCount)
|
||||
|
||||
// Rollback parent state too
|
||||
if (onStarChange) {
|
||||
onStarChange(id, localIsStarred, localStarCount)
|
||||
}
|
||||
|
||||
logger.error('Error toggling star:', error)
|
||||
} finally {
|
||||
setIsStarLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle use template
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onUse) {
|
||||
await onUse(id)
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('Template use API response:', data)
|
||||
|
||||
if (!data.workflowId) {
|
||||
logger.error('No workflowId returned from API:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
|
||||
logger.info('Template used successfully, navigating to:', workflowUrl)
|
||||
|
||||
// Call the callback if provided (for closing modals, etc.)
|
||||
if (onTemplateUsed) {
|
||||
onTemplateUsed()
|
||||
}
|
||||
|
||||
// Use window.location.href for more reliable navigation
|
||||
window.location.href = workflowUrl
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to use template:', response.statusText, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +358,7 @@ export function TemplateCard({
|
||||
{/* Left side - Info */}
|
||||
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
|
||||
{/* Top section */}
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 items-center gap-2.5'>
|
||||
{/* Icon container */}
|
||||
@@ -293,10 +386,11 @@ export function TemplateCard({
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors',
|
||||
isStarred
|
||||
'h-4 w-4 cursor-pointer transition-all duration-200',
|
||||
localIsStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
@@ -319,7 +413,7 @@ export function TemplateCard({
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className='flex min-w-0 items-center gap-1.5 font-sans text-muted-foreground text-xs'>
|
||||
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
|
||||
<span className='flex-shrink-0'>by</span>
|
||||
<span className='min-w-0 truncate'>{author}</span>
|
||||
<span className='flex-shrink-0'>•</span>
|
||||
@@ -329,7 +423,7 @@ export function TemplateCard({
|
||||
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
|
||||
<span>•</span>
|
||||
<Star className='h-3 w-3' />
|
||||
<span>{stars}</span>
|
||||
<span>{localStarCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,75 +90,18 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
}
|
||||
}
|
||||
|
||||
const handleTemplateClick = (templateId: string) => {
|
||||
// Navigate to template detail page
|
||||
router.push(`/workspace/${params.workspaceId}/templates/${templateId}`)
|
||||
}
|
||||
|
||||
// Handle using a template
|
||||
const handleUseTemplate = async (templateId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${templateId}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('Template use API response:', data)
|
||||
|
||||
if (!data.workflowId) {
|
||||
logger.error('No workflowId returned from API:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
|
||||
logger.info('Template used successfully, navigating to:', workflowUrl)
|
||||
|
||||
// Use window.location.href for more reliable navigation
|
||||
window.location.href = workflowUrl
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to use template:', response.statusText, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// TODO: Open create template modal or navigate to create page
|
||||
console.log('Create new template')
|
||||
}
|
||||
|
||||
// Handle starring/unstarring templates (client-side for interactivity)
|
||||
const handleStarToggle = async (templateId: string, isCurrentlyStarred: boolean) => {
|
||||
try {
|
||||
const method = isCurrentlyStarred ? 'DELETE' : 'POST'
|
||||
const response = await fetch(`/api/templates/${templateId}/star`, { method })
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state optimistically
|
||||
setTemplates((prev) =>
|
||||
prev.map((template) =>
|
||||
template.id === templateId
|
||||
? {
|
||||
...template,
|
||||
isStarred: !isCurrentlyStarred,
|
||||
stars: isCurrentlyStarred ? template.stars - 1 : template.stars + 1,
|
||||
}
|
||||
: template
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error toggling star:', error)
|
||||
}
|
||||
// Handle star change callback from template card
|
||||
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => {
|
||||
@@ -201,10 +144,8 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
icon={template.icon}
|
||||
iconColor={template.color}
|
||||
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
|
||||
onClick={() => handleTemplateClick(template.id)}
|
||||
onStar={handleStarToggle}
|
||||
onUse={handleUseTemplate}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { BookOpen, LibraryBig, ScrollText, Search, Shapes } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { TemplateCard, TemplateCardSkeleton } from '../../../templates/components/template-card'
|
||||
import { getKeyboardShortcutText } from '../../hooks/use-keyboard-shortcuts'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
templates?: TemplateData[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface TemplateData {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
usageCount: string
|
||||
stars: number
|
||||
icon: string
|
||||
iconColor: string
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
isStarred?: boolean
|
||||
}
|
||||
|
||||
interface BlockItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
bgColor: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface ToolItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
bgColor: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
href: string
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
interface DocItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
href: string
|
||||
type: 'main' | 'block' | 'tool'
|
||||
}
|
||||
|
||||
export function SearchModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
templates = [],
|
||||
loading = false,
|
||||
}: SearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Local state for templates to handle star changes
|
||||
const [localTemplates, setLocalTemplates] = useState<TemplateData[]>(templates)
|
||||
|
||||
// Update local templates when props change
|
||||
useEffect(() => {
|
||||
setLocalTemplates(templates)
|
||||
}, [templates])
|
||||
|
||||
// Refs for synchronized scrolling
|
||||
const blocksRow1Ref = useRef<HTMLDivElement>(null)
|
||||
const blocksRow2Ref = useRef<HTMLDivElement>(null)
|
||||
const toolsRow1Ref = useRef<HTMLDivElement>(null)
|
||||
const toolsRow2Ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Synchronized scrolling functions
|
||||
const handleBlocksRow1Scroll = useCallback(() => {
|
||||
if (blocksRow1Ref.current && blocksRow2Ref.current) {
|
||||
blocksRow2Ref.current.scrollLeft = blocksRow1Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleBlocksRow2Scroll = useCallback(() => {
|
||||
if (blocksRow1Ref.current && blocksRow2Ref.current) {
|
||||
blocksRow1Ref.current.scrollLeft = blocksRow2Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToolsRow1Scroll = useCallback(() => {
|
||||
if (toolsRow1Ref.current && toolsRow2Ref.current) {
|
||||
toolsRow2Ref.current.scrollLeft = toolsRow1Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToolsRow2Scroll = useCallback(() => {
|
||||
if (toolsRow1Ref.current && toolsRow2Ref.current) {
|
||||
toolsRow1Ref.current.scrollLeft = toolsRow2Ref.current.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get all available blocks
|
||||
const blocks = useMemo(() => {
|
||||
const allBlocks = getAllBlocks()
|
||||
return allBlocks
|
||||
.filter(
|
||||
(block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks'
|
||||
)
|
||||
.map(
|
||||
(block): BlockItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [])
|
||||
|
||||
// Get all available tools
|
||||
const tools = useMemo(() => {
|
||||
const allBlocks = getAllBlocks()
|
||||
return allBlocks
|
||||
.filter((block) => block.category === 'tools')
|
||||
.map(
|
||||
(block): ToolItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [])
|
||||
|
||||
// Define pages
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: getKeyboardShortcutText('L', true, true),
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
name: 'Knowledge',
|
||||
icon: LibraryBig,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
shortcut: getKeyboardShortcutText('K', true, true),
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Shapes,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'Docs',
|
||||
icon: BookOpen,
|
||||
href: 'https://docs.simstudio.ai/',
|
||||
},
|
||||
],
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
// Define docs
|
||||
const docs = useMemo((): DocItem[] => {
|
||||
const allBlocks = getAllBlocks()
|
||||
const docsItems: DocItem[] = []
|
||||
|
||||
// Add individual block/tool docs
|
||||
allBlocks.forEach((block) => {
|
||||
if (block.docsLink) {
|
||||
docsItems.push({
|
||||
id: `docs-${block.type}`,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
href: block.docsLink,
|
||||
type: block.category === 'blocks' ? 'block' : 'tool',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return docsItems.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [])
|
||||
|
||||
// Filter all items based on search query
|
||||
const filteredBlocks = useMemo(() => {
|
||||
if (!searchQuery.trim()) return blocks
|
||||
const query = searchQuery.toLowerCase()
|
||||
return blocks.filter((block) => block.name.toLowerCase().includes(query))
|
||||
}, [blocks, searchQuery])
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!searchQuery.trim()) return tools
|
||||
const query = searchQuery.toLowerCase()
|
||||
return tools.filter((tool) => tool.name.toLowerCase().includes(query))
|
||||
}, [tools, searchQuery])
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!searchQuery.trim()) return localTemplates.slice(0, 8)
|
||||
const query = searchQuery.toLowerCase()
|
||||
return localTemplates
|
||||
.filter(
|
||||
(template) =>
|
||||
template.title.toLowerCase().includes(query) ||
|
||||
template.description.toLowerCase().includes(query)
|
||||
)
|
||||
.slice(0, 8)
|
||||
}, [localTemplates, searchQuery])
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
if (!searchQuery.trim()) return pages
|
||||
const query = searchQuery.toLowerCase()
|
||||
return pages.filter((page) => page.name.toLowerCase().includes(query))
|
||||
}, [pages, searchQuery])
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
if (!searchQuery.trim()) return docs
|
||||
const query = searchQuery.toLowerCase()
|
||||
return docs.filter((doc) => doc.name.toLowerCase().includes(query))
|
||||
}, [docs, searchQuery])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// Clear search when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle block/tool click (same as toolbar interaction)
|
||||
const handleBlockClick = useCallback(
|
||||
(blockType: string) => {
|
||||
// Dispatch a custom event to be caught by the workflow component
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: {
|
||||
type: blockType,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
onOpenChange(false)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
// Handle page navigation
|
||||
const handlePageClick = useCallback(
|
||||
(href: string) => {
|
||||
// External links open in new tab
|
||||
if (href.startsWith('http')) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(href)
|
||||
}
|
||||
onOpenChange(false)
|
||||
},
|
||||
[router, onOpenChange]
|
||||
)
|
||||
|
||||
// Handle docs navigation
|
||||
const handleDocsClick = useCallback(
|
||||
(href: string) => {
|
||||
// External links open in new tab
|
||||
if (href.startsWith('http')) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(href)
|
||||
}
|
||||
onOpenChange(false)
|
||||
},
|
||||
[router, onOpenChange]
|
||||
)
|
||||
|
||||
// Handle page navigation shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle shortcuts when modal is open
|
||||
if (!open) return
|
||||
|
||||
// Don't trigger if user is typing in the search input
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) return
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey
|
||||
|
||||
if (isModifierPressed && e.shiftKey) {
|
||||
// Command+Shift+L - Navigate to Logs
|
||||
if (e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault()
|
||||
handlePageClick(`/workspace/${workspaceId}/logs`)
|
||||
}
|
||||
// Command+Shift+K - Navigate to Knowledge
|
||||
else if (e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
handlePageClick(`/workspace/${workspaceId}/knowledge`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handlePageClick, workspaceId])
|
||||
|
||||
// Handle template usage callback (closes modal after template is used)
|
||||
const handleTemplateUsed = useCallback(() => {
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
// Handle star change callback from template card
|
||||
const handleStarChange = useCallback(
|
||||
(templateId: string, isStarred: boolean, newStarCount: number) => {
|
||||
setLocalTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
|
||||
)
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Render skeleton cards for loading state
|
||||
const renderSkeletonCards = () => {
|
||||
return Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className='w-80 flex-shrink-0'>
|
||||
<TemplateCardSkeleton />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
className='bg-white/50 dark:bg-black/50'
|
||||
style={{ backdropFilter: 'blur(4.8px)' }}
|
||||
/>
|
||||
<DialogPrimitive.Content className='data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-xl border border-border bg-background p-0 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in'>
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
</VisuallyHidden.Root>
|
||||
{/* Header with search input */}
|
||||
<div className='flex items-center border-b px-6 py-2'>
|
||||
<Search className='h-5 w-5 font-sans text-muted-foreground text-xl' />
|
||||
<Input
|
||||
placeholder='Search anything'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='!font-[300] !text-lg placeholder:!text-lg border-0 bg-transparent font-sans text-muted-foreground leading-10 tracking-normal placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className='scrollbar-none flex-1 overflow-y-auto'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className='space-y-6 pt-6 pb-6'>
|
||||
{/* Blocks Section */}
|
||||
{filteredBlocks.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Blocks
|
||||
</h3>
|
||||
<div className='space-y-2'>
|
||||
{/* First row */}
|
||||
<div
|
||||
ref={blocksRow1Ref}
|
||||
onScroll={handleBlocksRow1Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredBlocks
|
||||
.slice(0, Math.ceil(filteredBlocks.length / 2))
|
||||
.map((block) => (
|
||||
<button
|
||||
key={block.id}
|
||||
onClick={() => handleBlockClick(block.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: block.bgColor }}
|
||||
>
|
||||
<block.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{block.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Second row */}
|
||||
{filteredBlocks.length > Math.ceil(filteredBlocks.length / 2) && (
|
||||
<div
|
||||
ref={blocksRow2Ref}
|
||||
onScroll={handleBlocksRow2Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredBlocks.slice(Math.ceil(filteredBlocks.length / 2)).map((block) => (
|
||||
<button
|
||||
key={block.id}
|
||||
onClick={() => handleBlockClick(block.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: block.bgColor }}
|
||||
>
|
||||
<block.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{block.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Section */}
|
||||
{filteredTools.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Tools
|
||||
</h3>
|
||||
<div className='space-y-2'>
|
||||
{/* First row */}
|
||||
<div
|
||||
ref={toolsRow1Ref}
|
||||
onScroll={handleToolsRow1Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredTools.slice(0, Math.ceil(filteredTools.length / 2)).map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => handleBlockClick(tool.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: tool.bgColor }}
|
||||
>
|
||||
<tool.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{tool.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Second row */}
|
||||
{filteredTools.length > Math.ceil(filteredTools.length / 2) && (
|
||||
<div
|
||||
ref={toolsRow2Ref}
|
||||
onScroll={handleToolsRow2Scroll}
|
||||
className='scrollbar-none flex gap-2 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{filteredTools.slice(Math.ceil(filteredTools.length / 2)).map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => handleBlockClick(tool.type)}
|
||||
className='flex h-9 w-[153.5px] flex-shrink-0 items-center gap-3 whitespace-nowrap rounded-xl bg-secondary p-2 transition-colors hover:bg-secondary/80'
|
||||
>
|
||||
<div
|
||||
className='flex h-5 w-5 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: tool.bgColor }}
|
||||
>
|
||||
<tool.icon className='h-4 w-4 text-white' />
|
||||
</div>
|
||||
<span className='font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{tool.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Section */}
|
||||
{(loading || filteredTemplates.length > 0) && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Templates
|
||||
</h3>
|
||||
<div
|
||||
className='scrollbar-none flex gap-4 overflow-x-auto pr-6 pb-1 pl-6'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: filteredTemplates.map((template) => (
|
||||
<div key={template.id} className='w-80 flex-shrink-0'>
|
||||
<TemplateCard
|
||||
id={template.id}
|
||||
title={template.title}
|
||||
description={template.description}
|
||||
author={template.author}
|
||||
usageCount={template.usageCount}
|
||||
stars={template.stars}
|
||||
icon={template.icon}
|
||||
iconColor={template.iconColor}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
onTemplateUsed={handleTemplateUsed}
|
||||
onStarChange={handleStarChange}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pages Section */}
|
||||
{filteredPages.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Pages
|
||||
</h3>
|
||||
<div className='space-y-1 px-6'>
|
||||
{filteredPages.map((page) => (
|
||||
<button
|
||||
key={page.id}
|
||||
onClick={() => handlePageClick(page.href)}
|
||||
className='flex h-10 w-full items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-accent/60 focus:bg-accent/60 focus:outline-none'
|
||||
>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<page.icon className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
<span className='flex-1 text-left font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{page.name}
|
||||
</span>
|
||||
{page.shortcut && (
|
||||
<kbd className='flex h-6 w-10 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]'>
|
||||
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
|
||||
<span className='text-lg'>⌘</span>
|
||||
<span className='pb-[4px] text-lg'>⇧</span>
|
||||
<span className='text-xs'>{page.shortcut.slice(-1)}</span>
|
||||
</span>
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Docs Section */}
|
||||
{filteredDocs.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-3 ml-6 font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
Docs
|
||||
</h3>
|
||||
<div className='space-y-1 px-6'>
|
||||
{filteredDocs.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => handleDocsClick(doc.href)}
|
||||
className='flex h-10 w-full items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-accent/60 focus:bg-accent/60 focus:outline-none'
|
||||
>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<doc.icon className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
<span className='flex-1 text-left font-normal font-sans text-muted-foreground text-sm leading-none tracking-normal'>
|
||||
{doc.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{searchQuery &&
|
||||
!loading &&
|
||||
filteredBlocks.length === 0 &&
|
||||
filteredTools.length === 0 &&
|
||||
filteredTemplates.length === 0 &&
|
||||
filteredPages.length === 0 &&
|
||||
filteredDocs.length === 0 && (
|
||||
<div className='ml-6 py-12 text-center'>
|
||||
<p className='text-muted-foreground'>No results found for "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -24,8 +24,8 @@ interface FolderContextMenuProps {
|
||||
folderId: string
|
||||
folderName: string
|
||||
onCreateWorkflow: (folderId: string) => void
|
||||
onRename?: (folderId: string, newName: string) => void
|
||||
onDelete?: (folderId: string) => void
|
||||
onStartEdit?: () => void
|
||||
level: number
|
||||
}
|
||||
|
||||
@@ -33,23 +33,20 @@ export function FolderContextMenu({
|
||||
folderId,
|
||||
folderName,
|
||||
onCreateWorkflow,
|
||||
onRename,
|
||||
onDelete,
|
||||
onStartEdit,
|
||||
level,
|
||||
}: FolderContextMenuProps) {
|
||||
const [showSubfolderDialog, setShowSubfolderDialog] = useState(false)
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
const [subfolderName, setSubfolderName] = useState('')
|
||||
const [renameName, setRenameName] = useState(folderName)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Get user permissions for the workspace
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { createFolder, updateFolder, deleteFolder } = useFolderStore()
|
||||
const { createFolder, deleteFolder } = useFolderStore()
|
||||
|
||||
const handleCreateWorkflow = () => {
|
||||
onCreateWorkflow(folderId)
|
||||
@@ -60,8 +57,9 @@ export function FolderContextMenu({
|
||||
}
|
||||
|
||||
const handleRename = () => {
|
||||
setRenameName(folderName)
|
||||
setShowRenameDialog(true)
|
||||
if (onStartEdit) {
|
||||
onStartEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -98,31 +96,9 @@ export function FolderContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!renameName.trim()) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
if (onRename) {
|
||||
onRename(folderId, renameName.trim())
|
||||
} else {
|
||||
// Default rename behavior
|
||||
await updateFolder(folderId, { name: renameName.trim() })
|
||||
}
|
||||
setShowRenameDialog(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to rename folder:', error)
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setSubfolderName('')
|
||||
setShowSubfolderDialog(false)
|
||||
setRenameName(folderName)
|
||||
setShowRenameDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -230,37 +206,6 @@ export function FolderContextMenu({
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className='sm:max-w-[425px]' onClick={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Folder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleRenameSubmit} className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='rename-folder'>Folder Name</Label>
|
||||
<Input
|
||||
id='rename-folder'
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder='Enter folder name...'
|
||||
maxLength={50}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={!renameName.trim() || isRenaming}>
|
||||
{isRenaming ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
|
||||
@@ -48,14 +49,33 @@ export function FolderItem({
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(folder.name)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const dragStartedRef = useRef(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const isExpanded = expandedFolders.has(folder.id)
|
||||
const updateTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const pendingStateRef = useRef<boolean | null>(null)
|
||||
|
||||
// Update editValue when folder name changes
|
||||
useEffect(() => {
|
||||
setEditValue(folder.name)
|
||||
}, [folder.name])
|
||||
|
||||
// Focus input when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const handleToggleExpanded = useCallback(() => {
|
||||
if (isEditing) return // Don't toggle when editing
|
||||
|
||||
const newExpandedState = !isExpanded
|
||||
toggleExpanded(folder.id)
|
||||
pendingStateRef.current = newExpandedState
|
||||
@@ -73,9 +93,11 @@ export function FolderItem({
|
||||
})
|
||||
}
|
||||
}, 300)
|
||||
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI])
|
||||
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI, isEditing])
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
if (isEditing) return
|
||||
|
||||
dragStartedRef.current = true
|
||||
setIsDragging(true)
|
||||
|
||||
@@ -101,7 +123,7 @@ export function FolderItem({
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (dragStartedRef.current) {
|
||||
if (dragStartedRef.current || isEditing) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -116,15 +138,57 @@ export function FolderItem({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRename = async (folderId: string, newName: string) => {
|
||||
const handleStartEdit = () => {
|
||||
setIsEditing(true)
|
||||
setEditValue(folder.name)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editValue.trim() || editValue.trim() === folder.name) {
|
||||
setIsEditing(false)
|
||||
setEditValue(folder.name)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
await updateFolderAPI(folderId, { name: newName })
|
||||
await updateFolderAPI(folder.id, { name: editValue.trim() })
|
||||
logger.info(`Successfully renamed folder from "${folder.name}" to "${editValue.trim()}"`)
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename folder:', { error })
|
||||
logger.error('Failed to rename folder:', {
|
||||
error,
|
||||
folderId: folder.id,
|
||||
oldName: folder.name,
|
||||
newName: editValue.trim(),
|
||||
})
|
||||
// Reset to original name on error
|
||||
setEditValue(folder.name)
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (folderId: string) => {
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false)
|
||||
setEditValue(folder.name)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSaveEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
handleSaveEdit()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
@@ -154,7 +218,7 @@ export function FolderItem({
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={handleClick}
|
||||
draggable={true}
|
||||
draggable={!isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
@@ -222,7 +286,7 @@ export function FolderItem({
|
||||
maxWidth: isFirstItem ? `${164 - level * 20}px` : `${206 - level * 20}px`,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
draggable={true}
|
||||
draggable={!isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
@@ -234,18 +298,34 @@ export function FolderItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className='flex-1 select-none truncate text-muted-foreground'>{folder.name}</span>
|
||||
|
||||
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
|
||||
<FolderContextMenu
|
||||
folderId={folder.id}
|
||||
folderName={folder.name}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
level={level}
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className='h-6 flex-1 border-0 bg-transparent p-0 text-muted-foreground text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
maxLength={50}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent folder toggle when clicking input
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className='flex-1 select-none truncate text-muted-foreground'>{folder.name}</span>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
|
||||
<FolderContextMenu
|
||||
folderId={folder.id}
|
||||
folderName={folder.name}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
onDelete={handleDelete}
|
||||
onStartEdit={handleStartEdit}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { WorkflowContextMenu } from '../../workflow-context-menu/workflow-context-menu'
|
||||
|
||||
@@ -32,14 +34,83 @@ export function WorkflowItem({
|
||||
isFirstItem = false,
|
||||
}: WorkflowItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(workflow.name)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const dragStartedRef = useRef(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { selectedWorkflows, selectOnly, toggleWorkflowSelection } = useFolderStore()
|
||||
const isSelected = useIsWorkflowSelected(workflow.id)
|
||||
const { updateWorkflow } = useWorkflowRegistry()
|
||||
|
||||
// Update editValue when workflow name changes
|
||||
useEffect(() => {
|
||||
setEditValue(workflow.name)
|
||||
}, [workflow.name])
|
||||
|
||||
// Focus input when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (isMarketplace) return
|
||||
setIsEditing(true)
|
||||
setEditValue(workflow.name)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editValue.trim() || editValue.trim() === workflow.name) {
|
||||
setIsEditing(false)
|
||||
setEditValue(workflow.name)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
await updateWorkflow(workflow.id, { name: editValue.trim() })
|
||||
logger.info(`Successfully renamed workflow from "${workflow.name}" to "${editValue.trim()}"`)
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename workflow:', {
|
||||
error,
|
||||
workflowId: workflow.id,
|
||||
oldName: workflow.name,
|
||||
newName: editValue.trim(),
|
||||
})
|
||||
// Reset to original name on error
|
||||
setEditValue(workflow.name)
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false)
|
||||
setEditValue(workflow.name)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSaveEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
handleSaveEdit()
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (dragStartedRef.current) {
|
||||
if (dragStartedRef.current || isEditing) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -55,7 +126,7 @@ export function WorkflowItem({
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
if (isMarketplace) return
|
||||
if (isMarketplace || isEditing) return
|
||||
|
||||
dragStartedRef.current = true
|
||||
setIsDragging(true)
|
||||
@@ -95,7 +166,7 @@ export function WorkflowItem({
|
||||
: '',
|
||||
isDragging ? 'opacity-50' : ''
|
||||
)}
|
||||
draggable={!isMarketplace}
|
||||
draggable={!isMarketplace && !isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={handleClick}
|
||||
@@ -134,7 +205,7 @@ export function WorkflowItem({
|
||||
? `${164 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`
|
||||
: `${206 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`,
|
||||
}}
|
||||
draggable={!isMarketplace}
|
||||
draggable={!isMarketplace && !isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-workflow-id={workflow.id}
|
||||
@@ -148,15 +219,29 @@ export function WorkflowItem({
|
||||
className='mr-2 h-[14px] w-[14px] flex-shrink-0 rounded'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<span className='flex-1 select-none truncate'>
|
||||
{workflow.name}
|
||||
{isMarketplace && ' (Preview)'}
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className='h-6 flex-1 border-0 bg-transparent p-0 text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => e.preventDefault()} // Prevent navigation when clicking input
|
||||
/>
|
||||
) : (
|
||||
<span className='flex-1 select-none truncate'>
|
||||
{workflow.name}
|
||||
{isMarketplace && ' (Preview)'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{!isMarketplace && (
|
||||
{!isMarketplace && !isEditing && (
|
||||
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
|
||||
<WorkflowContextMenu workflow={workflow} level={level} />
|
||||
<WorkflowContextMenu onStartEdit={handleStartEdit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,139 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { MoreHorizontal, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
const logger = createLogger('WorkflowContextMenu')
|
||||
|
||||
interface WorkflowContextMenuProps {
|
||||
workflow: WorkflowMetadata
|
||||
onRename?: (workflowId: string, newName: string) => void
|
||||
level: number
|
||||
onStartEdit?: () => void
|
||||
}
|
||||
|
||||
export function WorkflowContextMenu({ workflow, onRename, level }: WorkflowContextMenuProps) {
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
const [renameName, setRenameName] = useState(workflow.name)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
|
||||
export function WorkflowContextMenu({ onStartEdit }: WorkflowContextMenuProps) {
|
||||
// Get user permissions for the workspace
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { updateWorkflow } = useWorkflowRegistry()
|
||||
|
||||
const handleRename = () => {
|
||||
setRenameName(workflow.name)
|
||||
setShowRenameDialog(true)
|
||||
}
|
||||
|
||||
const handleRenameSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!renameName.trim()) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
if (onRename) {
|
||||
onRename(workflow.id, renameName.trim())
|
||||
} else {
|
||||
// Default rename behavior using updateWorkflow
|
||||
await updateWorkflow(workflow.id, { name: renameName.trim() })
|
||||
logger.info(
|
||||
`Successfully renamed workflow from "${workflow.name}" to "${renameName.trim()}"`
|
||||
)
|
||||
}
|
||||
setShowRenameDialog(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename workflow:', {
|
||||
error,
|
||||
workflowId: workflow.id,
|
||||
oldName: workflow.name,
|
||||
newName: renameName.trim(),
|
||||
})
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
if (onStartEdit) {
|
||||
onStartEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setRenameName(workflow.name)
|
||||
setShowRenameDialog(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className='h-3 w-3' />
|
||||
<span className='sr-only'>Workflow options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
>
|
||||
{userPermissions.canEdit && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleRename}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Pencil className='mr-2 h-4 w-4' />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className='sm:max-w-[425px]' onClick={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Workflow</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleRenameSubmit} className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='rename-workflow'>Workflow Name</Label>
|
||||
<Input
|
||||
id='rename-workflow'
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
placeholder='Enter workflow name...'
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={!renameName.trim() || isRenaming}>
|
||||
{isRenaming ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<MoreHorizontal className='h-3 w-3' />
|
||||
<span className='sr-only'>Workflow options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
|
||||
>
|
||||
{userPermissions.canEdit && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleRename}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Pencil className='mr-2 h-4 w-4' />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
|
||||
|
||||
const logger = createLogger('WorkspaceHeader')
|
||||
|
||||
@@ -29,6 +30,7 @@ interface WorkspaceHeaderProps {
|
||||
onCreateWorkflow: () => void
|
||||
isWorkspaceSelectorVisible: boolean
|
||||
onToggleWorkspaceSelector: () => void
|
||||
onToggleSidebar: () => void
|
||||
activeWorkspace: Workspace | null
|
||||
isWorkspacesLoading: boolean
|
||||
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
|
||||
@@ -42,12 +44,14 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
onCreateWorkflow,
|
||||
isWorkspaceSelectorVisible,
|
||||
onToggleWorkspaceSelector,
|
||||
onToggleSidebar,
|
||||
activeWorkspace,
|
||||
isWorkspacesLoading,
|
||||
updateWorkspaceName,
|
||||
}) => {
|
||||
// External hooks
|
||||
const { data: sessionData } = useSession()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const [isClientLoading, setIsClientLoading] = useState(true)
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
@@ -83,17 +87,15 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
}
|
||||
}, [isEditingName])
|
||||
|
||||
// Handle toggle sidebar
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
// This will be implemented when needed - placeholder for now
|
||||
logger.info('Toggle sidebar clicked')
|
||||
}, [])
|
||||
|
||||
// Handle workspace name click
|
||||
const handleWorkspaceNameClick = useCallback(() => {
|
||||
// Only allow admins to rename workspace
|
||||
if (!userPermissions.canAdmin) {
|
||||
return
|
||||
}
|
||||
setEditingName(displayName)
|
||||
setIsEditingName(true)
|
||||
}, [displayName])
|
||||
}, [displayName, userPermissions.canAdmin])
|
||||
|
||||
// Handle workspace name editing actions
|
||||
const handleEditingAction = useCallback(
|
||||
@@ -211,16 +213,29 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleWorkspaceNameClick}
|
||||
className='cursor-pointer truncate font-medium text-sm leading-none transition-all hover:brightness-75 dark:hover:brightness-125'
|
||||
style={{
|
||||
minHeight: '1rem',
|
||||
lineHeight: '1rem',
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={handleWorkspaceNameClick}
|
||||
className={`truncate font-medium text-sm leading-none transition-all ${
|
||||
userPermissions.canAdmin
|
||||
? 'cursor-pointer hover:brightness-75 dark:hover:brightness-125'
|
||||
: 'cursor-default'
|
||||
}`}
|
||||
style={{
|
||||
minHeight: '1rem',
|
||||
lineHeight: '1rem',
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!userPermissions.canAdmin && (
|
||||
<TooltipContent side='bottom'>
|
||||
Admin permissions required to rename workspace
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -248,7 +263,7 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={handleToggleSidebar}
|
||||
onClick={onToggleSidebar}
|
||||
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
|
||||
>
|
||||
<PanelLeft className='h-4 w-4' />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { HelpCircle, Loader2, X } from 'lucide-react'
|
||||
import { HelpCircle, Loader2, Trash2, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -41,11 +41,14 @@ interface UserPermissions {
|
||||
permissionType: PermissionType
|
||||
isCurrentUser?: boolean
|
||||
isPendingInvitation?: boolean
|
||||
invitationId?: string
|
||||
}
|
||||
|
||||
interface PermissionsTableProps {
|
||||
userPermissions: UserPermissions[]
|
||||
onPermissionChange: (userId: string, permissionType: PermissionType) => void
|
||||
onRemoveMember?: (userId: string, email: string) => void
|
||||
onRemoveInvitation?: (invitationId: string, email: string) => void
|
||||
disabled?: boolean
|
||||
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
|
||||
isSaving?: boolean
|
||||
@@ -189,198 +192,244 @@ const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified'): string =>
|
||||
}
|
||||
}
|
||||
|
||||
const PermissionsTable = React.memo<PermissionsTableProps>(
|
||||
({
|
||||
userPermissions,
|
||||
onPermissionChange,
|
||||
disabled,
|
||||
existingUserPermissionChanges,
|
||||
isSaving,
|
||||
workspacePermissions,
|
||||
permissionsLoading,
|
||||
pendingInvitations,
|
||||
isPendingInvitationsLoading,
|
||||
}) => {
|
||||
// Always call hooks first - before any conditional returns
|
||||
const { data: session } = useSession()
|
||||
const userPerms = useUserPermissionsContext()
|
||||
const PermissionsTable = ({
|
||||
userPermissions,
|
||||
onPermissionChange,
|
||||
onRemoveMember,
|
||||
onRemoveInvitation,
|
||||
disabled,
|
||||
existingUserPermissionChanges,
|
||||
isSaving,
|
||||
workspacePermissions,
|
||||
permissionsLoading,
|
||||
pendingInvitations,
|
||||
isPendingInvitationsLoading,
|
||||
}: PermissionsTableProps) => {
|
||||
const { data: session } = useSession()
|
||||
const userPerms = useUserPermissionsContext()
|
||||
|
||||
// All useMemo hooks must be called before any conditional returns
|
||||
const existingUsers: UserPermissions[] = useMemo(
|
||||
() =>
|
||||
workspacePermissions?.users?.map((user) => {
|
||||
const changes = existingUserPermissionChanges[user.userId] || {}
|
||||
const permissionType = user.permissionType || 'read'
|
||||
const existingUsers: UserPermissions[] = useMemo(
|
||||
() =>
|
||||
workspacePermissions?.users?.map((user) => {
|
||||
const changes = existingUserPermissionChanges[user.userId] || {}
|
||||
const permissionType = user.permissionType || 'read'
|
||||
|
||||
return {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
permissionType:
|
||||
changes.permissionType !== undefined ? changes.permissionType : permissionType,
|
||||
isCurrentUser: user.email === session?.user?.email,
|
||||
return {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
permissionType:
|
||||
changes.permissionType !== undefined ? changes.permissionType : permissionType,
|
||||
isCurrentUser: user.email === session?.user?.email,
|
||||
}
|
||||
}) || [],
|
||||
[workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email]
|
||||
)
|
||||
|
||||
const currentUser: UserPermissions | null = useMemo(
|
||||
() =>
|
||||
session?.user?.email
|
||||
? existingUsers.find((user) => user.isCurrentUser) || {
|
||||
email: session.user.email,
|
||||
permissionType: 'admin',
|
||||
isCurrentUser: true,
|
||||
}
|
||||
}) || [],
|
||||
[workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email]
|
||||
: null,
|
||||
[session?.user?.email, existingUsers]
|
||||
)
|
||||
|
||||
const filteredExistingUsers = useMemo(
|
||||
() => existingUsers.filter((user) => !user.isCurrentUser),
|
||||
[existingUsers]
|
||||
)
|
||||
|
||||
const allUsers: UserPermissions[] = useMemo(() => {
|
||||
// Get emails of existing users to filter out duplicate invitations
|
||||
const existingUserEmails = new Set([
|
||||
...(currentUser ? [currentUser.email] : []),
|
||||
...filteredExistingUsers.map((user) => user.email),
|
||||
])
|
||||
|
||||
// Filter out pending invitations for users who are already members
|
||||
const filteredPendingInvitations = pendingInvitations.filter(
|
||||
(invitation) => !existingUserEmails.has(invitation.email)
|
||||
)
|
||||
|
||||
const currentUser: UserPermissions | null = useMemo(
|
||||
() =>
|
||||
session?.user?.email
|
||||
? existingUsers.find((user) => user.isCurrentUser) || {
|
||||
email: session.user.email,
|
||||
permissionType: 'admin',
|
||||
isCurrentUser: true,
|
||||
}
|
||||
: null,
|
||||
[session?.user?.email, existingUsers]
|
||||
)
|
||||
return [
|
||||
...(currentUser ? [currentUser] : []),
|
||||
...filteredExistingUsers,
|
||||
...userPermissions,
|
||||
...filteredPendingInvitations,
|
||||
]
|
||||
}, [currentUser, filteredExistingUsers, userPermissions, pendingInvitations])
|
||||
|
||||
const filteredExistingUsers = useMemo(
|
||||
() => existingUsers.filter((user) => !user.isCurrentUser),
|
||||
[existingUsers]
|
||||
)
|
||||
if (permissionsLoading || userPerms.isLoading || isPendingInvitationsLoading) {
|
||||
return <PermissionsTableSkeleton />
|
||||
}
|
||||
|
||||
const allUsers: UserPermissions[] = useMemo(
|
||||
() => [
|
||||
...(currentUser ? [currentUser] : []),
|
||||
...filteredExistingUsers,
|
||||
...userPermissions,
|
||||
...pendingInvitations,
|
||||
],
|
||||
[currentUser, filteredExistingUsers, userPermissions, pendingInvitations]
|
||||
)
|
||||
|
||||
// Now we can safely have conditional returns after all hooks are called
|
||||
if (permissionsLoading || userPerms.isLoading || isPendingInvitationsLoading) {
|
||||
return <PermissionsTableSkeleton />
|
||||
}
|
||||
|
||||
if (
|
||||
userPermissions.length === 0 &&
|
||||
!session?.user?.email &&
|
||||
!workspacePermissions?.users?.length
|
||||
)
|
||||
return null
|
||||
|
||||
if (isSaving) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<h3 className='font-medium text-sm'>Member Permissions</h3>
|
||||
<div className='rounded-md border bg-card'>
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='flex items-center space-x-2 text-muted-foreground'>
|
||||
<Loader2 className='h-5 w-5 animate-spin' />
|
||||
<span className='font-medium text-sm'>Saving permission changes...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-h-[2rem] items-start'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please wait while we update the permissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentUserIsAdmin = userPerms.canAdmin
|
||||
if (userPermissions.length === 0 && !session?.user?.email && !workspacePermissions?.users?.length)
|
||||
return null
|
||||
|
||||
if (isSaving) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h3 className='font-medium text-sm'>Member Permissions</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-5 w-5 p-0 text-muted-foreground hover:text-foreground'
|
||||
type='button'
|
||||
>
|
||||
<HelpCircle className='h-4 w-4' />
|
||||
<span className='sr-only'>Member permissions help</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[320px]'>
|
||||
<div className='space-y-2'>
|
||||
{userPerms.isLoading || permissionsLoading ? (
|
||||
<p className='text-sm'>Loading permissions...</p>
|
||||
) : !currentUserIsAdmin ? (
|
||||
<p className='text-sm'>
|
||||
Only administrators can invite new members and modify permissions.
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm'>Admin grants all permissions automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='rounded-md border'>
|
||||
{allUsers.length > 0 && (
|
||||
<div className='divide-y'>
|
||||
{allUsers.map((user) => {
|
||||
const isCurrentUser = user.isCurrentUser === true
|
||||
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
|
||||
const isPendingInvitation = user.isPendingInvitation === true
|
||||
const userIdentifier = user.userId || user.email
|
||||
const hasChanges = existingUserPermissionChanges[userIdentifier] !== undefined
|
||||
|
||||
const uniqueKey = user.userId
|
||||
? `existing-${user.userId}`
|
||||
: isPendingInvitation
|
||||
? `pending-${user.email}`
|
||||
: `new-${user.email}`
|
||||
|
||||
return (
|
||||
<div key={uniqueKey} className='flex items-center justify-between p-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-card-foreground text-sm'>
|
||||
{user.email}
|
||||
</span>
|
||||
{isPendingInvitation && (
|
||||
<span className={getStatusBadgeStyles('sent')}>Sent</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-1 flex items-center gap-2'>
|
||||
{isExistingUser && !isCurrentUser && (
|
||||
<span className={getStatusBadgeStyles('member')}>Member</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<span className={getStatusBadgeStyles('modified')}>Modified</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<PermissionSelector
|
||||
value={user.permissionType}
|
||||
onChange={(newPermission) =>
|
||||
onPermissionChange(userIdentifier, newPermission)
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
!currentUserIsAdmin ||
|
||||
isPendingInvitation ||
|
||||
(isCurrentUser && user.permissionType === 'admin')
|
||||
}
|
||||
className='w-auto'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<h3 className='font-medium text-sm'>Member Permissions</h3>
|
||||
<div className='rounded-md border bg-card'>
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='flex items-center space-x-2 text-muted-foreground'>
|
||||
<Loader2 className='h-5 w-5 animate-spin' />
|
||||
<span className='font-medium text-sm'>Saving permission changes...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-h-[2rem] items-start'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please wait while we update the permissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PermissionsTable.displayName = 'PermissionsTable'
|
||||
const currentUserIsAdmin = userPerms.canAdmin
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h3 className='font-medium text-sm'>Member Permissions</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-5 w-5 p-0 text-muted-foreground hover:text-foreground'
|
||||
type='button'
|
||||
>
|
||||
<HelpCircle className='h-4 w-4' />
|
||||
<span className='sr-only'>Member permissions help</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[320px]'>
|
||||
<div className='space-y-2'>
|
||||
{userPerms.isLoading || permissionsLoading ? (
|
||||
<p className='text-sm'>Loading permissions...</p>
|
||||
) : !currentUserIsAdmin ? (
|
||||
<p className='text-sm'>
|
||||
Only administrators can invite new members and modify permissions.
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm'>Admin grants all permissions automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='rounded-md border'>
|
||||
{allUsers.length > 0 && (
|
||||
<div className='divide-y'>
|
||||
{allUsers.map((user) => {
|
||||
const isCurrentUser = user.isCurrentUser === true
|
||||
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
|
||||
const isPendingInvitation = user.isPendingInvitation === true
|
||||
const userIdentifier = user.userId || user.email
|
||||
// Check if current permission is different from original permission
|
||||
const originalPermission = workspacePermissions?.users?.find(
|
||||
(eu) => eu.userId === user.userId
|
||||
)?.permissionType
|
||||
const currentPermission =
|
||||
existingUserPermissionChanges[userIdentifier]?.permissionType ?? user.permissionType
|
||||
const hasChanges = originalPermission && currentPermission !== originalPermission
|
||||
// Check if user is in workspace permissions directly
|
||||
const isWorkspaceMember = workspacePermissions?.users?.some(
|
||||
(eu) => eu.email === user.email && eu.userId
|
||||
)
|
||||
const canShowRemoveButton =
|
||||
isWorkspaceMember &&
|
||||
!isCurrentUser &&
|
||||
!isPendingInvitation &&
|
||||
currentUserIsAdmin &&
|
||||
user.userId
|
||||
|
||||
const uniqueKey = user.userId
|
||||
? `existing-${user.userId}`
|
||||
: isPendingInvitation
|
||||
? `pending-${user.email}`
|
||||
: `new-${user.email}`
|
||||
|
||||
return (
|
||||
<div key={uniqueKey} className='flex items-center justify-between p-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
|
||||
{isPendingInvitation && (
|
||||
<span className={getStatusBadgeStyles('sent')}>Sent</span>
|
||||
)}
|
||||
{/* Show remove button for existing workspace members (not current user, not pending) */}
|
||||
{canShowRemoveButton && onRemoveMember && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => onRemoveMember(user.userId!, user.email)}
|
||||
disabled={disabled || isSaving}
|
||||
className='h-6 w-6 rounded-md p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
|
||||
title={`Remove ${user.email} from workspace`}
|
||||
>
|
||||
<Trash2 className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Remove {user.email}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Show remove button for pending invitations */}
|
||||
{isPendingInvitation &&
|
||||
currentUserIsAdmin &&
|
||||
user.invitationId &&
|
||||
onRemoveInvitation && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => onRemoveInvitation(user.invitationId!, user.email)}
|
||||
disabled={disabled || isSaving}
|
||||
className='h-6 w-6 rounded-md p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
|
||||
title={`Cancel invitation for ${user.email}`}
|
||||
>
|
||||
<Trash2 className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Cancel invitation for {user.email}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-1 flex items-center gap-2'>
|
||||
{isExistingUser && !isCurrentUser && (
|
||||
<span className={getStatusBadgeStyles('member')}>Member</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<span className={getStatusBadgeStyles('modified')}>Modified</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<PermissionSelector
|
||||
value={user.permissionType}
|
||||
onChange={(newPermission) =>
|
||||
onPermissionChange(userIdentifier, newPermission)
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
!currentUserIsAdmin ||
|
||||
isPendingInvitation ||
|
||||
(isCurrentUser && user.permissionType === 'admin')
|
||||
}
|
||||
className='w-auto'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
@@ -397,6 +446,15 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
const [showSent, setShowSent] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; email: string } | null>(
|
||||
null
|
||||
)
|
||||
const [isRemovingMember, setIsRemovingMember] = useState(false)
|
||||
const [invitationToRemove, setInvitationToRemove] = useState<{
|
||||
invitationId: string
|
||||
email: string
|
||||
} | null>(null)
|
||||
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -429,6 +487,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
email: inv.email,
|
||||
permissionType: inv.permissions,
|
||||
isPendingInvitation: true,
|
||||
invitationId: inv.id,
|
||||
})) || []
|
||||
|
||||
setPendingInvitations(workspacePendingInvitations)
|
||||
@@ -444,11 +503,15 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
if (open && workspaceId) {
|
||||
fetchPendingInvitations()
|
||||
}
|
||||
}, [open, fetchPendingInvitations])
|
||||
}, [open, workspaceId, fetchPendingInvitations])
|
||||
|
||||
// Clear errors when modal opens
|
||||
useEffect(() => {
|
||||
setErrorMessage(null)
|
||||
}, [pendingInvitations, workspacePermissions])
|
||||
if (open) {
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const addEmail = useCallback(
|
||||
(email: string) => {
|
||||
@@ -523,10 +586,19 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier)
|
||||
|
||||
if (existingUser) {
|
||||
setExistingUserPermissionChanges((prev) => ({
|
||||
...prev,
|
||||
[identifier]: { permissionType },
|
||||
}))
|
||||
setExistingUserPermissionChanges((prev) => {
|
||||
const newChanges = { ...prev }
|
||||
|
||||
// If the new permission matches the original, remove the change entry
|
||||
if (existingUser.permissionType === permissionType) {
|
||||
delete newChanges[identifier]
|
||||
} else {
|
||||
// Otherwise, track the change
|
||||
newChanges[identifier] = { permissionType }
|
||||
}
|
||||
|
||||
return newChanges
|
||||
})
|
||||
} else {
|
||||
setUserPermissions((prev) =>
|
||||
prev.map((user) => (user.email === identifier ? { ...user, permissionType } : user))
|
||||
@@ -599,6 +671,126 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
}, [userPerms.canAdmin, hasPendingChanges])
|
||||
|
||||
const handleRemoveMemberClick = useCallback((userId: string, email: string) => {
|
||||
setMemberToRemove({ userId, email })
|
||||
}, [])
|
||||
|
||||
const handleRemoveMemberConfirm = useCallback(async () => {
|
||||
if (!memberToRemove || !workspaceId || !userPerms.canAdmin) return
|
||||
|
||||
setIsRemovingMember(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
// Verify the user exists in workspace permissions
|
||||
const userRecord = workspacePermissions?.users?.find(
|
||||
(user) => user.userId === memberToRemove.userId
|
||||
)
|
||||
|
||||
if (!userRecord) {
|
||||
throw new Error('User is not a member of this workspace')
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/workspaces/members/${memberToRemove.userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to remove member')
|
||||
}
|
||||
|
||||
// Update the workspace permissions to remove the user
|
||||
if (workspacePermissions) {
|
||||
const updatedUsers = workspacePermissions.users.filter(
|
||||
(user) => user.userId !== memberToRemove.userId
|
||||
)
|
||||
updatePermissions({
|
||||
users: updatedUsers,
|
||||
total: workspacePermissions.total - 1,
|
||||
})
|
||||
}
|
||||
|
||||
// Clear any pending changes for this user
|
||||
setExistingUserPermissionChanges((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[memberToRemove.userId]
|
||||
return updated
|
||||
})
|
||||
|
||||
setSuccessMessage(`${memberToRemove.email} has been removed from the workspace`)
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
} catch (error) {
|
||||
logger.error('Error removing member:', error)
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Failed to remove member. Please try again.'
|
||||
setErrorMessage(errorMsg)
|
||||
} finally {
|
||||
setIsRemovingMember(false)
|
||||
setMemberToRemove(null)
|
||||
}
|
||||
}, [memberToRemove, workspaceId, userPerms.canAdmin, workspacePermissions, updatePermissions])
|
||||
|
||||
const handleRemoveMemberCancel = useCallback(() => {
|
||||
setMemberToRemove(null)
|
||||
}, [])
|
||||
|
||||
const handleRemoveInvitationClick = useCallback((invitationId: string, email: string) => {
|
||||
setInvitationToRemove({ invitationId, email })
|
||||
}, [])
|
||||
|
||||
const handleRemoveInvitationConfirm = useCallback(async () => {
|
||||
if (!invitationToRemove || !workspaceId || !userPerms.canAdmin) return
|
||||
|
||||
setIsRemovingInvitation(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/invitations/${invitationToRemove.invitationId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to cancel invitation')
|
||||
}
|
||||
|
||||
// Remove the invitation from the pending invitations list
|
||||
setPendingInvitations((prev) =>
|
||||
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
|
||||
)
|
||||
|
||||
setSuccessMessage(`Invitation for ${invitationToRemove.email} has been cancelled`)
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
} catch (error) {
|
||||
logger.error('Error cancelling invitation:', error)
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Failed to cancel invitation. Please try again.'
|
||||
setErrorMessage(errorMsg)
|
||||
} finally {
|
||||
setIsRemovingInvitation(false)
|
||||
setInvitationToRemove(null)
|
||||
}
|
||||
}, [invitationToRemove, workspaceId, userPerms.canAdmin])
|
||||
|
||||
const handleRemoveInvitationCancel = useCallback(() => {
|
||||
setInvitationToRemove(null)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
|
||||
@@ -645,6 +837,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
addEmail(inputValue)
|
||||
}
|
||||
|
||||
// Clear messages at start of submission
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
@@ -732,7 +925,9 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error inviting members:', err)
|
||||
setErrorMessage('An unexpected error occurred. Please try again.')
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'An unexpected error occurred. Please try again.'
|
||||
setErrorMessage(errorMessage)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -750,6 +945,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
// Batch state updates using React's automatic batching in React 18+
|
||||
setInputValue('')
|
||||
setEmails([])
|
||||
setInvalidEmails([])
|
||||
@@ -762,6 +958,10 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
setShowSent(false)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
setMemberToRemove(null)
|
||||
setIsRemovingMember(false)
|
||||
setInvitationToRemove(null)
|
||||
setIsRemovingInvitation(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@@ -880,7 +1080,9 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
<PermissionsTable
|
||||
userPermissions={userPermissions}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
disabled={isSubmitting || isSaving}
|
||||
onRemoveMember={handleRemoveMemberClick}
|
||||
onRemoveInvitation={handleRemoveInvitationClick}
|
||||
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
|
||||
existingUserPermissionChanges={existingUserPermissionChanges}
|
||||
isSaving={isSaving}
|
||||
workspacePermissions={workspacePermissions}
|
||||
@@ -946,6 +1148,74 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Remove Member Confirmation Dialog */}
|
||||
<Dialog open={!!memberToRemove} onOpenChange={handleRemoveMemberCancel}>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='py-4'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Are you sure you want to remove{' '}
|
||||
<span className='font-medium text-foreground'>{memberToRemove?.email}</span> from this
|
||||
workspace? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleRemoveMemberCancel}
|
||||
disabled={isRemovingMember}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleRemoveMemberConfirm}
|
||||
disabled={isRemovingMember}
|
||||
className='gap-2'
|
||||
>
|
||||
{isRemovingMember && <Loader2 className='h-4 w-4 animate-spin' />}
|
||||
Remove Member
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Remove Invitation Confirmation Dialog */}
|
||||
<Dialog open={!!invitationToRemove} onOpenChange={handleRemoveInvitationCancel}>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel Invitation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='py-4'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Are you sure you want to cancel the invitation for{' '}
|
||||
<span className='font-medium text-foreground'>{invitationToRemove?.email}</span>? This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleRemoveInvitationCancel}
|
||||
disabled={isRemovingInvitation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleRemoveInvitationConfirm}
|
||||
disabled={isRemovingInvitation}
|
||||
className='gap-2'
|
||||
>
|
||||
{isRemovingInvitation && <Loader2 className='h-4 w-4 animate-spin' />}
|
||||
Cancel Invitation
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
|
||||
import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lucide-react'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
|
||||
import { SearchModal } from '../search-modal/search-modal'
|
||||
import { CreateMenu } from './components/create-menu/create-menu'
|
||||
import { FolderTree } from './components/folder-tree/folder-tree'
|
||||
import { HelpModal } from './components/help-modal/help-modal'
|
||||
@@ -51,6 +52,24 @@ interface Workspace {
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Template data interface for search modal
|
||||
*/
|
||||
interface TemplateData {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
usageCount: string
|
||||
stars: number
|
||||
icon: string
|
||||
iconColor: string
|
||||
state?: {
|
||||
blocks?: Record<string, { type: string; name?: string }>
|
||||
}
|
||||
isStarred?: boolean
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
useGlobalShortcuts()
|
||||
|
||||
@@ -67,12 +86,18 @@ export function Sidebar() {
|
||||
|
||||
// Add state to prevent multiple simultaneous workflow creations
|
||||
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
||||
// Add sidebar collapsed state
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
// Template data for search modal
|
||||
const [templates, setTemplates] = useState<TemplateData[]>([])
|
||||
const [isTemplatesLoading, setIsTemplatesLoading] = useState(false)
|
||||
|
||||
// Refs
|
||||
const workflowScrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
@@ -348,6 +373,60 @@ export function Sidebar() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Fetch popular templates for search modal
|
||||
*/
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setIsTemplatesLoading(true)
|
||||
try {
|
||||
// Fetch templates from API, ordered by views (most popular first)
|
||||
const response = await fetch('/api/templates?limit=8&offset=0')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch templates: ${response.status}`)
|
||||
}
|
||||
|
||||
const apiResponse = await response.json()
|
||||
|
||||
// Map API response to TemplateData format
|
||||
const fetchedTemplates: TemplateData[] =
|
||||
apiResponse.data?.map((template: any) => ({
|
||||
id: template.id,
|
||||
title: template.name,
|
||||
description: template.description || '',
|
||||
author: template.author,
|
||||
usageCount: formatUsageCount(template.views || 0),
|
||||
stars: template.stars || 0,
|
||||
icon: template.icon || 'FileText',
|
||||
iconColor: template.color || '#6B7280',
|
||||
state: template.state,
|
||||
isStarred: template.isStarred || false,
|
||||
})) || []
|
||||
|
||||
setTemplates(fetchedTemplates)
|
||||
logger.info(`Templates loaded successfully: ${fetchedTemplates.length} templates`)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching templates:', error)
|
||||
// Set empty array on error
|
||||
setTemplates([])
|
||||
} finally {
|
||||
setIsTemplatesLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Format usage count for display (e.g., 1500 -> "1.5k")
|
||||
*/
|
||||
const formatUsageCount = (count: number): string => {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}m`
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
// Load workflows for the current workspace when workspaceId changes
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
@@ -368,6 +447,7 @@ export function Sidebar() {
|
||||
if (sessionData?.user?.id && !isInitializedRef.current) {
|
||||
isInitializedRef.current = true
|
||||
fetchWorkspaces()
|
||||
fetchTemplates()
|
||||
}
|
||||
}, [sessionData?.user?.id]) // Removed fetchWorkspaces dependency
|
||||
|
||||
@@ -391,7 +471,7 @@ export function Sidebar() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showInviteMembers, setShowInviteMembers] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showSearchModal, setShowSearchModal] = useState(false)
|
||||
|
||||
// Separate regular workflows from temporary marketplace workflows
|
||||
const { regularWorkflows, tempWorkflows } = useMemo(() => {
|
||||
@@ -456,6 +536,15 @@ export function Sidebar() {
|
||||
setIsWorkspaceSelectorVisible((prev) => !prev)
|
||||
}
|
||||
|
||||
// Toggle sidebar collapsed state
|
||||
const toggleSidebarCollapsed = () => {
|
||||
setIsSidebarCollapsed((prev) => !prev)
|
||||
// Hide workspace selector when collapsing sidebar
|
||||
if (!isSidebarCollapsed) {
|
||||
setIsWorkspaceSelectorVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate dynamic positions for floating elements
|
||||
const calculateFloatingPositions = useCallback(() => {
|
||||
const { CONTAINER_PADDING, WORKSPACE_HEADER, SEARCH, WORKFLOW_SELECTOR, WORKSPACE_SELECTOR } =
|
||||
@@ -467,13 +556,15 @@ export function Sidebar() {
|
||||
// Add workspace header
|
||||
currentTop += WORKSPACE_HEADER + SIDEBAR_GAP
|
||||
|
||||
// Add workspace selector if visible
|
||||
if (isWorkspaceSelectorVisible) {
|
||||
// Add workspace selector if visible and not collapsed
|
||||
if (isWorkspaceSelectorVisible && !isSidebarCollapsed) {
|
||||
currentTop += WORKSPACE_SELECTOR + SIDEBAR_GAP
|
||||
}
|
||||
|
||||
// Add search
|
||||
currentTop += SEARCH + SIDEBAR_GAP
|
||||
// Add search (if not collapsed)
|
||||
if (!isSidebarCollapsed) {
|
||||
currentTop += SEARCH + SIDEBAR_GAP
|
||||
}
|
||||
|
||||
// Add workflow selector
|
||||
currentTop += WORKFLOW_SELECTOR - 4
|
||||
@@ -488,10 +579,41 @@ export function Sidebar() {
|
||||
toolbarTop,
|
||||
navigationBottom,
|
||||
}
|
||||
}, [isWorkspaceSelectorVisible])
|
||||
}, [isWorkspaceSelectorVisible, isSidebarCollapsed])
|
||||
|
||||
const { toolbarTop, navigationBottom } = calculateFloatingPositions()
|
||||
|
||||
// Add keyboard shortcut for search modal (Cmd+K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't trigger if user is typing in an input, textarea, or contenteditable element
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) return
|
||||
|
||||
// Cmd/Ctrl + K - Open search modal
|
||||
if (
|
||||
event.key.toLowerCase() === 'k' &&
|
||||
((event.metaKey &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.platform.toUpperCase().indexOf('MAC') >= 0) ||
|
||||
(event.ctrlKey &&
|
||||
(typeof navigator === 'undefined' ||
|
||||
navigator.platform.toUpperCase().indexOf('MAC') < 0)))
|
||||
) {
|
||||
event.preventDefault()
|
||||
setShowSearchModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Navigation items with their respective actions
|
||||
const navigationItems = [
|
||||
{
|
||||
@@ -527,7 +649,6 @@ export function Sidebar() {
|
||||
icon: Shapes,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
tooltip: 'Templates',
|
||||
shortcut: getKeyboardShortcutText('T', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
]
|
||||
@@ -546,6 +667,7 @@ export function Sidebar() {
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
onToggleWorkspaceSelector={toggleWorkspaceSelector}
|
||||
onToggleSidebar={toggleSidebarCollapsed}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
updateWorkspaceName={updateWorkspaceName}
|
||||
@@ -553,44 +675,52 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* 2. Workspace Selector - Conditionally rendered */}
|
||||
{isWorkspaceSelectorVisible && (
|
||||
<div className='pointer-events-auto flex-shrink-0'>
|
||||
<WorkspaceSelector
|
||||
workspaces={workspaces}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
onWorkspaceUpdate={refreshWorkspaceList}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onDeleteWorkspace={confirmDeleteWorkspace}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`pointer-events-auto flex-shrink-0 ${
|
||||
!isWorkspaceSelectorVisible || isSidebarCollapsed ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<WorkspaceSelector
|
||||
workspaces={workspaces}
|
||||
activeWorkspace={activeWorkspace}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
onWorkspaceUpdate={refreshWorkspaceList}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onDeleteWorkspace={confirmDeleteWorkspace}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. Search */}
|
||||
{/* <div className='pointer-events-auto flex-shrink-0'>
|
||||
<div className='flex h-12 items-center gap-2 rounded-[14px] border bg-card pr-2 pl-3 shadow-xs'>
|
||||
<div
|
||||
className={`pointer-events-auto flex-shrink-0 ${isSidebarCollapsed ? 'hidden' : ''}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowSearchModal(true)}
|
||||
className='flex h-12 w-full cursor-pointer items-center gap-2 rounded-[14px] border bg-card pr-[10px] pl-3 shadow-xs transition-colors hover:bg-muted/50'
|
||||
>
|
||||
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
|
||||
<Input
|
||||
placeholder='Search anything'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='h-8 flex-1 border-0 bg-transparent px-0 font-normal text-base text-muted-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<span className='flex h-8 flex-1 items-center px-0 font-[350] text-muted-foreground text-sm leading-none'>
|
||||
Search anything
|
||||
</span>
|
||||
<kbd className='flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]'>
|
||||
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
|
||||
<span className='text-lg'>⌘</span>
|
||||
<span className='text-xs'>K</span>
|
||||
</span>
|
||||
</kbd>
|
||||
</div>
|
||||
</div> */}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 4. Workflow Selector */}
|
||||
<div className='pointer-events-auto relative h-[272px] flex-shrink-0 rounded-[14px] border bg-card shadow-xs'>
|
||||
<div
|
||||
className={`pointer-events-auto relative h-[212px] flex-shrink-0 rounded-[14px] border bg-card shadow-xs ${
|
||||
isSidebarCollapsed ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className='px-2'>
|
||||
<ScrollArea ref={workflowScrollAreaRef} className='h-[270px]' hideScrollbar={true}>
|
||||
<ScrollArea ref={workflowScrollAreaRef} className='h-[210px]' hideScrollbar={true}>
|
||||
<FolderTree
|
||||
regularWorkflows={regularWorkflows}
|
||||
marketplaceWorkflows={tempWorkflows}
|
||||
@@ -614,20 +744,20 @@ export function Sidebar() {
|
||||
</aside>
|
||||
|
||||
{/* Floating Toolbar - Only on workflow pages */}
|
||||
{isOnWorkflowPage && (
|
||||
<div
|
||||
className='pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs'
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
userPermissions={userPermissions}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs ${
|
||||
!isOnWorkflowPage || isSidebarCollapsed ? 'hidden' : ''
|
||||
}`}
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
userPermissions={userPermissions}
|
||||
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating Navigation - Always visible */}
|
||||
<div
|
||||
@@ -645,6 +775,7 @@ export function Sidebar() {
|
||||
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
||||
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
|
||||
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
|
||||
<SearchModal open={showSearchModal} onOpenChange={setShowSearchModal} templates={templates} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
|
||||
description: 'Manage Google Calendar events',
|
||||
longDescription:
|
||||
"Integrate Google Calendar functionality to create, read, update, and list calendar events within your workflow. Automate scheduling, check availability, and manage events using OAuth authentication. Email invitations are sent asynchronously and delivery depends on recipients' Google Calendar settings.",
|
||||
docsLink: 'https://docs.simstudio.ai/tools/google-calendar',
|
||||
docsLink: 'https://docs.simstudio.ai/tools/google_calendar',
|
||||
category: 'tools',
|
||||
bgColor: '#E0E0E0',
|
||||
icon: GoogleCalendarIcon,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const LinkupBlock: BlockConfig<LinkupSearchToolResponse> = {
|
||||
'Linkup Search allows you to search and retrieve up-to-date information from the web with source attribution.',
|
||||
docsLink: 'https://docs.simstudio.ai/tools/linkup',
|
||||
category: 'tools',
|
||||
bgColor: '#EAEADC',
|
||||
bgColor: '#D6D3C7',
|
||||
icon: LinkupIcon,
|
||||
|
||||
subBlocks: [
|
||||
|
||||
File diff suppressed because one or more lines are too long
47
apps/sim/db/migrations/0054_naive_raider.sql
Normal file
47
apps/sim/db/migrations/0054_naive_raider.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE "template_stars" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"template_id" text NOT NULL,
|
||||
"starred_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "templates" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workflow_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"author" text NOT NULL,
|
||||
"views" integer DEFAULT 0 NOT NULL,
|
||||
"stars" integer DEFAULT 0 NOT NULL,
|
||||
"color" text DEFAULT '#3972F6' NOT NULL,
|
||||
"icon" text DEFAULT 'FileText' NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"state" jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "workspace_member" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "template_stars" ADD CONSTRAINT "template_stars_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "template_stars" ADD CONSTRAINT "template_stars_template_id_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."templates"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "templates" ADD CONSTRAINT "templates_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "templates" ADD CONSTRAINT "templates_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "template_stars_user_id_idx" ON "template_stars" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "template_stars_template_id_idx" ON "template_stars" USING btree ("template_id");--> statement-breakpoint
|
||||
CREATE INDEX "template_stars_user_template_idx" ON "template_stars" USING btree ("user_id","template_id");--> statement-breakpoint
|
||||
CREATE INDEX "template_stars_template_user_idx" ON "template_stars" USING btree ("template_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX "template_stars_starred_at_idx" ON "template_stars" USING btree ("starred_at");--> statement-breakpoint
|
||||
CREATE INDEX "template_stars_template_starred_at_idx" ON "template_stars" USING btree ("template_id","starred_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "template_stars_user_template_unique" ON "template_stars" USING btree ("user_id","template_id");--> statement-breakpoint
|
||||
CREATE INDEX "templates_workflow_id_idx" ON "templates" USING btree ("workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "templates_user_id_idx" ON "templates" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "templates_category_idx" ON "templates" USING btree ("category");--> statement-breakpoint
|
||||
CREATE INDEX "templates_views_idx" ON "templates" USING btree ("views");--> statement-breakpoint
|
||||
CREATE INDEX "templates_stars_idx" ON "templates" USING btree ("stars");--> statement-breakpoint
|
||||
CREATE INDEX "templates_category_views_idx" ON "templates" USING btree ("category","views");--> statement-breakpoint
|
||||
CREATE INDEX "templates_category_stars_idx" ON "templates" USING btree ("category","stars");--> statement-breakpoint
|
||||
CREATE INDEX "templates_user_category_idx" ON "templates" USING btree ("user_id","category");--> statement-breakpoint
|
||||
CREATE INDEX "templates_created_at_idx" ON "templates" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "templates_updated_at_idx" ON "templates" USING btree ("updated_at");
|
||||
5539
apps/sim/db/migrations/meta/0054_snapshot.json
Normal file
5539
apps/sim/db/migrations/meta/0054_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -372,6 +372,13 @@
|
||||
"when": 1752093722331,
|
||||
"tag": "0053_gigantic_gabe_jones",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "7",
|
||||
"when": 1752708227343,
|
||||
"tag": "0054_naive_raider",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -628,28 +628,6 @@ export const workspace = pgTable('workspace', {
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const workspaceMember = pgTable(
|
||||
'workspace_member',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workspaceId: text('workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull().default('member'), // e.g., 'owner', 'admin', 'member'
|
||||
joinedAt: timestamp('joined_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
// Create index on userId for fast lookups of workspaces by user
|
||||
userIdIdx: uniqueIndex('user_workspace_idx').on(table.userId, table.workspaceId),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Define the permission enum
|
||||
export const permissionTypeEnum = pgEnum('permission_type', ['admin', 'write', 'read'])
|
||||
|
||||
|
||||
29
apps/sim/executor/consts.ts
Normal file
29
apps/sim/executor/consts.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Enum defining all supported block types in the executor.
|
||||
* This centralizes block type definitions and eliminates magic strings.
|
||||
*/
|
||||
export enum BlockType {
|
||||
PARALLEL = 'parallel',
|
||||
LOOP = 'loop',
|
||||
ROUTER = 'router',
|
||||
CONDITION = 'condition',
|
||||
FUNCTION = 'function',
|
||||
AGENT = 'agent',
|
||||
API = 'api',
|
||||
EVALUATOR = 'evaluator',
|
||||
RESPONSE = 'response',
|
||||
WORKFLOW = 'workflow',
|
||||
STARTER = 'starter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of all block types for iteration and validation
|
||||
*/
|
||||
export const ALL_BLOCK_TYPES = Object.values(BlockType) as string[]
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid block type
|
||||
*/
|
||||
export function isValidBlockType(type: string): type is BlockType {
|
||||
return ALL_BLOCK_TYPES.includes(type)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import type { ExecutionContext, StreamingExecution } from '../../types'
|
||||
import { AgentBlockHandler } from './agent-handler'
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
|
||||
@@ -88,8 +89,8 @@ describe('AgentBlockHandler', () => {
|
||||
|
||||
mockBlock = {
|
||||
id: 'test-agent-block',
|
||||
metadata: { id: 'agent', name: 'Test Agent' },
|
||||
type: 'agent',
|
||||
metadata: { id: BlockType.AGENT, name: 'Test Agent' },
|
||||
type: BlockType.AGENT,
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'mock-tool',
|
||||
@@ -908,7 +909,7 @@ describe('AgentBlockHandler', () => {
|
||||
logs: [
|
||||
{
|
||||
blockId: 'some-id',
|
||||
blockType: 'agent',
|
||||
blockType: BlockType.AGENT,
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: new Date().toISOString(),
|
||||
durationMs: 100,
|
||||
@@ -959,7 +960,7 @@ describe('AgentBlockHandler', () => {
|
||||
const logs = (result as StreamingExecution).execution.logs
|
||||
expect(logs?.length).toBe(1)
|
||||
if (logs && logs.length > 0 && logs[0]) {
|
||||
expect(logs[0].blockType).toBe('agent')
|
||||
expect(logs[0].blockType).toBe(BlockType.AGENT)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1069,7 +1070,7 @@ describe('AgentBlockHandler', () => {
|
||||
memories: [
|
||||
{
|
||||
key: 'conversation-1',
|
||||
type: 'agent',
|
||||
type: BlockType.AGENT,
|
||||
data: [
|
||||
{ role: 'user', content: 'Hi there!' },
|
||||
{ role: 'assistant', content: 'Hello! How can I help you?' },
|
||||
|
||||
@@ -2,13 +2,19 @@ import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type {
|
||||
AgentInputs,
|
||||
Message,
|
||||
StreamingConfig,
|
||||
ToolInput,
|
||||
} from '@/executor/handlers/agent/types'
|
||||
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getApiKey, getProviderFromModel, transformBlockTool } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getTool, getToolAsync } from '@/tools/utils'
|
||||
import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types'
|
||||
import type { AgentInputs, Message, StreamingConfig, ToolInput } from './types'
|
||||
|
||||
const logger = createLogger('AgentBlockHandler')
|
||||
|
||||
@@ -22,7 +28,7 @@ const CUSTOM_TOOL_PREFIX = 'custom_'
|
||||
*/
|
||||
export class AgentBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'agent'
|
||||
return block.metadata?.id === BlockType.AGENT
|
||||
}
|
||||
|
||||
async execute(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import '../../__test-utils__/mock-dependencies'
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { ApiBlockHandler } from './api-handler'
|
||||
|
||||
const mockGetTool = vi.mocked(getTool)
|
||||
const mockExecuteTool = executeTool as Mock
|
||||
@@ -21,7 +22,7 @@ describe('ApiBlockHandler', () => {
|
||||
handler = new ApiBlockHandler()
|
||||
mockBlock = {
|
||||
id: 'api-block-1',
|
||||
metadata: { id: 'api', name: 'Test API Block' },
|
||||
metadata: { id: BlockType.API, name: 'Test API Block' },
|
||||
position: { x: 10, y: 10 },
|
||||
config: { tool: 'http_request', params: {} },
|
||||
inputs: {},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getTool } from '@/tools/utils'
|
||||
import type { BlockHandler, ExecutionContext } from '../../types'
|
||||
|
||||
const logger = createLogger('ApiBlockHandler')
|
||||
|
||||
@@ -11,7 +12,7 @@ const logger = createLogger('ApiBlockHandler')
|
||||
*/
|
||||
export class ApiBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'api'
|
||||
return block.metadata?.id === BlockType.API
|
||||
}
|
||||
|
||||
async execute(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import '../../__test-utils__/mock-dependencies'
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mocked, type MockedClass, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import { InputResolver } from '@/executor/resolver/resolver'
|
||||
import type { BlockState, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { PathTracker } from '../../path'
|
||||
import { InputResolver } from '../../resolver'
|
||||
import type { BlockState, ExecutionContext } from '../../types'
|
||||
import { ConditionBlockHandler } from './condition-handler'
|
||||
|
||||
const MockPathTracker = PathTracker as MockedClass<typeof PathTracker>
|
||||
const MockInputResolver = InputResolver as MockedClass<typeof InputResolver>
|
||||
@@ -34,9 +35,9 @@ describe('ConditionBlockHandler', () => {
|
||||
}
|
||||
mockBlock = {
|
||||
id: 'cond-block-1',
|
||||
metadata: { id: 'condition', name: 'Test Condition' },
|
||||
metadata: { id: BlockType.CONDITION, name: 'Test Condition' },
|
||||
position: { x: 50, y: 50 },
|
||||
config: { tool: 'condition', params: {} },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: { conditions: 'json' }, // Corrected based on previous step
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { PathTracker } from '@/executor/path/path'
|
||||
import type { InputResolver } from '@/executor/resolver/resolver'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { PathTracker } from '../../path'
|
||||
import type { InputResolver } from '../../resolver'
|
||||
import type { BlockHandler, ExecutionContext } from '../../types'
|
||||
|
||||
const logger = createLogger('ConditionBlockHandler')
|
||||
|
||||
@@ -21,7 +22,7 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
) {}
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'condition'
|
||||
return block.metadata?.id === BlockType.CONDITION
|
||||
}
|
||||
|
||||
async execute(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import '../../__test-utils__/mock-dependencies'
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { EvaluatorBlockHandler } from './evaluator-handler'
|
||||
|
||||
const mockGetProviderFromModel = getProviderFromModel as Mock
|
||||
const mockFetch = global.fetch as unknown as Mock
|
||||
@@ -19,9 +20,9 @@ describe('EvaluatorBlockHandler', () => {
|
||||
|
||||
mockBlock = {
|
||||
id: 'eval-block-1',
|
||||
metadata: { id: 'evaluator', name: 'Test Evaluator' },
|
||||
metadata: { id: BlockType.EVALUATOR, name: 'Test Evaluator' },
|
||||
position: { x: 20, y: 20 },
|
||||
config: { tool: 'evaluator', params: {} },
|
||||
config: { tool: BlockType.EVALUATOR, params: {} },
|
||||
inputs: {
|
||||
content: 'string',
|
||||
metrics: 'json',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { BlockHandler, ExecutionContext } from '../../types'
|
||||
|
||||
const logger = createLogger('EvaluatorBlockHandler')
|
||||
|
||||
@@ -12,7 +13,7 @@ const logger = createLogger('EvaluatorBlockHandler')
|
||||
*/
|
||||
export class EvaluatorBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'evaluator'
|
||||
return block.metadata?.id === BlockType.EVALUATOR
|
||||
}
|
||||
|
||||
async execute(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { FunctionBlockHandler } from './function-handler'
|
||||
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
@@ -29,9 +30,9 @@ describe('FunctionBlockHandler', () => {
|
||||
|
||||
mockBlock = {
|
||||
id: 'func-block-1',
|
||||
metadata: { id: 'function', name: 'Test Function' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Test Function' },
|
||||
position: { x: 30, y: 30 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: { code: 'string', timeout: 'number' }, // Using ParamType strings
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import type { BlockHandler, ExecutionContext } from '../../types'
|
||||
|
||||
const logger = createLogger('FunctionBlockHandler')
|
||||
|
||||
@@ -10,7 +11,7 @@ const logger = createLogger('FunctionBlockHandler')
|
||||
*/
|
||||
export class FunctionBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'function'
|
||||
return block.metadata?.id === BlockType.FUNCTION
|
||||
}
|
||||
|
||||
async execute(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import '../../__test-utils__/mock-dependencies'
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { GenericBlockHandler } from './generic-handler'
|
||||
|
||||
const mockGetTool = vi.mocked(getTool)
|
||||
const mockExecuteTool = executeTool as Mock
|
||||
@@ -74,7 +75,7 @@ describe('GenericBlockHandler', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should always handle any block type', () => {
|
||||
const agentBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'agent' } }
|
||||
const agentBlock: SerializedBlock = { ...mockBlock, metadata: { id: BlockType.AGENT } }
|
||||
expect(handler.canHandle(agentBlock)).toBe(true)
|
||||
expect(handler.canHandle(mockBlock)).toBe(true)
|
||||
const noMetaIdBlock: SerializedBlock = { ...mockBlock, metadata: undefined }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
import { getTool } from '@/tools/utils'
|
||||
import type { BlockHandler, ExecutionContext } from '../../types'
|
||||
|
||||
const logger = createLogger('GenericBlockHandler')
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { AgentBlockHandler } from './agent/agent-handler'
|
||||
import { ApiBlockHandler } from './api/api-handler'
|
||||
import { ConditionBlockHandler } from './condition/condition-handler'
|
||||
import { EvaluatorBlockHandler } from './evaluator/evaluator-handler'
|
||||
import { FunctionBlockHandler } from './function/function-handler'
|
||||
import { GenericBlockHandler } from './generic/generic-handler'
|
||||
import { LoopBlockHandler } from './loop/loop-handler'
|
||||
import { ParallelBlockHandler } from './parallel/parallel-handler'
|
||||
import { ResponseBlockHandler } from './response/response-handler'
|
||||
import { RouterBlockHandler } from './router/router-handler'
|
||||
import { WorkflowBlockHandler } from './workflow/workflow-handler'
|
||||
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
|
||||
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
|
||||
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
|
||||
import { LoopBlockHandler } from '@/executor/handlers/loop/loop-handler'
|
||||
import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-handler'
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
|
||||
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
|
||||
|
||||
export {
|
||||
AgentBlockHandler,
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { LoopBlockHandler } from '@/executor/handlers/loop/loop-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { LoopBlockHandler } from './loop-handler'
|
||||
|
||||
describe('LoopBlockHandler', () => {
|
||||
let handler: LoopBlockHandler
|
||||
let mockContext: ExecutionContext
|
||||
let mockBlock: SerializedBlock
|
||||
|
||||
const mockPathTracker = {
|
||||
isInActivePath: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handler = new LoopBlockHandler()
|
||||
|
||||
mockBlock = {
|
||||
id: 'loop-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'loop', params: {} },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'loop', name: 'Test Loop' },
|
||||
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -66,7 +72,7 @@ describe('LoopBlockHandler', () => {
|
||||
|
||||
it('should not handle non-loop blocks', () => {
|
||||
if (mockBlock.metadata) {
|
||||
mockBlock.metadata.id = 'function'
|
||||
mockBlock.metadata.id = BlockType.FUNCTION
|
||||
}
|
||||
expect(handler.canHandle(mockBlock)).toBe(false)
|
||||
})
|
||||
@@ -212,4 +218,46 @@ describe('LoopBlockHandler', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PathTracker integration', () => {
|
||||
it('should activate children when in active path', async () => {
|
||||
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
|
||||
|
||||
// Mock PathTracker to return true (block is in active path)
|
||||
mockPathTracker.isInActivePath.mockReturnValue(true)
|
||||
|
||||
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
|
||||
|
||||
// Should activate children when in active path
|
||||
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true)
|
||||
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('loop-1', mockContext)
|
||||
})
|
||||
|
||||
it('should not activate children when not in active path', async () => {
|
||||
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
|
||||
|
||||
// Mock PathTracker to return false (block is not in active path)
|
||||
mockPathTracker.isInActivePath.mockReturnValue(false)
|
||||
|
||||
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
|
||||
|
||||
// Should not activate children when not in active path
|
||||
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(false)
|
||||
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('loop-1', mockContext)
|
||||
})
|
||||
|
||||
it('should handle PathTracker errors gracefully', async () => {
|
||||
const handlerWithPathTracker = new LoopBlockHandler(undefined, mockPathTracker as any)
|
||||
|
||||
// Mock PathTracker to throw error
|
||||
mockPathTracker.isInActivePath.mockImplementation(() => {
|
||||
throw new Error('PathTracker error')
|
||||
})
|
||||
|
||||
await handlerWithPathTracker.execute(mockBlock, {}, mockContext)
|
||||
|
||||
// Should default to activating children when PathTracker fails
|
||||
expect(mockContext.activeExecutionPath.has('inner-block')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { PathTracker } from '@/executor/path/path'
|
||||
import type { InputResolver } from '@/executor/resolver/resolver'
|
||||
import { Routing } from '@/executor/routing/routing'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { InputResolver } from '../../resolver'
|
||||
import type { BlockHandler, ExecutionContext } from '../../types'
|
||||
|
||||
const logger = createLogger('LoopBlockHandler')
|
||||
|
||||
@@ -13,10 +16,13 @@ const DEFAULT_MAX_ITERATIONS = 5
|
||||
* Loop blocks don't execute logic themselves but control the flow of blocks within them.
|
||||
*/
|
||||
export class LoopBlockHandler implements BlockHandler {
|
||||
constructor(private resolver?: InputResolver) {}
|
||||
constructor(
|
||||
private resolver?: InputResolver,
|
||||
private pathTracker?: PathTracker
|
||||
) {}
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'loop'
|
||||
return block.metadata?.id === BlockType.LOOP
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -126,15 +132,31 @@ export class LoopBlockHandler implements BlockHandler {
|
||||
`Loop ${block.id} - Incremented counter for next iteration: ${currentIteration + 1}`
|
||||
)
|
||||
|
||||
// Loop is still active, activate the loop-start-source connection
|
||||
const loopStartConnections =
|
||||
context.workflow?.connections.filter(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === 'loop-start-source'
|
||||
) || []
|
||||
// Use routing strategy to determine if this block requires active path checking
|
||||
const blockType = block.metadata?.id
|
||||
if (Routing.requiresActivePathCheck(blockType || '')) {
|
||||
let isInActivePath = true
|
||||
if (this.pathTracker) {
|
||||
try {
|
||||
isInActivePath = this.pathTracker.isInActivePath(block.id, context)
|
||||
} catch (error) {
|
||||
logger.warn(`PathTracker check failed for ${blockType} block ${block.id}:`, error)
|
||||
// Default to true to maintain existing behavior if PathTracker fails
|
||||
isInActivePath = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const conn of loopStartConnections) {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
logger.info(`Activated loop start path to ${conn.target} for iteration ${currentIteration}`)
|
||||
// Only activate child nodes if this block is in the active execution path
|
||||
if (isInActivePath) {
|
||||
this.activateChildNodes(block, context, currentIteration)
|
||||
} else {
|
||||
logger.info(
|
||||
`${blockType} block ${block.id} is not in active execution path, skipping child activation`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Regular blocks always activate their children
|
||||
this.activateChildNodes(block, context, currentIteration)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -147,6 +169,26 @@ export class LoopBlockHandler implements BlockHandler {
|
||||
} as Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate child nodes for loop execution
|
||||
*/
|
||||
private activateChildNodes(
|
||||
block: SerializedBlock,
|
||||
context: ExecutionContext,
|
||||
currentIteration: number
|
||||
): void {
|
||||
// Loop is still active, activate the loop-start-source connection
|
||||
const loopStartConnections =
|
||||
context.workflow?.connections.filter(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === 'loop-start-source'
|
||||
) || []
|
||||
|
||||
for (const conn of loopStartConnections) {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
logger.info(`Activated loop start path to ${conn.target} for iteration ${currentIteration}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates forEach items expression or value
|
||||
*/
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createParallelExecutionState } from '@/executor/__test-utils__/executor-mocks'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedParallel } from '@/serializer/types'
|
||||
import { createParallelExecutionState } from '../../__test-utils__/executor-mocks'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { ParallelBlockHandler } from './parallel-handler'
|
||||
|
||||
describe('ParallelBlockHandler', () => {
|
||||
const mockResolver = {
|
||||
resolveBlockReferences: vi.fn((expr: string) => expr),
|
||||
}
|
||||
|
||||
const mockPathTracker = {
|
||||
isInActivePath: vi.fn(),
|
||||
}
|
||||
|
||||
const createMockBlock = (id: string): SerializedBlock => ({
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'parallel', name: 'Test Parallel' },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Test Parallel' },
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
@@ -46,7 +51,7 @@ describe('ParallelBlockHandler', () => {
|
||||
|
||||
expect(handler.canHandle(block)).toBe(true)
|
||||
|
||||
const nonParallelBlock = { ...block, metadata: { id: 'agent' } }
|
||||
const nonParallelBlock = { ...block, metadata: { id: BlockType.AGENT } }
|
||||
expect(handler.canHandle(nonParallelBlock)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -394,24 +399,24 @@ describe('ParallelBlockHandler', () => {
|
||||
{
|
||||
id: 'agent-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'agent', params: {} },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'agent', name: 'Agent 1' },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'return <parallel.results>;',
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Function 1' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
@@ -481,4 +486,91 @@ describe('ParallelBlockHandler', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PathTracker integration', () => {
|
||||
it('should activate children when in active path', async () => {
|
||||
const handler = new ParallelBlockHandler(mockResolver as any, mockPathTracker as any)
|
||||
const block = createMockBlock('parallel-1')
|
||||
const parallel = {
|
||||
id: 'parallel-1',
|
||||
nodes: ['agent-1'],
|
||||
distribution: ['item1', 'item2'],
|
||||
}
|
||||
|
||||
const context = createMockContext(parallel)
|
||||
context.workflow!.connections = [
|
||||
{
|
||||
source: 'parallel-1',
|
||||
target: 'agent-1',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock PathTracker to return true (block is in active path)
|
||||
mockPathTracker.isInActivePath.mockReturnValue(true)
|
||||
|
||||
await handler.execute(block, {}, context)
|
||||
|
||||
// Should activate children when in active path
|
||||
expect(context.activeExecutionPath.has('agent-1')).toBe(true)
|
||||
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('parallel-1', context)
|
||||
})
|
||||
|
||||
it('should not activate children when not in active path', async () => {
|
||||
const handler = new ParallelBlockHandler(mockResolver as any, mockPathTracker as any)
|
||||
const block = createMockBlock('parallel-1')
|
||||
const parallel = {
|
||||
id: 'parallel-1',
|
||||
nodes: ['agent-1'],
|
||||
distribution: ['item1', 'item2'],
|
||||
}
|
||||
|
||||
const context = createMockContext(parallel)
|
||||
context.workflow!.connections = [
|
||||
{
|
||||
source: 'parallel-1',
|
||||
target: 'agent-1',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock PathTracker to return false (block is not in active path)
|
||||
mockPathTracker.isInActivePath.mockReturnValue(false)
|
||||
|
||||
await handler.execute(block, {}, context)
|
||||
|
||||
// Should not activate children when not in active path
|
||||
expect(context.activeExecutionPath.has('agent-1')).toBe(false)
|
||||
expect(mockPathTracker.isInActivePath).toHaveBeenCalledWith('parallel-1', context)
|
||||
})
|
||||
|
||||
it('should handle PathTracker errors gracefully', async () => {
|
||||
const handler = new ParallelBlockHandler(mockResolver as any, mockPathTracker as any)
|
||||
const block = createMockBlock('parallel-1')
|
||||
const parallel = {
|
||||
id: 'parallel-1',
|
||||
nodes: ['agent-1'],
|
||||
distribution: ['item1', 'item2'],
|
||||
}
|
||||
|
||||
const context = createMockContext(parallel)
|
||||
context.workflow!.connections = [
|
||||
{
|
||||
source: 'parallel-1',
|
||||
target: 'agent-1',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock PathTracker to throw error
|
||||
mockPathTracker.isInActivePath.mockImplementation(() => {
|
||||
throw new Error('PathTracker error')
|
||||
})
|
||||
|
||||
await handler.execute(block, {}, context)
|
||||
|
||||
// Should default to activating children when PathTracker fails
|
||||
expect(context.activeExecutionPath.has('agent-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { PathTracker } from '@/executor/path/path'
|
||||
import type { InputResolver } from '@/executor/resolver/resolver'
|
||||
import { Routing } from '@/executor/routing/routing'
|
||||
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { InputResolver } from '../../resolver'
|
||||
import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types'
|
||||
|
||||
const logger = createLogger('ParallelBlockHandler')
|
||||
|
||||
@@ -12,10 +15,13 @@ const logger = createLogger('ParallelBlockHandler')
|
||||
* create virtual instances for true parallel execution.
|
||||
*/
|
||||
export class ParallelBlockHandler implements BlockHandler {
|
||||
constructor(private resolver?: InputResolver) {}
|
||||
constructor(
|
||||
private resolver?: InputResolver,
|
||||
private pathTracker?: PathTracker
|
||||
) {}
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'parallel'
|
||||
return block.metadata?.id === BlockType.PARALLEL
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -185,15 +191,31 @@ export class ParallelBlockHandler implements BlockHandler {
|
||||
}
|
||||
// Note: For simple count-based parallels without distribution, we don't store items
|
||||
|
||||
// Activate all child nodes (the executor will handle creating virtual instances)
|
||||
const parallelStartConnections =
|
||||
context.workflow?.connections.filter(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === 'parallel-start-source'
|
||||
) || []
|
||||
// Use routing strategy to determine if this block requires active path checking
|
||||
const blockType = block.metadata?.id
|
||||
if (Routing.requiresActivePathCheck(blockType || '')) {
|
||||
let isInActivePath = true
|
||||
if (this.pathTracker) {
|
||||
try {
|
||||
isInActivePath = this.pathTracker.isInActivePath(block.id, context)
|
||||
} catch (error) {
|
||||
logger.warn(`PathTracker check failed for ${blockType} block ${block.id}:`, error)
|
||||
// Default to true to maintain existing behavior if PathTracker fails
|
||||
isInActivePath = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const conn of parallelStartConnections) {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
logger.info(`Activated parallel path to ${conn.target}`)
|
||||
// Only activate child nodes if this block is in the active execution path
|
||||
if (isInActivePath) {
|
||||
this.activateChildNodes(block, context)
|
||||
} else {
|
||||
logger.info(
|
||||
`${blockType} block ${block.id} is not in active execution path, skipping child activation`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Regular blocks always activate their children
|
||||
this.activateChildNodes(block, context)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -291,6 +313,22 @@ export class ParallelBlockHandler implements BlockHandler {
|
||||
} as Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate child nodes for parallel execution
|
||||
*/
|
||||
private activateChildNodes(block: SerializedBlock, context: ExecutionContext): void {
|
||||
// Activate all child nodes (the executor will handle creating virtual instances)
|
||||
const parallelStartConnections =
|
||||
context.workflow?.connections.filter(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === 'parallel-start-source'
|
||||
) || []
|
||||
|
||||
for (const conn of parallelStartConnections) {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
logger.info(`Activated parallel path to ${conn.target}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all iterations of a parallel block have completed
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { BlockHandler } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { BlockHandler } from '../../types'
|
||||
|
||||
const logger = createLogger('ResponseBlockHandler')
|
||||
|
||||
@@ -15,7 +16,7 @@ interface JSONProperty {
|
||||
|
||||
export class ResponseBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'response'
|
||||
return block.metadata?.id === BlockType.RESPONSE
|
||||
}
|
||||
|
||||
async execute(block: SerializedBlock, inputs: Record<string, any>): Promise<BlockOutput> {
|
||||
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
vi,
|
||||
} from 'vitest'
|
||||
import { generateRouterPrompt } from '@/blocks/blocks/router'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { PathTracker } from '../../path'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { RouterBlockHandler } from './router-handler'
|
||||
|
||||
const mockGenerateRouterPrompt = generateRouterPrompt as Mock
|
||||
const mockGetProviderFromModel = getProviderFromModel as Mock
|
||||
@@ -52,9 +53,9 @@ describe('RouterBlockHandler', () => {
|
||||
}
|
||||
mockBlock = {
|
||||
id: 'router-block-1',
|
||||
metadata: { id: 'router', name: 'Test Router' },
|
||||
metadata: { id: BlockType.ROUTER, name: 'Test Router' },
|
||||
position: { x: 50, y: 50 },
|
||||
config: { tool: 'router', params: {} },
|
||||
config: { tool: BlockType.ROUTER, params: {} },
|
||||
inputs: { prompt: 'string', model: 'string' }, // Using ParamType strings
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
|
||||
@@ -2,10 +2,11 @@ import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { generateRouterPrompt } from '@/blocks/blocks/router'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { PathTracker } from '@/executor/path/path'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { PathTracker } from '../../path'
|
||||
import type { BlockHandler, ExecutionContext } from '../../types'
|
||||
|
||||
const logger = createLogger('RouterBlockHandler')
|
||||
|
||||
@@ -19,7 +20,7 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
constructor(private pathTracker: PathTracker) {}
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'router'
|
||||
return block.metadata?.id === BlockType.ROUTER
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -144,7 +145,7 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
|
||||
// Extract system prompt for agent blocks
|
||||
let systemPrompt = ''
|
||||
if (targetBlock.metadata?.id === 'agent') {
|
||||
if (targetBlock.metadata?.id === BlockType.AGENT) {
|
||||
// Try to get system prompt from different possible locations
|
||||
systemPrompt =
|
||||
targetBlock.config?.params?.systemPrompt || targetBlock.inputs?.systemPrompt || ''
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { ExecutionContext } from '../../types'
|
||||
import { WorkflowBlockHandler } from './workflow-handler'
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn()
|
||||
@@ -18,9 +19,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
|
||||
mockBlock = {
|
||||
id: 'workflow-block-1',
|
||||
metadata: { id: 'workflow', name: 'Test Workflow Block' },
|
||||
metadata: { id: BlockType.WORKFLOW, name: 'Test Workflow Block' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'workflow', params: {} },
|
||||
config: { tool: BlockType.WORKFLOW, params: {} },
|
||||
inputs: { workflowId: 'string' },
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -64,9 +65,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter',
|
||||
metadata: { id: 'starter', name: 'Starter' },
|
||||
metadata: { id: BlockType.STARTER, name: 'Starter' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'starter', params: {} },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -87,7 +88,7 @@ describe('WorkflowBlockHandler', () => {
|
||||
})
|
||||
|
||||
it('should not handle non-workflow blocks', () => {
|
||||
const nonWorkflowBlock = { ...mockBlock, metadata: { id: 'function' } }
|
||||
const nonWorkflowBlock = { ...mockBlock, metadata: { id: BlockType.FUNCTION } }
|
||||
expect(handler.canHandle(nonWorkflowBlock)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,12 @@ import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { Executor } from '@/executor'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { Executor } from '../../index'
|
||||
import type { BlockHandler, ExecutionContext, StreamingExecution } from '../../types'
|
||||
|
||||
const logger = createLogger('WorkflowBlockHandler')
|
||||
|
||||
@@ -22,7 +23,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
private static executionStack = new Set<string>()
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === 'workflow'
|
||||
return block.metadata?.id === BlockType.WORKFLOW
|
||||
}
|
||||
|
||||
async execute(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* resolving inputs and dependencies, and managing errors.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { Executor } from '@/executor'
|
||||
import {
|
||||
createMinimalWorkflow,
|
||||
createMockContext,
|
||||
@@ -15,8 +16,8 @@ import {
|
||||
createWorkflowWithErrorPath,
|
||||
createWorkflowWithLoop,
|
||||
setupAllMocks,
|
||||
} from './__test-utils__/executor-mocks'
|
||||
import { Executor } from './index'
|
||||
} from '@/executor/__test-utils__/executor-mocks'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
|
||||
vi.mock('@/stores/execution/store', () => ({
|
||||
useExecutionStore: {
|
||||
@@ -155,14 +156,14 @@ describe('Executor', () => {
|
||||
|
||||
test('should throw error for workflow without starter block', () => {
|
||||
const workflow = createMinimalWorkflow()
|
||||
workflow.blocks = workflow.blocks.filter((block) => block.metadata?.id !== 'starter')
|
||||
workflow.blocks = workflow.blocks.filter((block) => block.metadata?.id !== BlockType.STARTER)
|
||||
|
||||
expect(() => new Executor(workflow)).toThrow('Workflow must have an enabled starter block')
|
||||
})
|
||||
|
||||
test('should throw error for workflow with disabled starter block', () => {
|
||||
const workflow = createMinimalWorkflow()
|
||||
workflow.blocks.find((block) => block.metadata?.id === 'starter')!.enabled = false
|
||||
workflow.blocks.find((block) => block.metadata?.id === BlockType.STARTER)!.enabled = false
|
||||
|
||||
expect(() => new Executor(workflow)).toThrow('Workflow must have an enabled starter block')
|
||||
})
|
||||
@@ -459,7 +460,7 @@ describe('Executor', () => {
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
metadata: { id: 'condition', name: 'Condition Block' },
|
||||
metadata: { id: BlockType.CONDITION, name: 'Condition Block' },
|
||||
})
|
||||
|
||||
// Mock context
|
||||
@@ -641,7 +642,7 @@ describe('Executor', () => {
|
||||
{
|
||||
id: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
metadata: { id: 'starter', name: 'Start' },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
config: { tool: 'test-tool', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
@@ -650,7 +651,7 @@ describe('Executor', () => {
|
||||
{
|
||||
id: 'router',
|
||||
position: { x: 100, y: 0 },
|
||||
metadata: { id: 'router', name: 'Router' },
|
||||
metadata: { id: BlockType.ROUTER, name: 'Router' },
|
||||
config: { tool: 'test-tool', params: { prompt: 'test', model: 'gpt-4' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
@@ -659,7 +660,7 @@ describe('Executor', () => {
|
||||
{
|
||||
id: 'api1',
|
||||
position: { x: 200, y: -50 },
|
||||
metadata: { id: 'api', name: 'API 1' },
|
||||
metadata: { id: BlockType.API, name: 'API 1' },
|
||||
config: { tool: 'test-tool', params: { url: 'http://api1.com', method: 'GET' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
@@ -668,7 +669,7 @@ describe('Executor', () => {
|
||||
{
|
||||
id: 'api2',
|
||||
position: { x: 200, y: 50 },
|
||||
metadata: { id: 'api', name: 'API 2' },
|
||||
metadata: { id: BlockType.API, name: 'API 2' },
|
||||
config: { tool: 'test-tool', params: { url: 'http://api2.com', method: 'GET' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
@@ -677,7 +678,7 @@ describe('Executor', () => {
|
||||
{
|
||||
id: 'agent',
|
||||
position: { x: 300, y: 0 },
|
||||
metadata: { id: 'agent', name: 'Agent' },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent' },
|
||||
config: { tool: 'test-tool', params: { model: 'gpt-4', userPrompt: 'test' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
@@ -771,7 +772,7 @@ describe('Executor', () => {
|
||||
workflow.blocks.push({
|
||||
id: 'router1',
|
||||
position: { x: 200, y: 0 },
|
||||
metadata: { id: 'router', name: 'Router' },
|
||||
metadata: { id: BlockType.ROUTER, name: 'Router' },
|
||||
config: { tool: 'test-tool', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { BlockPathCalculator } from '@/lib/block-path-calculator'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import {
|
||||
AgentBlockHandler,
|
||||
ApiBlockHandler,
|
||||
@@ -17,11 +14,11 @@ import {
|
||||
ResponseBlockHandler,
|
||||
RouterBlockHandler,
|
||||
WorkflowBlockHandler,
|
||||
} from './handlers/index'
|
||||
import { LoopManager } from './loops'
|
||||
import { ParallelManager } from './parallels'
|
||||
import { PathTracker } from './path'
|
||||
import { InputResolver } from './resolver'
|
||||
} from '@/executor/handlers'
|
||||
import { LoopManager } from '@/executor/loops/loops'
|
||||
import { ParallelManager } from '@/executor/parallels/parallels'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import { InputResolver } from '@/executor/resolver/resolver'
|
||||
import type {
|
||||
BlockHandler,
|
||||
BlockLog,
|
||||
@@ -29,8 +26,12 @@ import type {
|
||||
ExecutionResult,
|
||||
NormalizedBlockOutput,
|
||||
StreamingExecution,
|
||||
} from './types'
|
||||
import { streamingResponseFormatProcessor } from './utils'
|
||||
} from '@/executor/types'
|
||||
import { streamingResponseFormatProcessor } from '@/executor/utils'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useConsoleStore } from '@/stores/panel/console/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
const logger = createLogger('Executor')
|
||||
|
||||
@@ -152,8 +153,8 @@ export class Executor {
|
||||
new EvaluatorBlockHandler(),
|
||||
new FunctionBlockHandler(),
|
||||
new ApiBlockHandler(),
|
||||
new LoopBlockHandler(this.resolver),
|
||||
new ParallelBlockHandler(this.resolver),
|
||||
new LoopBlockHandler(this.resolver, this.pathTracker),
|
||||
new ParallelBlockHandler(this.resolver, this.pathTracker),
|
||||
new ResponseBlockHandler(),
|
||||
new WorkflowBlockHandler(),
|
||||
new GenericBlockHandler(),
|
||||
@@ -548,7 +549,7 @@ export class Executor {
|
||||
*/
|
||||
private validateWorkflow(): void {
|
||||
const starterBlock = this.actualWorkflow.blocks.find(
|
||||
(block) => block.metadata?.id === 'starter'
|
||||
(block) => block.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (!starterBlock || !starterBlock.enabled) {
|
||||
throw new Error('Workflow must have an enabled starter block')
|
||||
@@ -652,7 +653,7 @@ export class Executor {
|
||||
}
|
||||
|
||||
const starterBlock = this.actualWorkflow.blocks.find(
|
||||
(block) => block.metadata?.id === 'starter'
|
||||
(block) => block.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
// Initialize the starter block with the workflow input
|
||||
@@ -998,7 +999,7 @@ export class Executor {
|
||||
// Check if this is a loop block
|
||||
const isLoopBlock = incomingConnections.some((conn) => {
|
||||
const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source)
|
||||
return sourceBlock?.metadata?.id === 'loop'
|
||||
return sourceBlock?.metadata?.id === BlockType.LOOP
|
||||
})
|
||||
|
||||
if (isLoopBlock) {
|
||||
@@ -1081,7 +1082,7 @@ export class Executor {
|
||||
// For condition blocks, check if this is the selected path
|
||||
if (conn.sourceHandle?.startsWith('condition-')) {
|
||||
const sourceBlock = this.actualWorkflow.blocks.find((b) => b.id === conn.source)
|
||||
if (sourceBlock?.metadata?.id === 'condition') {
|
||||
if (sourceBlock?.metadata?.id === BlockType.CONDITION) {
|
||||
const conditionId = conn.sourceHandle.replace('condition-', '')
|
||||
const selectedCondition = context.decisions.condition.get(conn.source)
|
||||
|
||||
@@ -1096,7 +1097,7 @@ export class Executor {
|
||||
}
|
||||
|
||||
// For router blocks, check if this is the selected target
|
||||
if (sourceBlock?.metadata?.id === 'router') {
|
||||
if (sourceBlock?.metadata?.id === BlockType.ROUTER) {
|
||||
const selectedTarget = context.decisions.router.get(conn.source)
|
||||
|
||||
// If source is executed and this is not the selected target, consider it met
|
||||
@@ -1219,7 +1220,7 @@ export class Executor {
|
||||
|
||||
// Special case for starter block - it's already been initialized in createExecutionContext
|
||||
// This ensures we don't re-execute the starter block and just return its existing state
|
||||
if (block.metadata?.id === 'starter') {
|
||||
if (block.metadata?.id === BlockType.STARTER) {
|
||||
const starterState = context.blockStates.get(actualBlockId)
|
||||
if (starterState) {
|
||||
return starterState.output as NormalizedBlockOutput
|
||||
@@ -1243,7 +1244,9 @@ export class Executor {
|
||||
|
||||
// Check if this block needs the starter block's output
|
||||
// This is especially relevant for API, function, and conditions that might reference <start.input>
|
||||
const starterBlock = this.actualWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
|
||||
const starterBlock = this.actualWorkflow.blocks.find(
|
||||
(b) => b.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
const starterState = context.blockStates.get(starterBlock.id)
|
||||
if (!starterState) {
|
||||
@@ -1345,7 +1348,7 @@ export class Executor {
|
||||
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
// For streaming blocks, we'll add the console entry after stream processing
|
||||
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
|
||||
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
|
||||
addConsole({
|
||||
input: blockLog.input,
|
||||
output: blockLog.output,
|
||||
@@ -1415,7 +1418,7 @@ export class Executor {
|
||||
context.blockLogs.push(blockLog)
|
||||
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
|
||||
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
|
||||
addConsole({
|
||||
input: blockLog.input,
|
||||
output: blockLog.output,
|
||||
@@ -1483,7 +1486,7 @@ export class Executor {
|
||||
context.blockLogs.push(blockLog)
|
||||
|
||||
// Skip console logging for infrastructure blocks like loops and parallels
|
||||
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
|
||||
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
|
||||
addConsole({
|
||||
input: blockLog.input,
|
||||
output: {},
|
||||
@@ -1575,10 +1578,10 @@ export class Executor {
|
||||
// Skip for starter blocks which don't have error handles
|
||||
const block = this.actualWorkflow.blocks.find((b) => b.id === blockId)
|
||||
if (
|
||||
block?.metadata?.id === 'starter' ||
|
||||
block?.metadata?.id === 'condition' ||
|
||||
block?.metadata?.id === 'loop' ||
|
||||
block?.metadata?.id === 'parallel'
|
||||
block?.metadata?.id === BlockType.STARTER ||
|
||||
block?.metadata?.id === BlockType.CONDITION ||
|
||||
block?.metadata?.id === BlockType.LOOP ||
|
||||
block?.metadata?.id === BlockType.PARALLEL
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { createMockContext } from '@/executor/__test-utils__/executor-mocks'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { LoopManager } from '@/executor/loops/loops'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedLoop, SerializedWorkflow } from '@/serializer/types'
|
||||
import { createMockContext } from './__test-utils__/executor-mocks'
|
||||
import { LoopManager } from './loops'
|
||||
import type { ExecutionContext } from './types'
|
||||
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -40,8 +41,8 @@ describe('LoopManager', () => {
|
||||
{
|
||||
id: 'starter',
|
||||
position: { x: 0, y: 0 },
|
||||
metadata: { id: 'starter', name: 'Start' },
|
||||
config: { tool: 'starter', params: {} },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -49,8 +50,8 @@ describe('LoopManager', () => {
|
||||
{
|
||||
id: 'loop-1',
|
||||
position: { x: 100, y: 0 },
|
||||
metadata: { id: 'loop', name: 'Test Loop' },
|
||||
config: { tool: 'loop', params: {} },
|
||||
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -58,8 +59,8 @@ describe('LoopManager', () => {
|
||||
{
|
||||
id: 'block-1',
|
||||
position: { x: 200, y: 0 },
|
||||
metadata: { id: 'function', name: 'Block 1' },
|
||||
config: { tool: 'function', params: {} },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Block 1' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -67,8 +68,8 @@ describe('LoopManager', () => {
|
||||
{
|
||||
id: 'block-2',
|
||||
position: { x: 300, y: 0 },
|
||||
metadata: { id: 'function', name: 'Block 2' },
|
||||
config: { tool: 'function', params: {} },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Block 2' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -76,8 +77,8 @@ describe('LoopManager', () => {
|
||||
{
|
||||
id: 'after-loop',
|
||||
position: { x: 400, y: 0 },
|
||||
metadata: { id: 'function', name: 'After Loop' },
|
||||
config: { tool: 'function', params: {} },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'After Loop' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -409,7 +410,7 @@ describe('LoopManager', () => {
|
||||
test('should handle router blocks with selected paths', async () => {
|
||||
// Create a workflow with a router block inside the loop
|
||||
const workflow = createWorkflowWithLoop(createBasicLoop())
|
||||
workflow.blocks[2].metadata!.id = 'router' // Make block-1 a router
|
||||
workflow.blocks[2].metadata!.id = BlockType.ROUTER // Make block-1 a router
|
||||
workflow.connections = [
|
||||
{ source: 'starter', target: 'loop-1' },
|
||||
{ source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' },
|
||||
@@ -435,7 +436,7 @@ describe('LoopManager', () => {
|
||||
test('should handle condition blocks with selected paths', async () => {
|
||||
// Create a workflow with a condition block inside the loop
|
||||
const workflow = createWorkflowWithLoop(createBasicLoop())
|
||||
workflow.blocks[2].metadata!.id = 'condition' // Make block-1 a condition
|
||||
workflow.blocks[2].metadata!.id = BlockType.CONDITION // Make block-1 a condition
|
||||
workflow.connections = [
|
||||
{ source: 'starter', target: 'loop-1' },
|
||||
{ source: 'loop-1', target: 'block-1', sourceHandle: 'loop-start-source' },
|
||||
@@ -496,8 +497,8 @@ describe('LoopManager', () => {
|
||||
workflow.blocks.push({
|
||||
id: 'error-handler',
|
||||
position: { x: 350, y: 100 },
|
||||
metadata: { id: 'function', name: 'Error Handler' },
|
||||
config: { tool: 'function', params: {} },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Error Handler' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedConnection, SerializedLoop } from '@/serializer/types'
|
||||
import type { ExecutionContext } from './types'
|
||||
|
||||
const logger = createLogger('LoopManager')
|
||||
|
||||
@@ -366,13 +367,13 @@ export class LoopManager {
|
||||
const outgoing = blockOutgoingConnections.get(currentBlockId) || []
|
||||
|
||||
// Handle routing blocks specially
|
||||
if (block.metadata?.id === 'router') {
|
||||
if (block.metadata?.id === BlockType.ROUTER) {
|
||||
// For router blocks, only follow the selected path
|
||||
const selectedTarget = context.decisions.router.get(currentBlockId)
|
||||
if (selectedTarget && nodeIds.includes(selectedTarget)) {
|
||||
toVisit.push(selectedTarget)
|
||||
}
|
||||
} else if (block.metadata?.id === 'condition') {
|
||||
} else if (block.metadata?.id === BlockType.CONDITION) {
|
||||
// For condition blocks, only follow the selected condition path
|
||||
const selectedConditionId = context.decisions.condition.get(currentBlockId)
|
||||
if (selectedConditionId) {
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { createParallelExecutionState } from '@/executor/__test-utils__/executor-mocks'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { ParallelManager } from '@/executor/parallels/parallels'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
import { createParallelExecutionState } from './__test-utils__/executor-mocks'
|
||||
import { ParallelManager } from './parallels'
|
||||
import type { ExecutionContext } from './types'
|
||||
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -154,7 +155,7 @@ describe('ParallelManager', () => {
|
||||
const block = {
|
||||
id: 'func-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -185,7 +186,7 @@ describe('ParallelManager', () => {
|
||||
const block = {
|
||||
id: 'func-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedParallel, SerializedWorkflow } from '@/serializer/types'
|
||||
import type { ExecutionContext, NormalizedBlockOutput } from './types'
|
||||
|
||||
const logger = createLogger('ParallelManager')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import { Routing } from '@/executor/routing/routing'
|
||||
import type { BlockState, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
import { PathTracker } from './path'
|
||||
import type { BlockState, ExecutionContext } from './types'
|
||||
|
||||
describe('PathTracker', () => {
|
||||
let pathTracker: PathTracker
|
||||
@@ -32,27 +34,27 @@ describe('PathTracker', () => {
|
||||
},
|
||||
{
|
||||
id: 'router1',
|
||||
metadata: { id: 'router' },
|
||||
metadata: { id: BlockType.ROUTER },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'router', params: {} },
|
||||
config: { tool: BlockType.ROUTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'condition1',
|
||||
metadata: { id: 'condition' },
|
||||
metadata: { id: BlockType.CONDITION },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'condition', params: {} },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'loop1',
|
||||
metadata: { id: 'loop' },
|
||||
metadata: { id: BlockType.LOOP },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'loop', params: {} },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -75,6 +77,7 @@ describe('PathTracker', () => {
|
||||
loopType: 'for',
|
||||
},
|
||||
},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
mockContext = {
|
||||
@@ -417,36 +420,36 @@ describe('PathTracker', () => {
|
||||
blocks: [
|
||||
{
|
||||
id: 'router1',
|
||||
metadata: { id: 'router', name: 'Router' },
|
||||
metadata: { id: BlockType.ROUTER, name: 'Router' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'router', params: {} },
|
||||
config: { tool: BlockType.ROUTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'api1',
|
||||
metadata: { id: 'api', name: 'API 1' },
|
||||
metadata: { id: BlockType.API, name: 'API 1' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'api', params: {} },
|
||||
config: { tool: BlockType.API, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'api2',
|
||||
metadata: { id: 'api', name: 'API 2' },
|
||||
metadata: { id: BlockType.API, name: 'API 2' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'api', params: {} },
|
||||
config: { tool: BlockType.API, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent1',
|
||||
metadata: { id: 'agent', name: 'Agent' },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'agent', params: {} },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -485,7 +488,7 @@ describe('PathTracker', () => {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'api1',
|
||||
blockType: 'api',
|
||||
blockType: BlockType.API,
|
||||
blockTitle: 'API 1',
|
||||
},
|
||||
},
|
||||
@@ -508,9 +511,9 @@ describe('PathTracker', () => {
|
||||
// Add another level to test deep activation
|
||||
mockWorkflow.blocks.push({
|
||||
id: 'finalStep',
|
||||
metadata: { id: 'api', name: 'Final Step' },
|
||||
metadata: { id: BlockType.API, name: 'Final Step' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'api', params: {} },
|
||||
config: { tool: BlockType.API, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -524,7 +527,7 @@ describe('PathTracker', () => {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'api1',
|
||||
blockType: 'api',
|
||||
blockType: BlockType.API,
|
||||
blockTitle: 'API 1',
|
||||
},
|
||||
},
|
||||
@@ -552,7 +555,7 @@ describe('PathTracker', () => {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'api1',
|
||||
blockType: 'api',
|
||||
blockType: BlockType.API,
|
||||
blockTitle: 'API 1',
|
||||
},
|
||||
},
|
||||
@@ -586,7 +589,7 @@ describe('PathTracker', () => {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'api1',
|
||||
blockType: 'api',
|
||||
blockType: BlockType.API,
|
||||
blockTitle: 'API 1',
|
||||
},
|
||||
},
|
||||
@@ -602,4 +605,158 @@ describe('PathTracker', () => {
|
||||
expect(mockContext.activeExecutionPath.has('agent1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoutingStrategy integration', () => {
|
||||
beforeEach(() => {
|
||||
// Add more block types to test the new routing strategy
|
||||
mockWorkflow.blocks.push(
|
||||
{
|
||||
id: 'parallel1',
|
||||
metadata: { id: BlockType.PARALLEL },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function1',
|
||||
metadata: { id: BlockType.FUNCTION },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent1',
|
||||
metadata: { id: BlockType.AGENT },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
)
|
||||
|
||||
mockWorkflow.connections.push(
|
||||
{ source: 'parallel1', target: 'function1', sourceHandle: 'parallel-start-source' },
|
||||
{ source: 'parallel1', target: 'agent1', sourceHandle: 'parallel-end-source' }
|
||||
)
|
||||
|
||||
mockWorkflow.parallels = {
|
||||
parallel1: {
|
||||
id: 'parallel1',
|
||||
nodes: ['function1'],
|
||||
distribution: ['item1', 'item2'],
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(mockWorkflow)
|
||||
})
|
||||
|
||||
it('should correctly categorize different block types', () => {
|
||||
// Test that our refactored code properly uses RoutingStrategy
|
||||
expect(Routing.getCategory(BlockType.ROUTER)).toBe('routing')
|
||||
expect(Routing.getCategory(BlockType.CONDITION)).toBe('routing')
|
||||
expect(Routing.getCategory(BlockType.PARALLEL)).toBe('flow-control')
|
||||
expect(Routing.getCategory(BlockType.LOOP)).toBe('flow-control')
|
||||
expect(Routing.getCategory(BlockType.FUNCTION)).toBe('regular')
|
||||
expect(Routing.getCategory(BlockType.AGENT)).toBe('regular')
|
||||
})
|
||||
|
||||
it('should handle flow control blocks correctly in path checking', () => {
|
||||
// Test that parallel blocks are handled correctly
|
||||
mockContext.executedBlocks.add('parallel1')
|
||||
mockContext.activeExecutionPath.add('parallel1')
|
||||
|
||||
// Function1 should be reachable from parallel1 via parallel-start-source
|
||||
expect(pathTracker.isInActivePath('function1', mockContext)).toBe(true)
|
||||
|
||||
// Agent1 should be reachable from parallel1 via parallel-end-source
|
||||
expect(pathTracker.isInActivePath('agent1', mockContext)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle router selecting routing blocks correctly', () => {
|
||||
// Test the refactored logic where router selects another routing block
|
||||
const blockState: BlockState = {
|
||||
output: { selectedPath: { blockId: 'condition1' } },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
}
|
||||
mockContext.blockStates.set('router1', blockState)
|
||||
|
||||
pathTracker.updateExecutionPaths(['router1'], mockContext)
|
||||
|
||||
// Condition1 should be activated but not its downstream paths
|
||||
// (since routing blocks make their own decisions)
|
||||
expect(mockContext.activeExecutionPath.has('condition1')).toBe(true)
|
||||
expect(mockContext.decisions.router.get('router1')).toBe('condition1')
|
||||
})
|
||||
|
||||
it('should handle router selecting flow control blocks correctly', () => {
|
||||
// Test the refactored logic where router selects a flow control block
|
||||
const blockState: BlockState = {
|
||||
output: { selectedPath: { blockId: 'parallel1' } },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
}
|
||||
mockContext.blockStates.set('router1', blockState)
|
||||
|
||||
pathTracker.updateExecutionPaths(['router1'], mockContext)
|
||||
|
||||
// Parallel1 should be activated but not its downstream paths
|
||||
// (since flow control blocks don't activate downstream automatically)
|
||||
expect(mockContext.activeExecutionPath.has('parallel1')).toBe(true)
|
||||
expect(mockContext.decisions.router.get('router1')).toBe('parallel1')
|
||||
// Children should NOT be activated automatically
|
||||
expect(mockContext.activeExecutionPath.has('function1')).toBe(false)
|
||||
expect(mockContext.activeExecutionPath.has('agent1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle router selecting regular blocks correctly', () => {
|
||||
// Test that regular blocks still activate downstream paths
|
||||
const blockState: BlockState = {
|
||||
output: { selectedPath: { blockId: 'function1' } },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
}
|
||||
mockContext.blockStates.set('router1', blockState)
|
||||
|
||||
pathTracker.updateExecutionPaths(['router1'], mockContext)
|
||||
|
||||
// Function1 should be activated and can activate downstream paths
|
||||
expect(mockContext.activeExecutionPath.has('function1')).toBe(true)
|
||||
expect(mockContext.decisions.router.get('router1')).toBe('function1')
|
||||
})
|
||||
|
||||
it('should use category-based logic for updatePathForBlock', () => {
|
||||
// Test that the refactored switch statement works correctly
|
||||
|
||||
// Test routing block (condition)
|
||||
const conditionState: BlockState = {
|
||||
output: { selectedConditionId: 'if' },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
}
|
||||
mockContext.blockStates.set('condition1', conditionState)
|
||||
pathTracker.updateExecutionPaths(['condition1'], mockContext)
|
||||
expect(mockContext.decisions.condition.get('condition1')).toBe('if')
|
||||
|
||||
// Test flow control block (loop)
|
||||
pathTracker.updateExecutionPaths(['loop1'], mockContext)
|
||||
expect(mockContext.activeExecutionPath.has('block1')).toBe(true) // loop-start-source
|
||||
|
||||
// Test regular block
|
||||
const functionState: BlockState = {
|
||||
output: { result: 'success' },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
}
|
||||
mockContext.blockStates.set('function1', functionState)
|
||||
mockContext.executedBlocks.add('function1')
|
||||
pathTracker.updateExecutionPaths(['function1'], mockContext)
|
||||
// Should activate downstream connections (handled by regular block logic)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { Routing } from '@/executor/routing/routing'
|
||||
import type { BlockState, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedConnection, SerializedWorkflow } from '@/serializer/types'
|
||||
import type { BlockState, ExecutionContext } from './types'
|
||||
|
||||
const logger = createLogger('PathTracker')
|
||||
|
||||
@@ -79,14 +81,15 @@ export class PathTracker {
|
||||
const sourceBlock = this.getBlock(connection.source)
|
||||
if (!sourceBlock) return false
|
||||
|
||||
const blockType = sourceBlock.metadata?.id
|
||||
const blockType = sourceBlock.metadata?.id || ''
|
||||
const category = Routing.getCategory(blockType)
|
||||
|
||||
// Use strategy pattern for different block types
|
||||
switch (blockType) {
|
||||
case 'router':
|
||||
return this.isRouterConnectionActive(connection, context)
|
||||
case 'condition':
|
||||
return this.isConditionConnectionActive(connection, context)
|
||||
// Use routing strategy to determine connection checking method
|
||||
switch (category) {
|
||||
case 'routing':
|
||||
return blockType === BlockType.ROUTER
|
||||
? this.isRouterConnectionActive(connection, context)
|
||||
: this.isConditionConnectionActive(connection, context)
|
||||
default:
|
||||
return this.isRegularConnectionActive(connection, context)
|
||||
}
|
||||
@@ -137,17 +140,24 @@ export class PathTracker {
|
||||
* Update paths for a specific block based on its type
|
||||
*/
|
||||
private updatePathForBlock(block: SerializedBlock, context: ExecutionContext): void {
|
||||
const blockType = block.metadata?.id
|
||||
const blockType = block.metadata?.id || ''
|
||||
const category = Routing.getCategory(blockType)
|
||||
|
||||
switch (blockType) {
|
||||
case 'router':
|
||||
this.updateRouterPaths(block, context)
|
||||
switch (category) {
|
||||
case 'routing':
|
||||
if (blockType === BlockType.ROUTER) {
|
||||
this.updateRouterPaths(block, context)
|
||||
} else {
|
||||
this.updateConditionPaths(block, context)
|
||||
}
|
||||
break
|
||||
case 'condition':
|
||||
this.updateConditionPaths(block, context)
|
||||
break
|
||||
case 'loop':
|
||||
this.updateLoopPaths(block, context)
|
||||
case 'flow-control':
|
||||
if (blockType === BlockType.LOOP) {
|
||||
this.updateLoopPaths(block, context)
|
||||
} else {
|
||||
// For parallel blocks, they're handled by their own handler
|
||||
this.updateRegularBlockPaths(block, context)
|
||||
}
|
||||
break
|
||||
default:
|
||||
this.updateRegularBlockPaths(block, context)
|
||||
@@ -166,23 +176,43 @@ export class PathTracker {
|
||||
context.decisions.router.set(block.id, selectedPath)
|
||||
context.activeExecutionPath.add(selectedPath)
|
||||
|
||||
this.activateDownstreamPaths(selectedPath, context)
|
||||
// Check if the selected target should activate downstream paths
|
||||
const selectedBlock = this.getBlock(selectedPath)
|
||||
const selectedBlockType = selectedBlock?.metadata?.id || ''
|
||||
const selectedCategory = Routing.getCategory(selectedBlockType)
|
||||
|
||||
// Only activate downstream paths for regular blocks
|
||||
// Routing blocks make their own routing decisions when they execute
|
||||
// Flow control blocks manage their own path activation
|
||||
if (selectedCategory === 'regular') {
|
||||
this.activateDownstreamPathsSelectively(selectedPath, context)
|
||||
}
|
||||
|
||||
logger.info(`Router ${block.id} selected path: ${selectedPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively activate downstream paths from a block
|
||||
* Selectively activate downstream paths, respecting block routing behavior
|
||||
* This prevents flow control blocks from being activated when they should be controlled by routing
|
||||
*/
|
||||
private activateDownstreamPaths(blockId: string, context: ExecutionContext): void {
|
||||
private activateDownstreamPathsSelectively(blockId: string, context: ExecutionContext): void {
|
||||
const outgoingConnections = this.getOutgoingConnections(blockId)
|
||||
|
||||
for (const conn of outgoingConnections) {
|
||||
if (!context.activeExecutionPath.has(conn.target)) {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
const targetBlock = this.getBlock(conn.target)
|
||||
const targetBlockType = targetBlock?.metadata?.id
|
||||
|
||||
this.activateDownstreamPaths(conn.target, context)
|
||||
// Use routing strategy to determine if this connection should be activated
|
||||
if (!Routing.shouldSkipConnection(conn.sourceHandle, targetBlockType || '')) {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
|
||||
// Recursively activate downstream paths if the target block should activate downstream
|
||||
if (Routing.shouldActivateDownstream(targetBlockType || '')) {
|
||||
this.activateDownstreamPathsSelectively(conn.target, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,6 +268,14 @@ export class PathTracker {
|
||||
|
||||
for (const conn of outgoingConnections) {
|
||||
if (this.shouldActivateConnection(conn, hasError, isPartOfLoop, blockLoops, context)) {
|
||||
const targetBlock = this.getBlock(conn.target)
|
||||
const targetBlockType = targetBlock?.metadata?.id
|
||||
|
||||
// Use routing strategy to determine if this connection should be activated
|
||||
if (Routing.shouldSkipConnection(conn.sourceHandle, targetBlockType || '')) {
|
||||
continue
|
||||
}
|
||||
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { InputResolver } from '@/executor/resolver/resolver'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { InputResolver } from './resolver'
|
||||
import type { ExecutionContext } from './types'
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
@@ -27,36 +28,36 @@ describe('InputResolver', () => {
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-block',
|
||||
metadata: { id: 'starter', name: 'Start' },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 100, y: 100 },
|
||||
config: { tool: 'starter', params: {} },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-block',
|
||||
metadata: { id: 'function', name: 'Function' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function' },
|
||||
position: { x: 300, y: 100 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'condition-block',
|
||||
metadata: { id: 'condition', name: 'Condition' },
|
||||
metadata: { id: BlockType.CONDITION, name: 'Condition' },
|
||||
position: { x: 500, y: 100 },
|
||||
config: { tool: 'condition', params: {} },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'api-block',
|
||||
metadata: { id: 'api', name: 'API' },
|
||||
metadata: { id: BlockType.API, name: 'API' },
|
||||
position: { x: 700, y: 100 },
|
||||
config: { tool: 'api', params: {} },
|
||||
config: { tool: BlockType.API, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -429,7 +430,7 @@ describe('InputResolver', () => {
|
||||
it('should resolve environment variables in API key contexts', () => {
|
||||
const block: SerializedBlock = {
|
||||
id: 'test-block',
|
||||
metadata: { id: 'api', name: 'Test API Block' }, // API block type
|
||||
metadata: { id: BlockType.API, name: 'Test API Block' }, // API block type
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'api',
|
||||
@@ -626,10 +627,10 @@ describe('InputResolver', () => {
|
||||
it('should handle code input for function blocks', () => {
|
||||
const block: SerializedBlock = {
|
||||
id: 'code-block',
|
||||
metadata: { id: 'function', name: 'Code Block' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Code Block' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'const name = "<variable.stringVar>";\nconst num = <variable.numberVar>;\nreturn { name, num };',
|
||||
},
|
||||
@@ -652,7 +653,7 @@ describe('InputResolver', () => {
|
||||
it('should handle body input for API blocks', () => {
|
||||
const block: SerializedBlock = {
|
||||
id: 'api-block',
|
||||
metadata: { id: 'api', name: 'API Block' },
|
||||
metadata: { id: BlockType.API, name: 'API Block' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'api',
|
||||
@@ -679,7 +680,7 @@ describe('InputResolver', () => {
|
||||
it('should handle conditions parameter for condition blocks', () => {
|
||||
const block: SerializedBlock = {
|
||||
id: 'condition-block',
|
||||
metadata: { id: 'condition', name: 'Condition Block' },
|
||||
metadata: { id: BlockType.CONDITION, name: 'Condition Block' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'condition',
|
||||
@@ -734,10 +735,10 @@ describe('InputResolver', () => {
|
||||
const loopBlock: SerializedBlock = {
|
||||
id: 'loop-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'loop', params: {} },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'loop', name: 'Test Loop' },
|
||||
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -745,14 +746,14 @@ describe('InputResolver', () => {
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
item: '<loop.currentItem>', // Direct reference, not wrapped in quotes
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Process Item' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Process Item' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -796,10 +797,10 @@ describe('InputResolver', () => {
|
||||
const loopBlock: SerializedBlock = {
|
||||
id: 'loop-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'loop', params: {} },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'loop', name: 'Test Loop' },
|
||||
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -807,14 +808,14 @@ describe('InputResolver', () => {
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
index: '<loop.index>', // Direct reference, not wrapped in quotes
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Process Index' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Process Index' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -857,10 +858,10 @@ describe('InputResolver', () => {
|
||||
const loopBlock: SerializedBlock = {
|
||||
id: 'loop-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'loop', params: {} },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'loop', name: 'Test Loop' },
|
||||
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -868,14 +869,14 @@ describe('InputResolver', () => {
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
allItems: '<loop.items>', // Direct reference to all items
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Process All Items' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Process All Items' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -924,10 +925,10 @@ describe('InputResolver', () => {
|
||||
const loopBlock: SerializedBlock = {
|
||||
id: 'loop-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'loop', params: {} },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'loop', name: 'Test Loop' },
|
||||
metadata: { id: BlockType.LOOP, name: 'Test Loop' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -935,14 +936,14 @@ describe('InputResolver', () => {
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
allItems: '<loop.items>', // Direct reference to all items
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Process All Items' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Process All Items' },
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
@@ -997,19 +998,19 @@ describe('InputResolver', () => {
|
||||
{
|
||||
id: 'parallel-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'parallel', params: {} },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'parallel', name: 'Parallel 1' },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'function', params: { code: '<parallel.currentItem>' } },
|
||||
config: { tool: BlockType.FUNCTION, params: { code: '<parallel.currentItem>' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Function 1' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
@@ -1053,28 +1054,28 @@ describe('InputResolver', () => {
|
||||
{
|
||||
id: 'parallel-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'parallel', params: {} },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'parallel', name: 'Parallel 1' },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'parallel-2',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'parallel', params: {} },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'parallel', name: 'Parallel 2' },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'function', params: { code: '<Parallel1.results>' } },
|
||||
config: { tool: BlockType.FUNCTION, params: { code: '<Parallel1.results>' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Function 1' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
@@ -1156,19 +1157,19 @@ describe('InputResolver', () => {
|
||||
{
|
||||
id: 'parallel-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'parallel', params: {} },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'parallel', name: 'Parallel 1' },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'function', params: { code: '<parallel-1.results>' } },
|
||||
config: { tool: BlockType.FUNCTION, params: { code: '<parallel-1.results>' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
metadata: { id: 'function', name: 'Function 1' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
@@ -1244,36 +1245,36 @@ describe('InputResolver', () => {
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
metadata: { id: 'starter', name: 'Start' },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'starter', params: {} },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
metadata: { id: 'agent', name: 'Agent Block' },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent Block' },
|
||||
position: { x: 100, y: 100 },
|
||||
config: { tool: 'agent', params: {} },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
metadata: { id: 'function', name: 'Function Block' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function Block' },
|
||||
position: { x: 200, y: 200 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'isolated-block',
|
||||
metadata: { id: 'agent', name: 'Isolated Block' },
|
||||
metadata: { id: BlockType.AGENT, name: 'Isolated Block' },
|
||||
position: { x: 300, y: 300 },
|
||||
config: { tool: 'agent', params: {} },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1301,7 +1302,7 @@ describe('InputResolver', () => {
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = workflowWithConnections.blocks.find(
|
||||
(b) => b.metadata?.id === 'starter'
|
||||
(b) => b.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
accessibleBlocks.add(starterBlock.id)
|
||||
@@ -1323,7 +1324,7 @@ describe('InputResolver', () => {
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = workflowWithConnections.blocks.find(
|
||||
(b) => b.metadata?.id === 'starter'
|
||||
(b) => b.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
accessibleBlocks.add(starterBlock.id)
|
||||
@@ -1370,7 +1371,7 @@ describe('InputResolver', () => {
|
||||
const testBlock: SerializedBlock = {
|
||||
...functionBlock,
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'return <agent-1.content>', // function-1 can reference agent-1 (connected)
|
||||
},
|
||||
@@ -1385,9 +1386,9 @@ describe('InputResolver', () => {
|
||||
// Create a new block that is added to the workflow but not connected to isolated-block
|
||||
workflowWithConnections.blocks.push({
|
||||
id: 'test-block',
|
||||
metadata: { id: 'function', name: 'Test Block' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Test Block' },
|
||||
position: { x: 500, y: 500 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1404,7 +1405,9 @@ describe('InputResolver', () => {
|
||||
}
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = workflowWithConnections.blocks.find((b) => b.metadata?.id === 'starter')
|
||||
const starterBlock = workflowWithConnections.blocks.find(
|
||||
(b) => b.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
testBlockAccessible.add(starterBlock.id)
|
||||
}
|
||||
@@ -1412,10 +1415,10 @@ describe('InputResolver', () => {
|
||||
|
||||
const testBlock: SerializedBlock = {
|
||||
id: 'test-block',
|
||||
metadata: { id: 'function', name: 'Test Block' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Test Block' },
|
||||
position: { x: 500, y: 500 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'return <isolated-block.content>', // test-block cannot reference isolated-block (not connected)
|
||||
},
|
||||
@@ -1435,7 +1438,7 @@ describe('InputResolver', () => {
|
||||
const testBlock: SerializedBlock = {
|
||||
...functionBlock,
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'return <start.input>', // Any block can reference start
|
||||
},
|
||||
@@ -1450,9 +1453,9 @@ describe('InputResolver', () => {
|
||||
// Create a test block in the workflow first
|
||||
workflowWithConnections.blocks.push({
|
||||
id: 'test-block-2',
|
||||
metadata: { id: 'function', name: 'Test Block 2' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Test Block 2' },
|
||||
position: { x: 600, y: 600 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1469,7 +1472,9 @@ describe('InputResolver', () => {
|
||||
}
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = workflowWithConnections.blocks.find((b) => b.metadata?.id === 'starter')
|
||||
const starterBlock = workflowWithConnections.blocks.find(
|
||||
(b) => b.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
testBlock2Accessible.add(starterBlock.id)
|
||||
}
|
||||
@@ -1477,10 +1482,10 @@ describe('InputResolver', () => {
|
||||
|
||||
const testBlock: SerializedBlock = {
|
||||
id: 'test-block-2',
|
||||
metadata: { id: 'function', name: 'Test Block 2' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Test Block 2' },
|
||||
position: { x: 600, y: 600 },
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'return <nonexistent.value>',
|
||||
},
|
||||
@@ -1500,7 +1505,7 @@ describe('InputResolver', () => {
|
||||
const testBlock: SerializedBlock = {
|
||||
...functionBlock,
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
nameRef: '<Agent Block.content>', // Reference by actual name
|
||||
normalizedRef: '<agentblock.content>', // Reference by normalized name
|
||||
@@ -1523,9 +1528,9 @@ describe('InputResolver', () => {
|
||||
...workflowWithConnections.blocks,
|
||||
{
|
||||
id: 'response-1',
|
||||
metadata: { id: 'response', name: 'Response Block' },
|
||||
metadata: { id: BlockType.RESPONSE, name: 'Response Block' },
|
||||
position: { x: 400, y: 400 },
|
||||
config: { tool: 'response', params: {} },
|
||||
config: { tool: BlockType.RESPONSE, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1555,7 +1560,9 @@ describe('InputResolver', () => {
|
||||
}
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = extendedWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
|
||||
const starterBlock = extendedWorkflow.blocks.find(
|
||||
(b) => b.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
accessibleBlocks.add(starterBlock.id)
|
||||
}
|
||||
@@ -1572,7 +1579,9 @@ describe('InputResolver', () => {
|
||||
}
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = extendedWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
|
||||
const starterBlock = extendedWorkflow.blocks.find(
|
||||
(b) => b.metadata?.id === BlockType.STARTER
|
||||
)
|
||||
if (starterBlock) {
|
||||
accessibleBlocks.add(starterBlock.id)
|
||||
}
|
||||
@@ -1590,7 +1599,7 @@ describe('InputResolver', () => {
|
||||
const testBlock: SerializedBlock = {
|
||||
...responseBlock,
|
||||
config: {
|
||||
tool: 'response',
|
||||
tool: BlockType.RESPONSE,
|
||||
params: {
|
||||
canReferenceFunction: '<function-1.result>', // Can reference directly connected function-1
|
||||
cannotReferenceAgent: '<agent-1.content>', // Cannot reference agent-1 (not directly connected)
|
||||
@@ -1614,7 +1623,7 @@ describe('InputResolver', () => {
|
||||
expect(() => {
|
||||
const block1 = {
|
||||
...testBlock,
|
||||
config: { tool: 'response', params: { test: '<function-1.result>' } },
|
||||
config: { tool: BlockType.RESPONSE, params: { test: '<function-1.result>' } },
|
||||
}
|
||||
extendedResolver.resolveInputs(block1, extendedContext)
|
||||
}).not.toThrow()
|
||||
@@ -1624,9 +1633,9 @@ describe('InputResolver', () => {
|
||||
// Add the response block to the workflow so it can be validated properly
|
||||
extendedWorkflow.blocks.push({
|
||||
id: 'test-response-block',
|
||||
metadata: { id: 'response', name: 'Test Response Block' },
|
||||
metadata: { id: BlockType.RESPONSE, name: 'Test Response Block' },
|
||||
position: { x: 500, y: 500 },
|
||||
config: { tool: 'response', params: {} },
|
||||
config: { tool: BlockType.RESPONSE, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1635,9 +1644,9 @@ describe('InputResolver', () => {
|
||||
|
||||
const block2 = {
|
||||
id: 'test-response-block',
|
||||
metadata: { id: 'response', name: 'Test Response Block' },
|
||||
metadata: { id: BlockType.RESPONSE, name: 'Test Response Block' },
|
||||
position: { x: 500, y: 500 },
|
||||
config: { tool: 'response', params: { test: '<agent-1.content>' } },
|
||||
config: { tool: BlockType.RESPONSE, params: { test: '<agent-1.content>' } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1652,16 +1661,16 @@ describe('InputResolver', () => {
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter-1',
|
||||
metadata: { id: 'starter', name: 'Start' },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: 'starter', params: {} },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'loop-1',
|
||||
metadata: { id: 'loop', name: 'Loop' },
|
||||
metadata: { id: BlockType.LOOP, name: 'Loop' },
|
||||
position: { x: 100, y: 100 },
|
||||
config: { tool: '', params: {} },
|
||||
inputs: {},
|
||||
@@ -1670,18 +1679,18 @@ describe('InputResolver', () => {
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
metadata: { id: 'function', name: 'Function 1' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
position: { x: 200, y: 200 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-2',
|
||||
metadata: { id: 'function', name: 'Function 2' },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
|
||||
position: { x: 300, y: 300 },
|
||||
config: { tool: 'function', params: {} },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
@@ -1711,7 +1720,7 @@ describe('InputResolver', () => {
|
||||
}
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
|
||||
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === BlockType.STARTER)
|
||||
if (starterBlock) {
|
||||
accessibleBlocks.add(starterBlock.id)
|
||||
}
|
||||
@@ -1735,7 +1744,7 @@ describe('InputResolver', () => {
|
||||
}
|
||||
})
|
||||
// Always allow starter block access
|
||||
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === 'starter')
|
||||
const starterBlock = loopWorkflow.blocks.find((b) => b.metadata?.id === BlockType.STARTER)
|
||||
if (starterBlock) {
|
||||
accessibleBlocks.add(starterBlock.id)
|
||||
}
|
||||
@@ -1746,7 +1755,7 @@ describe('InputResolver', () => {
|
||||
const testBlock: SerializedBlock = {
|
||||
...loopWorkflow.blocks[2],
|
||||
config: {
|
||||
tool: 'function',
|
||||
tool: BlockType.FUNCTION,
|
||||
params: {
|
||||
code: 'return <function-2.result>', // function-1 can reference function-2 (same loop)
|
||||
},
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BlockPathCalculator } from '@/lib/block-path-calculator'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { VariableManager } from '@/lib/variables/variable-manager'
|
||||
import type { LoopManager } from '@/executor/loops/loops'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import type { LoopManager } from './loops'
|
||||
import type { ExecutionContext } from './types'
|
||||
|
||||
const logger = createLogger('InputResolver')
|
||||
|
||||
145
apps/sim/executor/routing/routing.test.ts
Normal file
145
apps/sim/executor/routing/routing.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { BlockCategory, Routing } from '@/executor/routing/routing'
|
||||
|
||||
describe('Routing', () => {
|
||||
describe('getCategory', () => {
|
||||
it.concurrent('should categorize flow control blocks correctly', () => {
|
||||
expect(Routing.getCategory(BlockType.PARALLEL)).toBe(BlockCategory.FLOW_CONTROL)
|
||||
expect(Routing.getCategory(BlockType.LOOP)).toBe(BlockCategory.FLOW_CONTROL)
|
||||
})
|
||||
|
||||
it.concurrent('should categorize routing blocks correctly', () => {
|
||||
expect(Routing.getCategory(BlockType.ROUTER)).toBe(BlockCategory.ROUTING_BLOCK)
|
||||
expect(Routing.getCategory(BlockType.CONDITION)).toBe(BlockCategory.ROUTING_BLOCK)
|
||||
})
|
||||
|
||||
it.concurrent('should categorize regular blocks correctly', () => {
|
||||
expect(Routing.getCategory(BlockType.FUNCTION)).toBe(BlockCategory.REGULAR_BLOCK)
|
||||
expect(Routing.getCategory(BlockType.AGENT)).toBe(BlockCategory.REGULAR_BLOCK)
|
||||
expect(Routing.getCategory(BlockType.API)).toBe(BlockCategory.REGULAR_BLOCK)
|
||||
expect(Routing.getCategory(BlockType.STARTER)).toBe(BlockCategory.REGULAR_BLOCK)
|
||||
})
|
||||
|
||||
it.concurrent('should default to regular block for unknown types', () => {
|
||||
expect(Routing.getCategory('unknown')).toBe(BlockCategory.REGULAR_BLOCK)
|
||||
expect(Routing.getCategory('')).toBe(BlockCategory.REGULAR_BLOCK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldActivateDownstream', () => {
|
||||
it.concurrent('should return true for routing blocks', () => {
|
||||
expect(Routing.shouldActivateDownstream(BlockType.ROUTER)).toBe(true)
|
||||
expect(Routing.shouldActivateDownstream(BlockType.CONDITION)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for flow control blocks', () => {
|
||||
expect(Routing.shouldActivateDownstream(BlockType.PARALLEL)).toBe(false)
|
||||
expect(Routing.shouldActivateDownstream(BlockType.LOOP)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for regular blocks', () => {
|
||||
expect(Routing.shouldActivateDownstream(BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldActivateDownstream(BlockType.AGENT)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty/undefined block types', () => {
|
||||
expect(Routing.shouldActivateDownstream('')).toBe(true)
|
||||
expect(Routing.shouldActivateDownstream(undefined as any)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requiresActivePathCheck', () => {
|
||||
it.concurrent('should return true for flow control blocks', () => {
|
||||
expect(Routing.requiresActivePathCheck(BlockType.PARALLEL)).toBe(true)
|
||||
expect(Routing.requiresActivePathCheck(BlockType.LOOP)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for routing blocks', () => {
|
||||
expect(Routing.requiresActivePathCheck(BlockType.ROUTER)).toBe(false)
|
||||
expect(Routing.requiresActivePathCheck(BlockType.CONDITION)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for regular blocks', () => {
|
||||
expect(Routing.requiresActivePathCheck(BlockType.FUNCTION)).toBe(false)
|
||||
expect(Routing.requiresActivePathCheck(BlockType.AGENT)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty/undefined block types', () => {
|
||||
expect(Routing.requiresActivePathCheck('')).toBe(false)
|
||||
expect(Routing.requiresActivePathCheck(undefined as any)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSkipInSelectiveActivation', () => {
|
||||
it.concurrent('should return true for flow control blocks', () => {
|
||||
expect(Routing.shouldSkipInSelectiveActivation(BlockType.PARALLEL)).toBe(true)
|
||||
expect(Routing.shouldSkipInSelectiveActivation(BlockType.LOOP)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for routing blocks', () => {
|
||||
expect(Routing.shouldSkipInSelectiveActivation(BlockType.ROUTER)).toBe(false)
|
||||
expect(Routing.shouldSkipInSelectiveActivation(BlockType.CONDITION)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for regular blocks', () => {
|
||||
expect(Routing.shouldSkipInSelectiveActivation(BlockType.FUNCTION)).toBe(false)
|
||||
expect(Routing.shouldSkipInSelectiveActivation(BlockType.AGENT)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSkipConnection', () => {
|
||||
it.concurrent('should allow regular connections to flow control blocks', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('source', BlockType.LOOP)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should skip flow control specific connections', () => {
|
||||
expect(Routing.shouldSkipConnection('parallel-start-source', BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('parallel-end-source', BlockType.AGENT)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('loop-start-source', BlockType.API)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('loop-end-source', BlockType.EVALUATOR)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not skip regular connections to regular blocks', () => {
|
||||
expect(Routing.shouldSkipConnection('source', BlockType.FUNCTION)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('source', BlockType.AGENT)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.API)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should skip condition-specific connections during selective activation', () => {
|
||||
expect(Routing.shouldSkipConnection('condition-test-if', BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('condition-test-else', BlockType.AGENT)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty/undefined types', () => {
|
||||
expect(Routing.shouldSkipConnection('', '')).toBe(false)
|
||||
expect(Routing.shouldSkipConnection(undefined, '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBehavior', () => {
|
||||
it.concurrent('should return correct behavior for each category', () => {
|
||||
const flowControlBehavior = Routing.getBehavior(BlockType.PARALLEL)
|
||||
expect(flowControlBehavior).toEqual({
|
||||
shouldActivateDownstream: false,
|
||||
requiresActivePathCheck: true,
|
||||
skipInSelectiveActivation: true,
|
||||
})
|
||||
|
||||
const routingBehavior = Routing.getBehavior(BlockType.ROUTER)
|
||||
expect(routingBehavior).toEqual({
|
||||
shouldActivateDownstream: true,
|
||||
requiresActivePathCheck: false,
|
||||
skipInSelectiveActivation: false,
|
||||
})
|
||||
|
||||
const regularBehavior = Routing.getBehavior(BlockType.FUNCTION)
|
||||
expect(regularBehavior).toEqual({
|
||||
shouldActivateDownstream: true,
|
||||
requiresActivePathCheck: false,
|
||||
skipInSelectiveActivation: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
105
apps/sim/executor/routing/routing.ts
Normal file
105
apps/sim/executor/routing/routing.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { BlockType } from '@/executor/consts'
|
||||
|
||||
export enum BlockCategory {
|
||||
ROUTING_BLOCK = 'routing', // router, condition - make routing decisions
|
||||
FLOW_CONTROL = 'flow-control', // parallel, loop - control execution flow
|
||||
REGULAR_BLOCK = 'regular', // function, agent, etc. - regular execution
|
||||
}
|
||||
|
||||
export interface RoutingBehavior {
|
||||
shouldActivateDownstream: boolean
|
||||
requiresActivePathCheck: boolean
|
||||
skipInSelectiveActivation: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized routing strategy that defines how different block types
|
||||
* should behave in the execution path system.
|
||||
*/
|
||||
export class Routing {
|
||||
private static readonly BEHAVIOR_MAP: Record<BlockCategory, RoutingBehavior> = {
|
||||
[BlockCategory.ROUTING_BLOCK]: {
|
||||
shouldActivateDownstream: true,
|
||||
requiresActivePathCheck: false,
|
||||
skipInSelectiveActivation: false,
|
||||
},
|
||||
[BlockCategory.FLOW_CONTROL]: {
|
||||
shouldActivateDownstream: false,
|
||||
requiresActivePathCheck: true,
|
||||
skipInSelectiveActivation: true,
|
||||
},
|
||||
[BlockCategory.REGULAR_BLOCK]: {
|
||||
shouldActivateDownstream: true,
|
||||
requiresActivePathCheck: false,
|
||||
skipInSelectiveActivation: false,
|
||||
},
|
||||
}
|
||||
|
||||
private static readonly BLOCK_TYPE_TO_CATEGORY: Record<string, BlockCategory> = {
|
||||
// Flow control blocks
|
||||
[BlockType.PARALLEL]: BlockCategory.FLOW_CONTROL,
|
||||
[BlockType.LOOP]: BlockCategory.FLOW_CONTROL,
|
||||
|
||||
// Routing blocks
|
||||
[BlockType.ROUTER]: BlockCategory.ROUTING_BLOCK,
|
||||
[BlockType.CONDITION]: BlockCategory.ROUTING_BLOCK,
|
||||
|
||||
// Regular blocks (default category)
|
||||
[BlockType.FUNCTION]: BlockCategory.REGULAR_BLOCK,
|
||||
[BlockType.AGENT]: BlockCategory.REGULAR_BLOCK,
|
||||
[BlockType.API]: BlockCategory.REGULAR_BLOCK,
|
||||
[BlockType.EVALUATOR]: BlockCategory.REGULAR_BLOCK,
|
||||
[BlockType.RESPONSE]: BlockCategory.REGULAR_BLOCK,
|
||||
[BlockType.WORKFLOW]: BlockCategory.REGULAR_BLOCK,
|
||||
[BlockType.STARTER]: BlockCategory.REGULAR_BLOCK,
|
||||
}
|
||||
|
||||
static getCategory(blockType: string): BlockCategory {
|
||||
return Routing.BLOCK_TYPE_TO_CATEGORY[blockType] || BlockCategory.REGULAR_BLOCK
|
||||
}
|
||||
|
||||
static getBehavior(blockType: string): RoutingBehavior {
|
||||
const category = Routing.getCategory(blockType)
|
||||
return Routing.BEHAVIOR_MAP[category]
|
||||
}
|
||||
|
||||
static shouldActivateDownstream(blockType: string): boolean {
|
||||
return Routing.getBehavior(blockType).shouldActivateDownstream
|
||||
}
|
||||
|
||||
static requiresActivePathCheck(blockType: string): boolean {
|
||||
return Routing.getBehavior(blockType).requiresActivePathCheck
|
||||
}
|
||||
|
||||
static shouldSkipInSelectiveActivation(blockType: string): boolean {
|
||||
return Routing.getBehavior(blockType).skipInSelectiveActivation
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a connection should be skipped during selective activation
|
||||
*/
|
||||
static shouldSkipConnection(sourceHandle: string | undefined, targetBlockType: string): boolean {
|
||||
// Skip flow control specific connections (internal flow control handles)
|
||||
const flowControlHandles = [
|
||||
'parallel-start-source',
|
||||
'parallel-end-source',
|
||||
'loop-start-source',
|
||||
'loop-end-source',
|
||||
]
|
||||
|
||||
if (flowControlHandles.includes(sourceHandle || '')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip condition-specific connections during selective activation
|
||||
// These should only be activated when the condition makes a specific decision
|
||||
if (sourceHandle?.startsWith('condition-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For regular connections (no special source handle), allow activation of flow control blocks
|
||||
// This enables regular blocks (like agents) to activate parallel/loop blocks
|
||||
// The flow control blocks themselves will handle active path checking
|
||||
return false
|
||||
}
|
||||
}
|
||||
227
apps/sim/executor/tests/executor-layer-validation.test.ts
Normal file
227
apps/sim/executor/tests/executor-layer-validation.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Executor } from '@/executor'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
describe('Full Executor Test', () => {
|
||||
let workflow: SerializedWorkflow
|
||||
let executor: Executor
|
||||
|
||||
beforeEach(() => {
|
||||
workflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
|
||||
position: { x: 0, y: 0 },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
position: { x: 100, y: 0 },
|
||||
metadata: { id: BlockType.ROUTER, name: 'Router 1' },
|
||||
config: {
|
||||
tool: BlockType.ROUTER,
|
||||
params: {
|
||||
prompt: 'if x then function 1\nif y then parallel\n\ninput: x',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
|
||||
position: { x: 200, y: -50 },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
config: { tool: BlockType.FUNCTION, params: { code: "return 'one'" } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
position: { x: 200, y: 50 },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
position: { x: 300, y: -50 },
|
||||
metadata: { id: 'condition', name: 'Condition 1' },
|
||||
config: { tool: 'condition', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '033ea142-3002-4a68-9e12-092b10b8c9c8',
|
||||
position: { x: 400, y: -100 },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
|
||||
config: { tool: BlockType.FUNCTION, params: { code: "return 'two'" } },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
position: { x: 400, y: 0 },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'a91e3a02-b884-4823-8197-30ae498ac94c',
|
||||
position: { x: 300, y: 100 },
|
||||
metadata: { id: 'agent', name: 'Agent 1' },
|
||||
config: { tool: 'agent', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
|
||||
position: { x: 500, y: 0 },
|
||||
metadata: { id: 'agent', name: 'Agent 2' },
|
||||
config: { tool: 'agent', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{
|
||||
source: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
|
||||
target: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
},
|
||||
{
|
||||
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
target: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
|
||||
},
|
||||
{
|
||||
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
target: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
},
|
||||
{
|
||||
source: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
|
||||
target: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
},
|
||||
{
|
||||
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
target: '033ea142-3002-4a68-9e12-092b10b8c9c8',
|
||||
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-if',
|
||||
},
|
||||
{
|
||||
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
target: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-else',
|
||||
},
|
||||
{
|
||||
source: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
target: 'a91e3a02-b884-4823-8197-30ae498ac94c',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
{
|
||||
source: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
target: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'a62902db-fd8d-4851-aa88-acd5e7667497': {
|
||||
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
nodes: ['a91e3a02-b884-4823-8197-30ae498ac94c'],
|
||||
distribution: ['item1', 'item2'],
|
||||
},
|
||||
'037140a8-fda3-44e2-896c-6adea53ea30f': {
|
||||
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
nodes: ['97974a42-cdf4-4810-9caa-b5e339f42ab0'],
|
||||
distribution: ['item1', 'item2'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
executor = new Executor(workflow)
|
||||
})
|
||||
|
||||
it('should test the full executor flow and see what happens', async () => {
|
||||
// Mock the necessary functions to avoid actual API calls
|
||||
const mockInput = {}
|
||||
|
||||
try {
|
||||
// Execute the workflow
|
||||
const result = await executor.execute('test-workflow-id')
|
||||
|
||||
// Check if it's an ExecutionResult (not StreamingExecution)
|
||||
if ('success' in result) {
|
||||
// Check if there are any logs that might indicate what happened
|
||||
if (result.logs) {
|
||||
}
|
||||
|
||||
// The test itself doesn't need to assert anything specific
|
||||
// We just want to see what the executor does
|
||||
expect(result.success).toBeDefined()
|
||||
} else {
|
||||
expect(result).toBeDefined()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Execution error:', error)
|
||||
// Log the error but don't fail the test - we want to see what happens
|
||||
}
|
||||
})
|
||||
|
||||
it('should test the executor getNextExecutionLayer method directly', async () => {
|
||||
// Create a mock context in the exact state after the condition executes
|
||||
const context = (executor as any).createExecutionContext('test-workflow', new Date())
|
||||
|
||||
// Set up the state as it would be after the condition executes
|
||||
context.executedBlocks.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start
|
||||
context.executedBlocks.add('f29a40b7-125a-45a7-a670-af14a1498f94') // Router 1
|
||||
context.executedBlocks.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b') // Function 1
|
||||
context.executedBlocks.add('0494cf56-2520-4e29-98ad-313ea55cf142') // Condition 1
|
||||
context.executedBlocks.add('033ea142-3002-4a68-9e12-092b10b8c9c8') // Function 2
|
||||
|
||||
// Set router decision
|
||||
context.decisions.router.set(
|
||||
'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
'd09b0a90-2c59-4a2c-af15-c30321e36d9b'
|
||||
)
|
||||
|
||||
// Set condition decision to if path (Function 2)
|
||||
context.decisions.condition.set(
|
||||
'0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
'0494cf56-2520-4e29-98ad-313ea55cf142-if'
|
||||
)
|
||||
|
||||
// Set up active execution path as it should be after condition
|
||||
context.activeExecutionPath.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19')
|
||||
context.activeExecutionPath.add('f29a40b7-125a-45a7-a670-af14a1498f94')
|
||||
context.activeExecutionPath.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b')
|
||||
context.activeExecutionPath.add('0494cf56-2520-4e29-98ad-313ea55cf142')
|
||||
context.activeExecutionPath.add('033ea142-3002-4a68-9e12-092b10b8c9c8')
|
||||
|
||||
// Get the next execution layer
|
||||
const nextLayer = (executor as any).getNextExecutionLayer(context)
|
||||
|
||||
// Check if Parallel 2 is in the next execution layer
|
||||
const hasParallel2 = nextLayer.includes('037140a8-fda3-44e2-896c-6adea53ea30f')
|
||||
|
||||
// Check if Agent 2 is in the next execution layer
|
||||
const hasAgent2 = nextLayer.includes('97974a42-cdf4-4810-9caa-b5e339f42ab0')
|
||||
|
||||
// The key test: Parallel 2 should NOT be in the next execution layer
|
||||
expect(nextLayer).not.toContain('037140a8-fda3-44e2-896c-6adea53ea30f')
|
||||
expect(nextLayer).not.toContain('97974a42-cdf4-4810-9caa-b5e339f42ab0')
|
||||
})
|
||||
})
|
||||
307
apps/sim/executor/tests/nested-router-condition.test.ts
Normal file
307
apps/sim/executor/tests/nested-router-condition.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
describe('Nested Routing Fix - Router → Condition → Target', () => {
|
||||
let workflow: SerializedWorkflow
|
||||
let pathTracker: PathTracker
|
||||
let mockContext: ExecutionContext
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a workflow similar to the screenshot: Router → Condition → Function/Parallel
|
||||
workflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'starter',
|
||||
position: { x: 0, y: 0 },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'router-1',
|
||||
position: { x: 100, y: 0 },
|
||||
metadata: { id: BlockType.ROUTER, name: 'Router 1' },
|
||||
config: { tool: BlockType.ROUTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-2',
|
||||
position: { x: 200, y: -100 },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'condition-1',
|
||||
position: { x: 200, y: 100 },
|
||||
metadata: { id: BlockType.CONDITION, name: 'Condition 1' },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-4',
|
||||
position: { x: 350, y: 50 },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 4' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'parallel-block',
|
||||
position: { x: 350, y: 150 },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel Block' },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-inside-parallel',
|
||||
position: { x: 450, y: 150 },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent Inside Parallel' },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'starter', target: 'router-1' },
|
||||
{ source: 'router-1', target: 'function-2' },
|
||||
{ source: 'router-1', target: 'condition-1' },
|
||||
{
|
||||
source: 'condition-1',
|
||||
target: 'function-4',
|
||||
sourceHandle: 'condition-b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if',
|
||||
},
|
||||
{
|
||||
source: 'condition-1',
|
||||
target: 'parallel-block',
|
||||
sourceHandle: 'condition-b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-else',
|
||||
},
|
||||
{
|
||||
source: 'parallel-block',
|
||||
target: 'agent-inside-parallel',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-block': {
|
||||
id: 'parallel-block',
|
||||
nodes: ['agent-inside-parallel'],
|
||||
distribution: ['item1', 'item2'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
|
||||
mockContext = {
|
||||
workflowId: 'test-workflow',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map(),
|
||||
loopItems: new Map(),
|
||||
completedLoops: new Set(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
workflow,
|
||||
}
|
||||
|
||||
// Initialize starter as executed and in active path
|
||||
mockContext.executedBlocks.add('starter')
|
||||
mockContext.activeExecutionPath.add('starter')
|
||||
mockContext.activeExecutionPath.add('router-1')
|
||||
})
|
||||
|
||||
it('should handle nested routing: router selects condition, condition selects function', () => {
|
||||
// Step 1: Router selects the condition path (not function-2)
|
||||
mockContext.blockStates.set('router-1', {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'condition-1',
|
||||
blockType: BlockType.CONDITION,
|
||||
blockTitle: 'Condition 1',
|
||||
},
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('router-1')
|
||||
|
||||
// Update paths after router execution
|
||||
pathTracker.updateExecutionPaths(['router-1'], mockContext)
|
||||
|
||||
// Verify router decision
|
||||
expect(mockContext.decisions.router.get('router-1')).toBe('condition-1')
|
||||
|
||||
// After router execution, condition should be active but not function-2
|
||||
expect(mockContext.activeExecutionPath.has('condition-1')).toBe(true)
|
||||
expect(mockContext.activeExecutionPath.has('function-2')).toBe(false)
|
||||
|
||||
// CRITICAL: Parallel block should NOT be activated yet
|
||||
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(false)
|
||||
expect(mockContext.activeExecutionPath.has('agent-inside-parallel')).toBe(false)
|
||||
|
||||
// Step 2: Condition executes and selects function-4 (not parallel)
|
||||
mockContext.blockStates.set('condition-1', {
|
||||
output: {
|
||||
result: 'two',
|
||||
stdout: '',
|
||||
conditionResult: true,
|
||||
selectedPath: {
|
||||
blockId: 'function-4',
|
||||
blockType: BlockType.FUNCTION,
|
||||
blockTitle: 'Function 4',
|
||||
},
|
||||
selectedConditionId: 'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
|
||||
// Update paths after condition execution
|
||||
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
|
||||
|
||||
// Verify condition decision
|
||||
expect(mockContext.decisions.condition.get('condition-1')).toBe(
|
||||
'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if'
|
||||
)
|
||||
|
||||
// After condition execution, function-4 should be active
|
||||
expect(mockContext.activeExecutionPath.has('function-4')).toBe(true)
|
||||
|
||||
// CRITICAL: Parallel block should still NOT be activated
|
||||
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(false)
|
||||
expect(mockContext.activeExecutionPath.has('agent-inside-parallel')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle nested routing: router selects condition, condition selects parallel', () => {
|
||||
// Step 1: Router selects the condition path
|
||||
mockContext.blockStates.set('router-1', {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'condition-1',
|
||||
blockType: BlockType.CONDITION,
|
||||
blockTitle: 'Condition 1',
|
||||
},
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('router-1')
|
||||
|
||||
pathTracker.updateExecutionPaths(['router-1'], mockContext)
|
||||
|
||||
// Step 2: Condition executes and selects parallel-block (not function-4)
|
||||
mockContext.blockStates.set('condition-1', {
|
||||
output: {
|
||||
result: 'else',
|
||||
stdout: '',
|
||||
conditionResult: false,
|
||||
selectedPath: {
|
||||
blockId: 'parallel-block',
|
||||
blockType: BlockType.PARALLEL,
|
||||
blockTitle: 'Parallel Block',
|
||||
},
|
||||
selectedConditionId: 'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-else',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
|
||||
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
|
||||
|
||||
// Verify condition decision
|
||||
expect(mockContext.decisions.condition.get('condition-1')).toBe(
|
||||
'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-else'
|
||||
)
|
||||
|
||||
// After condition execution, parallel-block should be active
|
||||
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(true)
|
||||
|
||||
// Function-4 should NOT be activated
|
||||
expect(mockContext.activeExecutionPath.has('function-4')).toBe(false)
|
||||
|
||||
// The agent inside parallel should NOT be automatically activated
|
||||
// It should only be activated when the parallel block executes
|
||||
expect(mockContext.activeExecutionPath.has('agent-inside-parallel')).toBe(false)
|
||||
})
|
||||
|
||||
it('should prevent parallel blocks from executing when not selected by nested routing', () => {
|
||||
// This test simulates the exact scenario from the bug report
|
||||
|
||||
// Step 1: Router selects condition path
|
||||
mockContext.blockStates.set('router-1', {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'condition-1',
|
||||
blockType: BlockType.CONDITION,
|
||||
blockTitle: 'Condition 1',
|
||||
},
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('router-1')
|
||||
pathTracker.updateExecutionPaths(['router-1'], mockContext)
|
||||
|
||||
// Step 2: Condition selects function-4 (NOT parallel)
|
||||
mockContext.blockStates.set('condition-1', {
|
||||
output: {
|
||||
result: 'two',
|
||||
stdout: '',
|
||||
conditionResult: true,
|
||||
selectedPath: {
|
||||
blockId: 'function-4',
|
||||
blockType: BlockType.FUNCTION,
|
||||
blockTitle: 'Function 4',
|
||||
},
|
||||
selectedConditionId: 'b8f0a33c-a57f-4a36-ac7a-dc9f2b5e6c07-if',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
|
||||
|
||||
// Step 3: Simulate what the executor's getNextExecutionLayer would do
|
||||
const blocksToExecute = workflow.blocks.filter(
|
||||
(block) =>
|
||||
mockContext.activeExecutionPath.has(block.id) && !mockContext.executedBlocks.has(block.id)
|
||||
)
|
||||
|
||||
const blockIds = blocksToExecute.map((b) => b.id)
|
||||
|
||||
// Should only include function-4, NOT parallel-block
|
||||
expect(blockIds).toContain('function-4')
|
||||
expect(blockIds).not.toContain('parallel-block')
|
||||
expect(blockIds).not.toContain('agent-inside-parallel')
|
||||
|
||||
// Verify that parallel block is not in active path
|
||||
expect(mockContext.activeExecutionPath.has('parallel-block')).toBe(false)
|
||||
|
||||
// Verify that isInActivePath also returns false for parallel block
|
||||
const isParallelActive = pathTracker.isInActivePath('parallel-block', mockContext)
|
||||
expect(isParallelActive).toBe(false)
|
||||
})
|
||||
})
|
||||
131
apps/sim/executor/tests/parallel-activation-integration.test.ts
Normal file
131
apps/sim/executor/tests/parallel-activation-integration.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { Routing } from '@/executor/routing/routing'
|
||||
|
||||
describe('Parallel Activation Integration - shouldSkipConnection behavior', () => {
|
||||
describe('Regular blocks can activate parallel/loop blocks', () => {
|
||||
it('should allow Agent → Parallel connections', () => {
|
||||
// This was the original bug - agent couldn't activate parallel
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('source', BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow Function → Parallel connections', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('source', BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow API → Loop connections', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.LOOP)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('source', BlockType.LOOP)).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow all regular blocks to activate parallel/loop', () => {
|
||||
const regularBlocks = [
|
||||
BlockType.FUNCTION,
|
||||
BlockType.AGENT,
|
||||
BlockType.API,
|
||||
BlockType.EVALUATOR,
|
||||
BlockType.RESPONSE,
|
||||
BlockType.WORKFLOW,
|
||||
]
|
||||
|
||||
regularBlocks.forEach((sourceBlockType) => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.LOOP)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('✅ Still works: Router and Condition blocks can activate parallel/loop', () => {
|
||||
it('should allow Router → Parallel connections', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow Condition → Parallel connections', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('✅ Still blocked: Internal flow control connections', () => {
|
||||
it('should block parallel-start-source connections during selective activation', () => {
|
||||
expect(Routing.shouldSkipConnection('parallel-start-source', BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('parallel-start-source', BlockType.AGENT)).toBe(true)
|
||||
})
|
||||
|
||||
it('should block parallel-end-source connections during selective activation', () => {
|
||||
expect(Routing.shouldSkipConnection('parallel-end-source', BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('parallel-end-source', BlockType.AGENT)).toBe(true)
|
||||
})
|
||||
|
||||
it('should block loop-start-source connections during selective activation', () => {
|
||||
expect(Routing.shouldSkipConnection('loop-start-source', BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('loop-start-source', BlockType.AGENT)).toBe(true)
|
||||
})
|
||||
|
||||
it('should block loop-end-source connections during selective activation', () => {
|
||||
expect(Routing.shouldSkipConnection('loop-end-source', BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('loop-end-source', BlockType.AGENT)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('✅ Still blocked: Condition-specific connections during selective activation', () => {
|
||||
it('should block condition-specific connections during selective activation', () => {
|
||||
expect(Routing.shouldSkipConnection('condition-test-if', BlockType.FUNCTION)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('condition-test-else', BlockType.AGENT)).toBe(true)
|
||||
expect(Routing.shouldSkipConnection('condition-some-id', BlockType.PARALLEL)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('✅ Still works: Regular connections', () => {
|
||||
it('should allow regular connections between regular blocks', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.FUNCTION)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('source', BlockType.AGENT)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('output', BlockType.API)).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow regular connections with any source handle (except blocked ones)', () => {
|
||||
expect(Routing.shouldSkipConnection('result', BlockType.FUNCTION)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('output', BlockType.AGENT)).toBe(false)
|
||||
expect(Routing.shouldSkipConnection('data', BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real-world workflow scenarios', () => {
|
||||
describe('✅ Working: User workflows', () => {
|
||||
it('should support: Start → Agent → Parallel → Agent pattern', () => {
|
||||
// This is the user's exact workflow pattern that was broken
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
|
||||
it('should support: Start → Function → Loop → Function pattern', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.LOOP)).toBe(false)
|
||||
})
|
||||
|
||||
it('should support: Start → API → Parallel → Multiple Agents pattern', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
|
||||
it('should support: Start → Evaluator → Parallel → Response pattern', () => {
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('✅ Working: Complex routing patterns', () => {
|
||||
it('should support: Start → Router → Parallel → Function (existing working pattern)', () => {
|
||||
// This already worked before the fix
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
|
||||
it('should support: Start → Condition → Parallel → Agent (existing working pattern)', () => {
|
||||
// This already worked before the fix
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
|
||||
it('should support: Start → Router → Function → Parallel → Agent (new working pattern)', () => {
|
||||
// Router selects function, function activates parallel
|
||||
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
545
apps/sim/executor/tests/parallel-activation-regression.test.ts
Normal file
545
apps/sim/executor/tests/parallel-activation-regression.test.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
describe('Parallel Block Activation Regression Tests', () => {
|
||||
let pathTracker: PathTracker
|
||||
let mockContext: ExecutionContext
|
||||
|
||||
const createMockContext = (workflow: SerializedWorkflow): ExecutionContext => ({
|
||||
workflowId: 'test-workflow',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map(),
|
||||
loopItems: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(['start']),
|
||||
completedLoops: new Set(),
|
||||
workflow,
|
||||
})
|
||||
|
||||
describe('Original Bug: Agent → Parallel should work', () => {
|
||||
beforeEach(() => {
|
||||
// The exact scenario from the user's non-working workflow
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'parallel-1',
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
position: { x: 400, y: 0 },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 2' },
|
||||
position: { x: 600, y: 0 },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'agent-1' },
|
||||
{ source: 'agent-1', target: 'parallel-1' }, // This was broken!
|
||||
{ source: 'parallel-1', target: 'agent-2', sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: ['agent-2'],
|
||||
count: 3,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
mockContext = createMockContext(workflow)
|
||||
})
|
||||
|
||||
it('should allow agent to activate parallel block', () => {
|
||||
// Agent 1 executes successfully
|
||||
mockContext.blockStates.set('agent-1', {
|
||||
output: { content: 'Agent response', usage: { tokens: 100 } },
|
||||
executed: true,
|
||||
executionTime: 1000,
|
||||
})
|
||||
mockContext.executedBlocks.add('agent-1')
|
||||
mockContext.activeExecutionPath.add('agent-1')
|
||||
|
||||
// Update paths after agent execution
|
||||
pathTracker.updateExecutionPaths(['agent-1'], mockContext)
|
||||
|
||||
// ✅ The parallel block should be activated
|
||||
expect(mockContext.activeExecutionPath.has('parallel-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not activate parallel-start-source connections during path updates', () => {
|
||||
// Set up parallel block as executed
|
||||
mockContext.blockStates.set('parallel-1', {
|
||||
output: { parallelId: 'parallel-1', parallelCount: 3, started: true },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
mockContext.executedBlocks.add('parallel-1')
|
||||
mockContext.activeExecutionPath.add('parallel-1')
|
||||
|
||||
// Update paths after parallel execution
|
||||
pathTracker.updateExecutionPaths(['parallel-1'], mockContext)
|
||||
|
||||
// ✅ The child agent should NOT be activated via PathTracker (parallel handler manages this)
|
||||
expect(mockContext.activeExecutionPath.has('agent-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regression: Router → Parallel should still work', () => {
|
||||
beforeEach(() => {
|
||||
// The working scenario that should continue to work
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'router-1',
|
||||
metadata: { id: BlockType.ROUTER, name: 'Router 1' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: { tool: BlockType.ROUTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'parallel-1',
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
position: { x: 400, y: 0 },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
position: { x: 600, y: 0 },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'router-1' },
|
||||
{ source: 'router-1', target: 'parallel-1' },
|
||||
{ source: 'parallel-1', target: 'function-1', sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: ['function-1'],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
mockContext = createMockContext(workflow)
|
||||
})
|
||||
|
||||
it('should allow router to activate parallel block', () => {
|
||||
// Router executes and selects parallel
|
||||
mockContext.blockStates.set('router-1', {
|
||||
output: {
|
||||
selectedPath: { blockId: 'parallel-1', blockType: BlockType.PARALLEL },
|
||||
reasoning: 'Going to parallel',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 500,
|
||||
})
|
||||
mockContext.executedBlocks.add('router-1')
|
||||
mockContext.activeExecutionPath.add('router-1')
|
||||
|
||||
// Update paths after router execution
|
||||
pathTracker.updateExecutionPaths(['router-1'], mockContext)
|
||||
|
||||
// ✅ Router should activate parallel block
|
||||
expect(mockContext.activeExecutionPath.has('parallel-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regression: Condition → Parallel should still work', () => {
|
||||
beforeEach(() => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'condition-1',
|
||||
metadata: { id: BlockType.CONDITION, name: 'Condition 1' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'parallel-1',
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
position: { x: 400, y: 0 },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
position: { x: 400, y: 200 },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
|
||||
position: { x: 600, y: 0 },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'condition-1' },
|
||||
{ source: 'condition-1', target: 'parallel-1', sourceHandle: 'condition-if' },
|
||||
{ source: 'condition-1', target: 'function-1', sourceHandle: 'condition-else' },
|
||||
{ source: 'parallel-1', target: 'agent-1', sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: ['agent-1'],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
mockContext = createMockContext(workflow)
|
||||
})
|
||||
|
||||
it('should allow condition to activate parallel block when if condition is met', () => {
|
||||
// Condition executes and selects if path (parallel)
|
||||
mockContext.blockStates.set('condition-1', {
|
||||
output: {
|
||||
selectedConditionId: 'if',
|
||||
conditionResult: true,
|
||||
selectedPath: { blockId: 'parallel-1', blockType: BlockType.PARALLEL },
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 200,
|
||||
})
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
mockContext.activeExecutionPath.add('condition-1')
|
||||
|
||||
// Update paths after condition execution
|
||||
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
|
||||
|
||||
// ✅ Condition should activate parallel block
|
||||
expect(mockContext.activeExecutionPath.has('parallel-1')).toBe(true)
|
||||
// ✅ Function should NOT be activated (else path)
|
||||
expect(mockContext.activeExecutionPath.has('function-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow condition to activate function block when else condition is met', () => {
|
||||
// Condition executes and selects else path (function)
|
||||
mockContext.blockStates.set('condition-1', {
|
||||
output: {
|
||||
selectedConditionId: 'else',
|
||||
conditionResult: false,
|
||||
selectedPath: { blockId: 'function-1', blockType: BlockType.FUNCTION },
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 200,
|
||||
})
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
mockContext.activeExecutionPath.add('condition-1')
|
||||
|
||||
// Update paths after condition execution
|
||||
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
|
||||
|
||||
// ✅ Function should be activated (else path)
|
||||
expect(mockContext.activeExecutionPath.has('function-1')).toBe(true)
|
||||
// ✅ Parallel should NOT be activated (if path)
|
||||
expect(mockContext.activeExecutionPath.has('parallel-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regression: All regular blocks should activate parallel/loop', () => {
|
||||
it.each([
|
||||
{ blockType: BlockType.FUNCTION, name: 'Function' },
|
||||
{ blockType: BlockType.AGENT, name: 'Agent' },
|
||||
{ blockType: BlockType.API, name: 'API' },
|
||||
{ blockType: BlockType.EVALUATOR, name: 'Evaluator' },
|
||||
{ blockType: BlockType.RESPONSE, name: 'Response' },
|
||||
{ blockType: BlockType.WORKFLOW, name: 'Workflow' },
|
||||
])('should allow $name → Parallel activation', ({ blockType, name }) => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'regular-block',
|
||||
metadata: { id: blockType, name },
|
||||
position: { x: 200, y: 0 },
|
||||
config: { tool: blockType, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'parallel-1',
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
position: { x: 400, y: 0 },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'target-function',
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Target Function' },
|
||||
position: { x: 600, y: 0 },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'regular-block' },
|
||||
{ source: 'regular-block', target: 'parallel-1' },
|
||||
{
|
||||
source: 'parallel-1',
|
||||
target: 'target-function',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-1': {
|
||||
id: 'parallel-1',
|
||||
nodes: ['target-function'],
|
||||
count: 2,
|
||||
parallelType: 'count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
mockContext = createMockContext(workflow)
|
||||
|
||||
// Regular block executes
|
||||
mockContext.blockStates.set('regular-block', {
|
||||
output: { result: 'Success' },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
mockContext.executedBlocks.add('regular-block')
|
||||
mockContext.activeExecutionPath.add('regular-block')
|
||||
|
||||
// Update paths after regular block execution
|
||||
pathTracker.updateExecutionPaths(['regular-block'], mockContext)
|
||||
|
||||
// ✅ The parallel block should be activated
|
||||
expect(mockContext.activeExecutionPath.has('parallel-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regression: Internal flow control connections should still be blocked', () => {
|
||||
it('should prevent activation of parallel-start-source connections during selective activation', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'function-1',
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
// This is an internal flow control connection that should be blocked
|
||||
{ source: 'function-1', target: 'agent-1', sourceHandle: 'parallel-start-source' },
|
||||
],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
mockContext = createMockContext(workflow)
|
||||
|
||||
// Function 1 executes
|
||||
mockContext.blockStates.set('function-1', {
|
||||
output: { result: 'Success' },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
mockContext.executedBlocks.add('function-1')
|
||||
mockContext.activeExecutionPath.add('function-1')
|
||||
|
||||
// Update paths after function execution
|
||||
pathTracker.updateExecutionPaths(['function-1'], mockContext)
|
||||
|
||||
// ❌ Agent should NOT be activated via parallel-start-source during selective activation
|
||||
expect(mockContext.activeExecutionPath.has('agent-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle loop blocks the same way as parallel blocks', () => {
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'start',
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-1',
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'loop-1',
|
||||
metadata: { id: BlockType.LOOP, name: 'Loop 1' },
|
||||
position: { x: 400, y: 0 },
|
||||
config: { tool: BlockType.LOOP, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
|
||||
position: { x: 600, y: 0 },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'start', target: 'function-1' },
|
||||
{ source: 'function-1', target: 'loop-1' }, // Function → Loop should work
|
||||
{ source: 'loop-1', target: 'agent-1', sourceHandle: 'loop-start-source' },
|
||||
],
|
||||
loops: {
|
||||
'loop-1': {
|
||||
id: 'loop-1',
|
||||
nodes: ['agent-1'],
|
||||
iterations: 3,
|
||||
loopType: 'for',
|
||||
},
|
||||
},
|
||||
parallels: {},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
mockContext = createMockContext(workflow)
|
||||
|
||||
// Function 1 executes
|
||||
mockContext.blockStates.set('function-1', {
|
||||
output: { result: 'Success' },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
mockContext.executedBlocks.add('function-1')
|
||||
mockContext.activeExecutionPath.add('function-1')
|
||||
|
||||
// Update paths after function execution
|
||||
pathTracker.updateExecutionPaths(['function-1'], mockContext)
|
||||
|
||||
// ✅ Function should be able to activate loop block
|
||||
expect(mockContext.activeExecutionPath.has('loop-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
206
apps/sim/executor/tests/parallel-handler-routing.test.ts
Normal file
206
apps/sim/executor/tests/parallel-handler-routing.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-handler'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
describe('Parallel Handler Integration with PathTracker', () => {
|
||||
let workflow: SerializedWorkflow
|
||||
let pathTracker: PathTracker
|
||||
let parallelHandler: ParallelBlockHandler
|
||||
let mockContext: ExecutionContext
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a simplified workflow with condition → parallel scenario
|
||||
workflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'condition-1',
|
||||
position: { x: 0, y: 0 },
|
||||
metadata: { id: BlockType.CONDITION, name: 'Condition 1' },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'function-2',
|
||||
position: { x: 100, y: -50 },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'parallel-2',
|
||||
position: { x: 100, y: 50 },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'agent-2',
|
||||
position: { x: 200, y: 50 },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 2' },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
// Condition → Function 2 (if path)
|
||||
{
|
||||
source: 'condition-1',
|
||||
target: 'function-2',
|
||||
sourceHandle: 'condition-test-if',
|
||||
},
|
||||
// Condition → Parallel 2 (else path)
|
||||
{
|
||||
source: 'condition-1',
|
||||
target: 'parallel-2',
|
||||
sourceHandle: 'condition-test-else',
|
||||
},
|
||||
// Parallel 2 → Agent 2
|
||||
{
|
||||
source: 'parallel-2',
|
||||
target: 'agent-2',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'parallel-2': {
|
||||
id: 'parallel-2',
|
||||
nodes: ['agent-2'],
|
||||
distribution: ['item1', 'item2'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
parallelHandler = new ParallelBlockHandler(undefined, pathTracker)
|
||||
|
||||
mockContext = {
|
||||
workflowId: 'test-workflow',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map(),
|
||||
loopItems: new Map(),
|
||||
completedLoops: new Set(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
workflow,
|
||||
}
|
||||
})
|
||||
|
||||
it('should not allow parallel block to execute when not in active path', async () => {
|
||||
// Set up scenario where condition selected function-2 (if path), not parallel-2 (else path)
|
||||
mockContext.decisions.condition.set('condition-1', 'test-if')
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
mockContext.activeExecutionPath.add('condition-1')
|
||||
mockContext.activeExecutionPath.add('function-2') // Only function-2 should be active
|
||||
|
||||
// Parallel-2 should NOT be in active path
|
||||
expect(mockContext.activeExecutionPath.has('parallel-2')).toBe(false)
|
||||
|
||||
// Test PathTracker's isInActivePath method
|
||||
const isParallel2Active = pathTracker.isInActivePath('parallel-2', mockContext)
|
||||
expect(isParallel2Active).toBe(false)
|
||||
|
||||
// Get the parallel block
|
||||
const parallelBlock = workflow.blocks.find((b) => b.id === 'parallel-2')!
|
||||
|
||||
// Try to execute the parallel block
|
||||
const result = await parallelHandler.execute(parallelBlock, {}, mockContext)
|
||||
|
||||
// The parallel block should execute (return started: true) but should NOT activate its children
|
||||
expect(result).toMatchObject({
|
||||
parallelId: 'parallel-2',
|
||||
started: true,
|
||||
})
|
||||
|
||||
// CRITICAL: Agent 2 should NOT be activated because parallel-2 is not in active path
|
||||
expect(mockContext.activeExecutionPath.has('agent-2')).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow parallel block to execute and activate children when in active path', async () => {
|
||||
// Set up scenario where condition selected parallel-2 (else path)
|
||||
mockContext.decisions.condition.set('condition-1', 'test-else')
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
mockContext.activeExecutionPath.add('condition-1')
|
||||
mockContext.activeExecutionPath.add('parallel-2') // Parallel-2 should be active
|
||||
|
||||
// Parallel-2 should be in active path
|
||||
expect(mockContext.activeExecutionPath.has('parallel-2')).toBe(true)
|
||||
|
||||
// Test PathTracker's isInActivePath method
|
||||
const isParallel2Active = pathTracker.isInActivePath('parallel-2', mockContext)
|
||||
expect(isParallel2Active).toBe(true)
|
||||
|
||||
// Get the parallel block
|
||||
const parallelBlock = workflow.blocks.find((b) => b.id === 'parallel-2')!
|
||||
|
||||
// Try to execute the parallel block
|
||||
const result = await parallelHandler.execute(parallelBlock, {}, mockContext)
|
||||
|
||||
// The parallel block should execute and activate its children
|
||||
expect(result).toMatchObject({
|
||||
parallelId: 'parallel-2',
|
||||
started: true,
|
||||
})
|
||||
|
||||
// Agent 2 should be activated because parallel-2 is in active path
|
||||
expect(mockContext.activeExecutionPath.has('agent-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should test the routing failure scenario with parallel block', async () => {
|
||||
// Step 1: Condition 1 selects Function 2 (if path)
|
||||
mockContext.blockStates.set('condition-1', {
|
||||
output: {
|
||||
result: 'one',
|
||||
stdout: '',
|
||||
conditionResult: true,
|
||||
selectedPath: {
|
||||
blockId: 'function-2',
|
||||
blockType: 'function',
|
||||
blockTitle: 'Function 2',
|
||||
},
|
||||
selectedConditionId: 'test-if',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('condition-1')
|
||||
mockContext.activeExecutionPath.add('condition-1')
|
||||
|
||||
// Update paths after condition execution
|
||||
pathTracker.updateExecutionPaths(['condition-1'], mockContext)
|
||||
|
||||
// Verify condition selected if path
|
||||
expect(mockContext.decisions.condition.get('condition-1')).toBe('test-if')
|
||||
expect(mockContext.activeExecutionPath.has('function-2')).toBe(true)
|
||||
expect(mockContext.activeExecutionPath.has('parallel-2')).toBe(false)
|
||||
|
||||
// Step 2: Try to execute parallel-2 (should not activate children)
|
||||
const parallelBlock = workflow.blocks.find((b) => b.id === 'parallel-2')!
|
||||
const result = await parallelHandler.execute(parallelBlock, {}, mockContext)
|
||||
|
||||
// Parallel should execute but not activate children
|
||||
expect(result).toMatchObject({
|
||||
parallelId: 'parallel-2',
|
||||
started: true,
|
||||
})
|
||||
|
||||
// CRITICAL: Agent 2 should NOT be activated
|
||||
expect(mockContext.activeExecutionPath.has('agent-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
318
apps/sim/executor/tests/router-parallel-execution.test.ts
Normal file
318
apps/sim/executor/tests/router-parallel-execution.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { PathTracker } from '@/executor/path/path'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
describe('Router and Condition Block Path Selection in Complex Workflows', () => {
|
||||
let workflow: SerializedWorkflow
|
||||
let pathTracker: PathTracker
|
||||
let mockContext: ExecutionContext
|
||||
|
||||
beforeEach(() => {
|
||||
workflow = {
|
||||
version: '2.0',
|
||||
blocks: [
|
||||
{
|
||||
id: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
|
||||
position: { x: 0, y: 0 },
|
||||
metadata: { id: BlockType.STARTER, name: 'Start' },
|
||||
config: { tool: BlockType.STARTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
position: { x: 100, y: 0 },
|
||||
metadata: { id: BlockType.ROUTER, name: 'Router 1' },
|
||||
config: { tool: BlockType.ROUTER, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
|
||||
position: { x: 200, y: -50 },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 1' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
position: { x: 200, y: 50 },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 1' },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
position: { x: 300, y: -50 },
|
||||
metadata: { id: BlockType.CONDITION, name: 'Condition 1' },
|
||||
config: { tool: BlockType.CONDITION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '033ea142-3002-4a68-9e12-092b10b8c9c8',
|
||||
position: { x: 400, y: -100 },
|
||||
metadata: { id: BlockType.FUNCTION, name: 'Function 2' },
|
||||
config: { tool: BlockType.FUNCTION, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
position: { x: 400, y: 0 },
|
||||
metadata: { id: BlockType.PARALLEL, name: 'Parallel 2' },
|
||||
config: { tool: BlockType.PARALLEL, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'a91e3a02-b884-4823-8197-30ae498ac94c',
|
||||
position: { x: 300, y: 100 },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 1' },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
|
||||
position: { x: 500, y: 0 },
|
||||
metadata: { id: BlockType.AGENT, name: 'Agent 2' },
|
||||
config: { tool: BlockType.AGENT, params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
// Start → Router 1
|
||||
{
|
||||
source: 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19',
|
||||
target: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
},
|
||||
// Router 1 → Function 1
|
||||
{
|
||||
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
target: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
|
||||
},
|
||||
// Router 1 → Parallel 1
|
||||
{
|
||||
source: 'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
target: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
},
|
||||
// Function 1 → Condition 1
|
||||
{
|
||||
source: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
|
||||
target: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
},
|
||||
// Condition 1 → Function 2 (if path)
|
||||
{
|
||||
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
target: '033ea142-3002-4a68-9e12-092b10b8c9c8',
|
||||
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-if',
|
||||
},
|
||||
// Condition 1 → Parallel 2 (else path)
|
||||
{
|
||||
source: '0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
target: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
sourceHandle: 'condition-0494cf56-2520-4e29-98ad-313ea55cf142-else',
|
||||
},
|
||||
// Parallel 1 → Agent 1
|
||||
{
|
||||
source: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
target: 'a91e3a02-b884-4823-8197-30ae498ac94c',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
// Parallel 2 → Agent 2
|
||||
{
|
||||
source: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
target: '97974a42-cdf4-4810-9caa-b5e339f42ab0',
|
||||
sourceHandle: 'parallel-start-source',
|
||||
},
|
||||
],
|
||||
loops: {},
|
||||
parallels: {
|
||||
'a62902db-fd8d-4851-aa88-acd5e7667497': {
|
||||
id: 'a62902db-fd8d-4851-aa88-acd5e7667497',
|
||||
nodes: ['a91e3a02-b884-4823-8197-30ae498ac94c'],
|
||||
distribution: ['item1', 'item2'],
|
||||
},
|
||||
'037140a8-fda3-44e2-896c-6adea53ea30f': {
|
||||
id: '037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
nodes: ['97974a42-cdf4-4810-9caa-b5e339f42ab0'],
|
||||
distribution: ['item1', 'item2'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pathTracker = new PathTracker(workflow)
|
||||
|
||||
mockContext = {
|
||||
workflowId: 'test-workflow',
|
||||
blockStates: new Map(),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopIterations: new Map(),
|
||||
loopItems: new Map(),
|
||||
completedLoops: new Set(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
workflow,
|
||||
}
|
||||
|
||||
// Initialize execution state
|
||||
mockContext.executedBlocks.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start
|
||||
mockContext.activeExecutionPath.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start
|
||||
mockContext.activeExecutionPath.add('f29a40b7-125a-45a7-a670-af14a1498f94') // Router 1
|
||||
})
|
||||
|
||||
it('should reproduce the exact router and condition block path selection scenario', () => {
|
||||
// Step 1: Router 1 executes and selects Function 1 (not Parallel 1)
|
||||
mockContext.blockStates.set('f29a40b7-125a-45a7-a670-af14a1498f94', {
|
||||
output: {
|
||||
selectedPath: {
|
||||
blockId: 'd09b0a90-2c59-4a2c-af15-c30321e36d9b',
|
||||
blockType: BlockType.FUNCTION,
|
||||
blockTitle: 'Function 1',
|
||||
},
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('f29a40b7-125a-45a7-a670-af14a1498f94')
|
||||
|
||||
pathTracker.updateExecutionPaths(['f29a40b7-125a-45a7-a670-af14a1498f94'], mockContext)
|
||||
|
||||
// Verify router selected Function 1
|
||||
expect(mockContext.decisions.router.get('f29a40b7-125a-45a7-a670-af14a1498f94')).toBe(
|
||||
'd09b0a90-2c59-4a2c-af15-c30321e36d9b'
|
||||
)
|
||||
expect(mockContext.activeExecutionPath.has('d09b0a90-2c59-4a2c-af15-c30321e36d9b')).toBe(true) // Function 1
|
||||
|
||||
// Parallel 1 should NOT be in active path (not selected by router)
|
||||
expect(mockContext.activeExecutionPath.has('a62902db-fd8d-4851-aa88-acd5e7667497')).toBe(false) // Parallel 1
|
||||
expect(mockContext.activeExecutionPath.has('a91e3a02-b884-4823-8197-30ae498ac94c')).toBe(false) // Agent 1
|
||||
|
||||
// Step 2: Function 1 executes and returns "one"
|
||||
mockContext.blockStates.set('d09b0a90-2c59-4a2c-af15-c30321e36d9b', {
|
||||
output: {
|
||||
result: 'one',
|
||||
stdout: '',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b')
|
||||
|
||||
pathTracker.updateExecutionPaths(['d09b0a90-2c59-4a2c-af15-c30321e36d9b'], mockContext)
|
||||
|
||||
// Function 1 should activate Condition 1
|
||||
expect(mockContext.activeExecutionPath.has('0494cf56-2520-4e29-98ad-313ea55cf142')).toBe(true) // Condition 1
|
||||
|
||||
// Parallel 2 should NOT be in active path yet
|
||||
expect(mockContext.activeExecutionPath.has('037140a8-fda3-44e2-896c-6adea53ea30f')).toBe(false) // Parallel 2
|
||||
expect(mockContext.activeExecutionPath.has('97974a42-cdf4-4810-9caa-b5e339f42ab0')).toBe(false) // Agent 2
|
||||
|
||||
// Step 3: Condition 1 executes and selects Function 2 (if path, not else/parallel path)
|
||||
mockContext.blockStates.set('0494cf56-2520-4e29-98ad-313ea55cf142', {
|
||||
output: {
|
||||
result: 'one',
|
||||
stdout: '',
|
||||
conditionResult: true,
|
||||
selectedPath: {
|
||||
blockId: '033ea142-3002-4a68-9e12-092b10b8c9c8',
|
||||
blockType: BlockType.FUNCTION,
|
||||
blockTitle: 'Function 2',
|
||||
},
|
||||
selectedConditionId: '0494cf56-2520-4e29-98ad-313ea55cf142-if',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('0494cf56-2520-4e29-98ad-313ea55cf142')
|
||||
|
||||
pathTracker.updateExecutionPaths(['0494cf56-2520-4e29-98ad-313ea55cf142'], mockContext)
|
||||
|
||||
// Verify condition selected the if path (Function 2)
|
||||
expect(mockContext.decisions.condition.get('0494cf56-2520-4e29-98ad-313ea55cf142')).toBe(
|
||||
'0494cf56-2520-4e29-98ad-313ea55cf142-if'
|
||||
)
|
||||
expect(mockContext.activeExecutionPath.has('033ea142-3002-4a68-9e12-092b10b8c9c8')).toBe(true) // Function 2
|
||||
|
||||
// CRITICAL: Parallel 2 should NOT be in active path (condition selected if, not else)
|
||||
expect(mockContext.activeExecutionPath.has('037140a8-fda3-44e2-896c-6adea53ea30f')).toBe(false) // Parallel 2
|
||||
expect(mockContext.activeExecutionPath.has('97974a42-cdf4-4810-9caa-b5e339f42ab0')).toBe(false) // Agent 2
|
||||
|
||||
// Step 4: Function 2 executes (this should be the end of the workflow)
|
||||
mockContext.blockStates.set('033ea142-3002-4a68-9e12-092b10b8c9c8', {
|
||||
output: {
|
||||
result: 'two',
|
||||
stdout: '',
|
||||
},
|
||||
executed: true,
|
||||
executionTime: 0,
|
||||
})
|
||||
mockContext.executedBlocks.add('033ea142-3002-4a68-9e12-092b10b8c9c8')
|
||||
|
||||
pathTracker.updateExecutionPaths(['033ea142-3002-4a68-9e12-092b10b8c9c8'], mockContext)
|
||||
|
||||
// Final verification: Parallel 2 and Agent 2 should NEVER be in active path
|
||||
expect(mockContext.activeExecutionPath.has('037140a8-fda3-44e2-896c-6adea53ea30f')).toBe(false) // Parallel 2
|
||||
expect(mockContext.activeExecutionPath.has('97974a42-cdf4-4810-9caa-b5e339f42ab0')).toBe(false) // Agent 2
|
||||
|
||||
// Simulate what executor's getNextExecutionLayer would return
|
||||
const blocksToExecute = workflow.blocks.filter(
|
||||
(block) =>
|
||||
mockContext.activeExecutionPath.has(block.id) && !mockContext.executedBlocks.has(block.id)
|
||||
)
|
||||
const blockIds = blocksToExecute.map((b) => b.id)
|
||||
|
||||
// Should be empty (no more blocks to execute)
|
||||
expect(blockIds).toHaveLength(0)
|
||||
|
||||
// Should NOT include Parallel 2 or Agent 2
|
||||
expect(blockIds).not.toContain('037140a8-fda3-44e2-896c-6adea53ea30f') // Parallel 2
|
||||
expect(blockIds).not.toContain('97974a42-cdf4-4810-9caa-b5e339f42ab0') // Agent 2
|
||||
})
|
||||
|
||||
it('should test the isInActivePath method for Parallel 2', () => {
|
||||
// Set up the same execution state as above
|
||||
mockContext.executedBlocks.add('f29a40b7-125a-45a7-a670-af14a1498f94') // Router 1
|
||||
mockContext.executedBlocks.add('d09b0a90-2c59-4a2c-af15-c30321e36d9b') // Function 1
|
||||
mockContext.executedBlocks.add('0494cf56-2520-4e29-98ad-313ea55cf142') // Condition 1
|
||||
|
||||
// Set router decision
|
||||
mockContext.decisions.router.set(
|
||||
'f29a40b7-125a-45a7-a670-af14a1498f94',
|
||||
'd09b0a90-2c59-4a2c-af15-c30321e36d9b'
|
||||
)
|
||||
|
||||
// Set condition decision to if path (not else path)
|
||||
mockContext.decisions.condition.set(
|
||||
'0494cf56-2520-4e29-98ad-313ea55cf142',
|
||||
'0494cf56-2520-4e29-98ad-313ea55cf142-if'
|
||||
)
|
||||
|
||||
// Test isInActivePath for Parallel 2
|
||||
const isParallel2Active = pathTracker.isInActivePath(
|
||||
'037140a8-fda3-44e2-896c-6adea53ea30f',
|
||||
mockContext
|
||||
)
|
||||
|
||||
// Should be false because condition selected if path, not else path
|
||||
expect(isParallel2Active).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { StreamingResponseFormatProcessor, streamingResponseFormatProcessor } from './utils'
|
||||
import {
|
||||
StreamingResponseFormatProcessor,
|
||||
streamingResponseFormatProcessor,
|
||||
} from '@/executor/utils'
|
||||
|
||||
vi.mock('@/lib/logs/console-logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import type { ResponseFormatStreamProcessor } from './types'
|
||||
import type { ResponseFormatStreamProcessor } from '@/executor/types'
|
||||
|
||||
const logger = createLogger('ExecutorUtils')
|
||||
|
||||
|
||||
@@ -44,8 +44,14 @@ export function useCollaborativeWorkflow() {
|
||||
const lastPositionTimestamps = useRef<Map<string, number>>(new Map())
|
||||
|
||||
// Operation queue
|
||||
const { queue, hasOperationError, addToQueue, confirmOperation, failOperation } =
|
||||
useOperationQueue()
|
||||
const {
|
||||
queue,
|
||||
hasOperationError,
|
||||
addToQueue,
|
||||
confirmOperation,
|
||||
failOperation,
|
||||
cancelOperationsForBlock,
|
||||
} = useOperationQueue()
|
||||
|
||||
// Clear position timestamps when switching workflows
|
||||
// Note: Workflow joining is now handled automatically by socket connect event based on URL
|
||||
@@ -332,7 +338,7 @@ export function useCollaborativeWorkflow() {
|
||||
const { operationId, error, retryable } = data
|
||||
logger.warn('Operation failed', { operationId, error, retryable })
|
||||
|
||||
failOperation(operationId)
|
||||
failOperation(operationId, retryable)
|
||||
}
|
||||
|
||||
// Register event handlers
|
||||
@@ -534,9 +540,11 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
const collaborativeRemoveBlock = useCallback(
|
||||
(id: string) => {
|
||||
cancelOperationsForBlock(id)
|
||||
|
||||
executeQueuedOperation('remove', 'block', { id }, () => workflowStore.removeBlock(id))
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
[executeQueuedOperation, workflowStore, cancelOperationsForBlock]
|
||||
)
|
||||
|
||||
const collaborativeUpdateBlockPosition = useCallback(
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getUserEntityPermissions,
|
||||
getUsersWithPermissions,
|
||||
hasAdminPermission,
|
||||
} from '@/lib/permissions/utils'
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
@@ -32,6 +27,16 @@ vi.mock('@/db/schema', () => ({
|
||||
name: 'user_name',
|
||||
image: 'user_image',
|
||||
},
|
||||
workspace: {
|
||||
id: 'workspace_id',
|
||||
name: 'workspace_name',
|
||||
ownerId: 'workspace_owner_id',
|
||||
},
|
||||
member: {
|
||||
userId: 'member_user_id',
|
||||
organizationId: 'member_organization_id',
|
||||
role: 'member_role',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
@@ -39,253 +44,120 @@ vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn().mockReturnValue('eq-condition'),
|
||||
}))
|
||||
|
||||
import {
|
||||
getManageableWorkspaces,
|
||||
getUserEntityPermissions,
|
||||
getUsersWithPermissions,
|
||||
hasAdminPermission,
|
||||
hasWorkspaceAdminAccess,
|
||||
isOrganizationAdminForWorkspace,
|
||||
isOrganizationOwnerOrAdmin,
|
||||
} from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { permissions, user } from '@/db/schema'
|
||||
|
||||
const mockDb = db as any
|
||||
|
||||
type PermissionType = 'admin' | 'write' | 'read'
|
||||
|
||||
function createMockChain(finalResult: any) {
|
||||
const chain: any = {}
|
||||
|
||||
chain.then = vi.fn().mockImplementation((resolve: any) => resolve(finalResult))
|
||||
chain.select = vi.fn().mockReturnValue(chain)
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
describe('Permission Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockDb.select.mockReturnValue(mockDb)
|
||||
mockDb.from.mockReturnValue(mockDb)
|
||||
mockDb.where.mockReturnValue(mockDb)
|
||||
mockDb.limit.mockResolvedValue([])
|
||||
mockDb.innerJoin.mockReturnValue(mockDb)
|
||||
mockDb.orderBy.mockReturnValue(mockDb)
|
||||
})
|
||||
|
||||
describe('getUserEntityPermissions', () => {
|
||||
it.concurrent('should return null when user has no permissions', async () => {
|
||||
mockDb.where.mockResolvedValue([])
|
||||
it('should return null when user has no permissions', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockDb.select).toHaveBeenCalledWith({ permissionType: permissions.permissionType })
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
expect(mockDb.where).toHaveBeenCalledWith('and-condition')
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should return the highest permission when user has multiple permissions',
|
||||
async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
mockDb.where.mockResolvedValue(mockResults)
|
||||
it('should return the highest permission when user has multiple permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
expect(mockDb.select).toHaveBeenCalledWith({
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
}
|
||||
)
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it.concurrent('should return single permission when user has only one', async () => {
|
||||
it('should return single permission when user has only one', async () => {
|
||||
const mockResults = [{ permissionType: 'read' as PermissionType }]
|
||||
mockDb.where.mockResolvedValue(mockResults)
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user123', 'workflow', 'workflow789')
|
||||
|
||||
expect(result).toBe('read')
|
||||
})
|
||||
|
||||
it.concurrent('should handle different entity types', async () => {
|
||||
const mockResults = [{ permissionType: 'write' as PermissionType }]
|
||||
mockDb.where.mockResolvedValue(mockResults)
|
||||
|
||||
const result = await getUserEntityPermissions('user456', 'organization', 'org123')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should return highest permission when multiple exist (admin > write > read)',
|
||||
async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
]
|
||||
mockDb.where.mockResolvedValue(mockResults)
|
||||
|
||||
const result = await getUserEntityPermissions('user789', 'workspace', 'workspace123')
|
||||
|
||||
expect(result).toBe('write')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should prioritize admin over other permissions', async () => {
|
||||
it('should prioritize admin over other permissions', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
{ permissionType: 'admin' as PermissionType },
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
]
|
||||
mockDb.where.mockResolvedValue(mockResults)
|
||||
const chain = createMockChain(mockResults)
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUserEntityPermissions('user999', 'workspace', 'workspace999')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it.concurrent('should handle edge case with single admin permission', async () => {
|
||||
const mockResults = [{ permissionType: 'admin' as PermissionType }]
|
||||
mockDb.where.mockResolvedValue(mockResults)
|
||||
|
||||
const result = await getUserEntityPermissions('admin-user', 'workspace', 'workspace-admin')
|
||||
|
||||
expect(result).toBe('admin')
|
||||
})
|
||||
|
||||
it.concurrent('should correctly prioritize write over read', async () => {
|
||||
const mockResults = [
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
{ permissionType: 'write' as PermissionType },
|
||||
{ permissionType: 'read' as PermissionType },
|
||||
]
|
||||
mockDb.where.mockResolvedValue(mockResults)
|
||||
|
||||
const result = await getUserEntityPermissions('write-user', 'workflow', 'workflow-write')
|
||||
|
||||
expect(result).toBe('write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAdminPermission', () => {
|
||||
it.concurrent('should return true when user has admin permission for workspace', async () => {
|
||||
const mockResult = [
|
||||
{
|
||||
/* some admin permission record */
|
||||
},
|
||||
]
|
||||
mockDb.limit.mockResolvedValue(mockResult)
|
||||
it('should return true when user has admin permission for workspace', async () => {
|
||||
const chain = createMockChain([{ permissionType: 'admin' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasAdminPermission('admin-user', 'workspace123')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockDb.select).toHaveBeenCalledWith()
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
expect(mockDb.where).toHaveBeenCalledWith('and-condition')
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should return false when user has no admin permission for workspace',
|
||||
async () => {
|
||||
mockDb.limit.mockResolvedValue([])
|
||||
it('should return false when user has no admin permission for workspace', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasAdminPermission('regular-user', 'workspace123')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockDb.select).toHaveBeenCalledWith()
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
expect(mockDb.where).toHaveBeenCalledWith('and-condition')
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should handle different user and workspace combinations', async () => {
|
||||
// Test with no admin permission
|
||||
mockDb.limit.mockResolvedValue([])
|
||||
|
||||
const result1 = await hasAdminPermission('user456', 'workspace789')
|
||||
expect(result1).toBe(false)
|
||||
|
||||
// Test with admin permission
|
||||
const mockAdminResult = [{ permissionType: 'admin' }]
|
||||
mockDb.limit.mockResolvedValue(mockAdminResult)
|
||||
|
||||
const result2 = await hasAdminPermission('admin789', 'workspace456')
|
||||
expect(result2).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should call database with correct parameters for workspace admin check',
|
||||
async () => {
|
||||
mockDb.limit.mockResolvedValue([])
|
||||
|
||||
await hasAdminPermission('test-user-id', 'test-workspace-id')
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalledWith()
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
expect(mockDb.where).toHaveBeenCalledWith('and-condition')
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'should return true even if multiple admin records exist (due to limit 1)',
|
||||
async () => {
|
||||
// This shouldn't happen in practice, but tests the limit functionality
|
||||
const mockResult = [{ permissionType: 'admin' }] // Only one record due to limit(1)
|
||||
mockDb.limit.mockResolvedValue(mockResult)
|
||||
|
||||
const result = await hasAdminPermission('super-admin', 'workspace999')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should handle edge cases with empty strings', async () => {
|
||||
mockDb.limit.mockResolvedValue([])
|
||||
|
||||
const result = await hasAdminPermission('', '')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.concurrent('should return false for non-existent workspace', async () => {
|
||||
mockDb.limit.mockResolvedValue([])
|
||||
|
||||
const result = await hasAdminPermission('user123', 'non-existent-workspace')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for non-existent user', async () => {
|
||||
mockDb.limit.mockResolvedValue([])
|
||||
|
||||
const result = await hasAdminPermission('non-existent-user', 'workspace123')
|
||||
const result = await hasAdminPermission('regular-user', 'workspace123')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsersWithPermissions', () => {
|
||||
it.concurrent(
|
||||
'should return empty array when no users have permissions for workspace',
|
||||
async () => {
|
||||
mockDb.orderBy.mockResolvedValue([])
|
||||
it('should return empty array when no users have permissions for workspace', async () => {
|
||||
const usersChain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(usersChain)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace123')
|
||||
const result = await getUsersWithPermissions('workspace123')
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mockDb.select).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition')
|
||||
expect(mockDb.where).toHaveBeenCalledWith('and-condition')
|
||||
expect(mockDb.orderBy).toHaveBeenCalledWith(user.email)
|
||||
}
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should return users with their permissions for workspace', async () => {
|
||||
const mockResults = [
|
||||
it('should return users with their permissions for workspace', async () => {
|
||||
const mockUsersResults = [
|
||||
{
|
||||
userId: 'user1',
|
||||
email: 'alice@example.com',
|
||||
@@ -293,22 +165,10 @@ describe('Permission Utils', () => {
|
||||
image: 'https://example.com/alice.jpg',
|
||||
permissionType: 'admin' as PermissionType,
|
||||
},
|
||||
{
|
||||
userId: 'user2',
|
||||
email: 'bob@example.com',
|
||||
name: 'Bob Johnson',
|
||||
image: 'https://example.com/bob.jpg',
|
||||
permissionType: 'write' as PermissionType,
|
||||
},
|
||||
{
|
||||
userId: 'user3',
|
||||
email: 'charlie@example.com',
|
||||
name: 'Charlie Brown',
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
},
|
||||
]
|
||||
mockDb.orderBy.mockResolvedValue(mockResults)
|
||||
|
||||
const usersChain = createMockChain(mockUsersResults)
|
||||
mockDb.select.mockReturnValue(usersChain)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace456')
|
||||
|
||||
@@ -320,158 +180,136 @@ describe('Permission Utils', () => {
|
||||
image: 'https://example.com/alice.jpg',
|
||||
permissionType: 'admin',
|
||||
},
|
||||
{
|
||||
userId: 'user2',
|
||||
email: 'bob@example.com',
|
||||
name: 'Bob Johnson',
|
||||
image: 'https://example.com/bob.jpg',
|
||||
permissionType: 'write',
|
||||
},
|
||||
{
|
||||
userId: 'user3',
|
||||
email: 'charlie@example.com',
|
||||
name: 'Charlie Brown',
|
||||
image: null,
|
||||
permissionType: 'read',
|
||||
},
|
||||
])
|
||||
expect(mockDb.select).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
permissionType: permissions.permissionType,
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOrganizationAdminForWorkspace', () => {
|
||||
it('should return false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await isOrganizationAdminForWorkspace('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user has no organization memberships', async () => {
|
||||
// Mock workspace exists, but user has no org memberships
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ ownerId: 'workspace-owner-123' }])
|
||||
}
|
||||
return createMockChain([]) // No memberships
|
||||
})
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition')
|
||||
expect(mockDb.where).toHaveBeenCalledWith('and-condition')
|
||||
expect(mockDb.orderBy).toHaveBeenCalledWith(user.email)
|
||||
|
||||
const result = await isOrganizationAdminForWorkspace('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasWorkspaceAdminAccess', () => {
|
||||
it('should return true when user has direct admin permission', async () => {
|
||||
const chain = createMockChain([{ permissionType: 'admin' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle single user with permission', async () => {
|
||||
const mockResults = [
|
||||
{
|
||||
userId: 'solo-user',
|
||||
email: 'solo@example.com',
|
||||
name: 'Solo User',
|
||||
image: 'https://example.com/solo.jpg',
|
||||
permissionType: 'admin' as PermissionType,
|
||||
},
|
||||
]
|
||||
mockDb.orderBy.mockResolvedValue(mockResults)
|
||||
it('should return false when user has neither direct nor organization admin access', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace-solo')
|
||||
const result = await hasWorkspaceAdminAccess('user123', 'workspace456')
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
userId: 'solo-user',
|
||||
email: 'solo@example.com',
|
||||
name: 'Solo User',
|
||||
image: 'https://example.com/solo.jpg',
|
||||
permissionType: 'admin',
|
||||
},
|
||||
])
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOrganizationOwnerOrAdmin', () => {
|
||||
it('should return true when user is owner of organization', async () => {
|
||||
const chain = createMockChain([{ role: 'owner' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await isOrganizationOwnerOrAdmin('user123', 'org456')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle users with null names and images', async () => {
|
||||
const mockResults = [
|
||||
{
|
||||
userId: 'user-minimal',
|
||||
email: 'minimal@example.com',
|
||||
name: null,
|
||||
image: null,
|
||||
permissionType: 'read' as PermissionType,
|
||||
},
|
||||
]
|
||||
mockDb.orderBy.mockResolvedValue(mockResults)
|
||||
it('should return true when user is admin of organization', async () => {
|
||||
const chain = createMockChain([{ role: 'admin' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace-minimal')
|
||||
const result = await isOrganizationOwnerOrAdmin('user123', 'org456')
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
userId: 'user-minimal',
|
||||
email: 'minimal@example.com',
|
||||
name: null,
|
||||
image: null,
|
||||
permissionType: 'read',
|
||||
},
|
||||
])
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should call database with correct parameters', async () => {
|
||||
mockDb.orderBy.mockResolvedValue([])
|
||||
it('should return false when user is regular member of organization', async () => {
|
||||
const chain = createMockChain([{ role: 'member' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
await getUsersWithPermissions('test-workspace-123')
|
||||
const result = await isOrganizationOwnerOrAdmin('user123', 'org456')
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
permissionType: permissions.permissionType,
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when user is not member of organization', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await isOrganizationOwnerOrAdmin('user123', 'org456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockDb.select.mockImplementation(() => {
|
||||
throw new Error('Database error')
|
||||
})
|
||||
expect(mockDb.from).toHaveBeenCalledWith(permissions)
|
||||
expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition')
|
||||
expect(mockDb.where).toHaveBeenCalledWith('and-condition')
|
||||
expect(mockDb.orderBy).toHaveBeenCalledWith(user.email)
|
||||
|
||||
const result = await isOrganizationOwnerOrAdmin('user123', 'org456')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should handle different workspace IDs', async () => {
|
||||
mockDb.orderBy.mockResolvedValue([])
|
||||
describe('getManageableWorkspaces', () => {
|
||||
it('should return empty array when user has no manageable workspaces', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result1 = await getUsersWithPermissions('workspace-abc-123')
|
||||
const result2 = await getUsersWithPermissions('workspace-xyz-789')
|
||||
|
||||
expect(result1).toEqual([])
|
||||
expect(result2).toEqual([])
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
expect(mockDb.from).toHaveBeenCalled()
|
||||
expect(mockDb.innerJoin).toHaveBeenCalled()
|
||||
expect(mockDb.where).toHaveBeenCalled()
|
||||
expect(mockDb.orderBy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.concurrent('should handle all permission types correctly', async () => {
|
||||
const mockResults = [
|
||||
{
|
||||
userId: 'admin-user',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
image: 'admin.jpg',
|
||||
permissionType: 'admin' as PermissionType,
|
||||
},
|
||||
{
|
||||
userId: 'write-user',
|
||||
email: 'writer@example.com',
|
||||
name: 'Write User',
|
||||
image: 'writer.jpg',
|
||||
permissionType: 'write' as PermissionType,
|
||||
},
|
||||
{
|
||||
userId: 'read-user',
|
||||
email: 'reader@example.com',
|
||||
name: 'Read User',
|
||||
image: 'reader.jpg',
|
||||
permissionType: 'read' as PermissionType,
|
||||
},
|
||||
]
|
||||
mockDb.orderBy.mockResolvedValue(mockResults)
|
||||
|
||||
const result = await getUsersWithPermissions('workspace-all-perms')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].permissionType).toBe('admin')
|
||||
expect(result[1].permissionType).toBe('write')
|
||||
expect(result[2].permissionType).toBe('read')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty workspace ID', async () => {
|
||||
mockDb.orderBy.mockResolvedValue([])
|
||||
|
||||
const result = await getUsersWithPermissions('')
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(mockDb.select).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return direct admin workspaces', async () => {
|
||||
const mockDirectWorkspaces = [
|
||||
{ id: 'ws1', name: 'Workspace 1', ownerId: 'owner1' },
|
||||
{ id: 'ws2', name: 'Workspace 2', ownerId: 'owner2' },
|
||||
]
|
||||
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain(mockDirectWorkspaces) // direct admin workspaces
|
||||
}
|
||||
return createMockChain([]) // no organization memberships
|
||||
})
|
||||
|
||||
const result = await getManageableWorkspaces('user123')
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: 'ws1', name: 'Workspace 1', ownerId: 'owner1', accessType: 'direct' },
|
||||
{ id: 'ws2', name: 'Workspace 2', ownerId: 'owner2', accessType: 'direct' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSubBlockValue,
|
||||
parseCronToHumanReadable,
|
||||
parseTimeString,
|
||||
validateCronExpression,
|
||||
} from '@/lib/schedules/utils'
|
||||
|
||||
describe('Schedule Utilities', () => {
|
||||
@@ -102,6 +103,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [12, 0],
|
||||
monthlyDay: 15,
|
||||
monthlyTime: [14, 30],
|
||||
cronExpression: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,6 +129,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0], // Default
|
||||
monthlyDay: 1, // Default
|
||||
monthlyTime: [9, 0], // Default
|
||||
cronExpression: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -143,6 +146,7 @@ describe('Schedule Utilities', () => {
|
||||
monthlyDay: 15,
|
||||
monthlyTime: [14, 30] as [number, number],
|
||||
timezone: 'UTC',
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
// Minutes (every 15 minutes)
|
||||
@@ -196,6 +200,7 @@ describe('Schedule Utilities', () => {
|
||||
monthlyDay: 15,
|
||||
monthlyTime: [14, 30] as [number, number],
|
||||
timezone: 'UTC',
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
expect(generateCronExpression('minutes', standardScheduleValues)).toBe('*/15 * * * *')
|
||||
@@ -230,6 +235,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
||||
@@ -254,6 +260,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
||||
@@ -275,6 +282,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('hourly', scheduleValues)
|
||||
@@ -297,6 +305,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
||||
@@ -320,6 +329,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [10, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('weekly', scheduleValues)
|
||||
@@ -342,6 +352,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 15,
|
||||
monthlyTime: [14, 30] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
||||
@@ -366,6 +377,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
// Last ran 10 minutes ago
|
||||
@@ -393,6 +405,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
||||
@@ -413,6 +426,7 @@ describe('Schedule Utilities', () => {
|
||||
weeklyTime: [9, 0] as [number, number],
|
||||
monthlyDay: 1,
|
||||
monthlyTime: [9, 0] as [number, number],
|
||||
cronExpression: null,
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
||||
@@ -423,6 +437,50 @@ describe('Schedule Utilities', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateCronExpression', () => {
|
||||
it.concurrent('should validate correct cron expressions', () => {
|
||||
expect(validateCronExpression('0 9 * * *')).toEqual({
|
||||
isValid: true,
|
||||
nextRun: expect.any(Date),
|
||||
})
|
||||
expect(validateCronExpression('*/15 * * * *')).toEqual({
|
||||
isValid: true,
|
||||
nextRun: expect.any(Date),
|
||||
})
|
||||
expect(validateCronExpression('30 14 15 * *')).toEqual({
|
||||
isValid: true,
|
||||
nextRun: expect.any(Date),
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should reject invalid cron expressions', () => {
|
||||
expect(validateCronExpression('invalid')).toEqual({
|
||||
isValid: false,
|
||||
error: expect.stringContaining('invalid'),
|
||||
})
|
||||
expect(validateCronExpression('60 * * * *')).toEqual({
|
||||
isValid: false,
|
||||
error: expect.any(String),
|
||||
})
|
||||
expect(validateCronExpression('')).toEqual({
|
||||
isValid: false,
|
||||
error: 'Cron expression cannot be empty',
|
||||
})
|
||||
expect(validateCronExpression(' ')).toEqual({
|
||||
isValid: false,
|
||||
error: 'Cron expression cannot be empty',
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should detect impossible cron expressions', () => {
|
||||
// This would be February 31st - impossible date
|
||||
expect(validateCronExpression('0 0 31 2 *')).toEqual({
|
||||
isValid: false,
|
||||
error: 'Cron expression produces no future occurrences',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCronToHumanReadable', () => {
|
||||
it.concurrent('should parse common cron patterns', () => {
|
||||
expect(parseCronToHumanReadable('* * * * *')).toBe('Every minute')
|
||||
|
||||
@@ -1,8 +1,49 @@
|
||||
import { Cron } from 'croner'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
|
||||
const logger = createLogger('ScheduleUtils')
|
||||
|
||||
/**
|
||||
* Validates a cron expression and returns validation results
|
||||
* @param cronExpression - The cron expression to validate
|
||||
* @returns Validation result with isValid flag, error message, and next run date
|
||||
*/
|
||||
export function validateCronExpression(cronExpression: string): {
|
||||
isValid: boolean
|
||||
error?: string
|
||||
nextRun?: Date
|
||||
} {
|
||||
if (!cronExpression?.trim()) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Cron expression cannot be empty',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cron = new Cron(cronExpression)
|
||||
const nextRun = cron.nextRun()
|
||||
|
||||
if (!nextRun) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Cron expression produces no future occurrences',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
nextRun,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: error instanceof Error ? error.message : 'Invalid cron expression syntax',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubBlockValue {
|
||||
value: string
|
||||
}
|
||||
@@ -60,6 +101,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
|
||||
weeklyTime: [number, number]
|
||||
monthlyDay: number
|
||||
monthlyTime: [number, number]
|
||||
cronExpression: string | null
|
||||
timezone: string
|
||||
} {
|
||||
// Extract schedule time (common field that can override others)
|
||||
@@ -92,6 +134,16 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
|
||||
const monthlyDay = Number.parseInt(monthlyDayStr) || 1
|
||||
const monthlyTime = parseTimeString(getSubBlockValue(starterBlock, 'monthlyTime'))
|
||||
|
||||
const cronExpression = getSubBlockValue(starterBlock, 'cronExpression') || null
|
||||
|
||||
// Validate cron expression if provided
|
||||
if (cronExpression) {
|
||||
const validation = validateCronExpression(cronExpression)
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Invalid cron expression: ${validation.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleTime,
|
||||
scheduleStartAt,
|
||||
@@ -103,6 +155,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
|
||||
weeklyTime,
|
||||
monthlyDay,
|
||||
monthlyTime,
|
||||
cronExpression,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,14 +295,10 @@ export function generateCronExpression(
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
const cronExpression = getSubBlockValue(
|
||||
scheduleValues as unknown as BlockState,
|
||||
'cronExpression'
|
||||
)
|
||||
if (!cronExpression) {
|
||||
throw new Error('No cron expression provided for custom schedule')
|
||||
if (!scheduleValues.cronExpression?.trim()) {
|
||||
throw new Error('Custom schedule requires a valid cron expression')
|
||||
}
|
||||
return cronExpression
|
||||
return scheduleValues.cronExpression
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -573,11 +622,29 @@ export const parseCronToHumanReadable = (cronExpression: string): string => {
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
if (month.includes(',')) {
|
||||
const monthNames = month.split(',').map((m) => months[Number.parseInt(m, 10) - 1])
|
||||
description += `on day ${dayOfMonth} of ${monthNames.join(', ')}`
|
||||
} else if (month.includes('/')) {
|
||||
// Handle interval patterns like */3, 1/3, etc.
|
||||
const interval = month.split('/')[1]
|
||||
description += `on day ${dayOfMonth} every ${interval} months`
|
||||
} else if (month.includes('-')) {
|
||||
// Handle range patterns like 1-6
|
||||
const [start, end] = month.split('-').map((m) => Number.parseInt(m, 10))
|
||||
const startMonth = months[start - 1]
|
||||
const endMonth = months[end - 1]
|
||||
description += `on day ${dayOfMonth} from ${startMonth} to ${endMonth}`
|
||||
} else {
|
||||
description += `on day ${dayOfMonth} of ${months[Number.parseInt(month, 10) - 1]}`
|
||||
// Handle specific month numbers
|
||||
const monthIndex = Number.parseInt(month, 10) - 1
|
||||
const monthName = months[monthIndex]
|
||||
if (monthName) {
|
||||
description += `on day ${dayOfMonth} of ${monthName}`
|
||||
} else {
|
||||
description += `on day ${dayOfMonth} of month ${month}`
|
||||
}
|
||||
}
|
||||
} else if (dayOfWeek !== '*') {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"@browserbasehq/stagehand": "^2.0.0",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@next/font": "14.2.15",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-collector": "^0.25.0",
|
||||
"@opentelemetry/exporter-jaeger": "^2.0.0",
|
||||
|
||||
@@ -125,9 +125,16 @@ export function setupSubblocksHandlers(
|
||||
serverTimestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Subblock update in workflow ${workflowId}: ${blockId}.${subblockId}`)
|
||||
logger.debug(`Subblock update in workflow ${workflowId}: ${blockId}.${subblockId}`)
|
||||
} else if (operationId) {
|
||||
// Block was deleted - notify client that operation completed (but didn't update anything)
|
||||
socket.emit('operation-failed', {
|
||||
operationId,
|
||||
error: 'Block no longer exists',
|
||||
retryable: false, // No point retrying for deleted blocks
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling subblock update:', error)
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ interface OperationQueueState {
|
||||
|
||||
addToQueue: (operation: Omit<QueuedOperation, 'timestamp' | 'retryCount' | 'status'>) => void
|
||||
confirmOperation: (operationId: string) => void
|
||||
failOperation: (operationId: string) => void
|
||||
failOperation: (operationId: string, retryable?: boolean) => void
|
||||
handleOperationTimeout: (operationId: string) => void
|
||||
processNextOperation: () => void
|
||||
cancelOperationsForBlock: (blockId: string) => void
|
||||
|
||||
triggerOfflineMode: () => void
|
||||
clearError: () => void
|
||||
@@ -211,7 +212,7 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
|
||||
get().processNextOperation()
|
||||
},
|
||||
|
||||
failOperation: (operationId: string) => {
|
||||
failOperation: (operationId: string, retryable = true) => {
|
||||
const state = get()
|
||||
const operation = state.operations.find((op) => op.id === operationId)
|
||||
if (!operation) {
|
||||
@@ -239,6 +240,18 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
|
||||
}
|
||||
}
|
||||
|
||||
if (!retryable) {
|
||||
logger.debug('Operation marked as non-retryable, removing from queue', { operationId })
|
||||
|
||||
set((state) => ({
|
||||
operations: state.operations.filter((op) => op.id !== operationId),
|
||||
isProcessing: false,
|
||||
}))
|
||||
|
||||
get().processNextOperation()
|
||||
return
|
||||
}
|
||||
|
||||
if (operation.retryCount < 3) {
|
||||
const newRetryCount = operation.retryCount + 1
|
||||
const delay = 2 ** newRetryCount * 1000 // 2s, 4s, 8s
|
||||
@@ -338,6 +351,66 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
|
||||
operationTimeouts.set(nextOperation.id, timeoutId)
|
||||
},
|
||||
|
||||
cancelOperationsForBlock: (blockId: string) => {
|
||||
logger.debug('Canceling all operations for block', { blockId })
|
||||
|
||||
// Cancel all debounce timeouts for this block's subblocks
|
||||
const keysToDelete: string[] = []
|
||||
for (const [key, timeout] of subblockDebounceTimeouts.entries()) {
|
||||
if (key.startsWith(`${blockId}-`)) {
|
||||
clearTimeout(timeout)
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
}
|
||||
keysToDelete.forEach((key) => subblockDebounceTimeouts.delete(key))
|
||||
|
||||
// Find and cancel operation timeouts for operations related to this block
|
||||
const state = get()
|
||||
const operationsToCancel = state.operations.filter(
|
||||
(op) =>
|
||||
(op.operation.target === 'block' && op.operation.payload?.id === blockId) ||
|
||||
(op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId)
|
||||
)
|
||||
|
||||
// Cancel timeouts for these operations
|
||||
operationsToCancel.forEach((op) => {
|
||||
const operationTimeout = operationTimeouts.get(op.id)
|
||||
if (operationTimeout) {
|
||||
clearTimeout(operationTimeout)
|
||||
operationTimeouts.delete(op.id)
|
||||
}
|
||||
|
||||
const retryTimeout = retryTimeouts.get(op.id)
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout)
|
||||
retryTimeouts.delete(op.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove all operations for this block (both pending and processing)
|
||||
const newOperations = state.operations.filter(
|
||||
(op) =>
|
||||
!(
|
||||
(op.operation.target === 'block' && op.operation.payload?.id === blockId) ||
|
||||
(op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId)
|
||||
)
|
||||
)
|
||||
|
||||
set({
|
||||
operations: newOperations,
|
||||
isProcessing: false, // Reset processing state in case we removed the current operation
|
||||
})
|
||||
|
||||
logger.debug('Cancelled operations for block', {
|
||||
blockId,
|
||||
cancelledDebounceTimeouts: keysToDelete.length,
|
||||
cancelledOperations: operationsToCancel.length,
|
||||
})
|
||||
|
||||
// Process next operation if there are any remaining
|
||||
get().processNextOperation()
|
||||
},
|
||||
|
||||
triggerOfflineMode: () => {
|
||||
logger.error('Operation failed after retries - triggering offline mode')
|
||||
|
||||
@@ -369,6 +442,7 @@ export function useOperationQueue() {
|
||||
confirmOperation: store.confirmOperation,
|
||||
failOperation: store.failOperation,
|
||||
processNextOperation: store.processNextOperation,
|
||||
cancelOperationsForBlock: store.cancelOperationsForBlock,
|
||||
triggerOfflineMode: store.triggerOfflineMode,
|
||||
clearError: store.clearError,
|
||||
}
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -65,7 +65,6 @@
|
||||
"@browserbasehq/stagehand": "^2.0.0",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@next/font": "14.2.15",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-collector": "^0.25.0",
|
||||
"@opentelemetry/exporter-jaeger": "^2.0.0",
|
||||
@@ -612,8 +611,6 @@
|
||||
|
||||
"@next/env": ["@next/env@15.3.4", "", {}, "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="],
|
||||
|
||||
"@next/font": ["@next/font@14.2.15", "", { "peerDependencies": { "next": "*" } }, "sha512-QopYhBmCDDrNDynbi+ZD1hDZXmQXVFo7TmAFp4DQgO/kogz1OLbQ92hPigJbj572eZ3GaaVxNIyYVn3/eAsehg=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw=="],
|
||||
|
||||
20515
package-lock.json
generated
20515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user