Compare commits

...

9 Commits

Author SHA1 Message Date
Waleed Latif
8f71684dcb v0.3.2: improvement + fix + feat
v0.3.2: improvement + fix + feat
2025-07-16 15:07:42 -07:00
Waleed Latif
92fe353f44 fix(subflow): fixed subflow execution regardless of path decision (#707)
* fix typo in docs file

* fix(subflows): fixed subflows executing irrespective of active path

* added routing strategy

* reorganized executor

* brought folder renaming inline

* cleanup
2025-07-16 14:21:32 -07:00
Emir Karabeg
4c6c7272c5 fix: permissions check for duplicating workflow (#706) 2025-07-16 14:07:31 -07:00
Vikhyath Mondreti
55a9adfdda improvement(voice): interrupt UI + mute mic while agent is talking (#705)
* improvement(voice): interrupt UI + live transcription

* cleanup logs

* remove cross
2025-07-16 13:38:51 -07:00
Waleed Latif
bdfe7e9b99 fix(invitation): allow admins to remove members from workspace (#701)
* fix(invitation): added ability for admins to remove members of their workspace

* lint

* remove references to workspace_member db table

* remove deprecated @next/font

* only allow admin to rename workspace

* bring workflow name change inline, remove dialog
2025-07-15 22:35:35 -07:00
Vikhyath Mondreti
27c248a70c fix(sockets): delete block case (#703) 2025-07-15 21:21:59 -07:00
Adam Gough
19ca9c78b4 fix(schedule): fix for custom cron (#699)
* fix: added cronExpression field and fixed formatting

* fix: modified the test.ts file #699

* added additional validation

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2025-07-15 20:42:34 -07:00
Emir Karabeg
b13f339327 feat(sidebar): sidebar toggle and search (#700)
* fix: sidebar toggle

* feat: search complete
2025-07-15 20:41:20 -07:00
Waleed Latif
aade4bf3ae fix(sockets): remove package-lock 2025-07-15 13:43:40 -07:00
81 changed files with 5355 additions and 22263 deletions

View File

@@ -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,
}))

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 })
}

View File

@@ -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)

View 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' })
})
})

View 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 })
}
}

View File

@@ -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)

View 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()
})
})
})

View File

@@ -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

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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,

View File

@@ -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]

View File

@@ -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='w-6 h-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='w-80 h-80 md:w-96 md:h-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 h-24 flex items-center justify-center'>
{currentTranscript && (
<div className='max-w-2xl px-8'>
<p className='text-xl text-gray-700 text-center leading-relaxed overflow-hidden'>
{currentTranscript}
</p>
</div>
)}
</div>
{/* Status */}
<p className='text-lg text-gray-600 mb-8 text-center'>
{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>

View File

@@ -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>

View File

@@ -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}
/>
)

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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' />

View File

@@ -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>
)
}

View File

@@ -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-[212px]' 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} />
</>
)
}

View File

@@ -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,

View File

@@ -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

View File

@@ -628,6 +628,7 @@ export const workspace = pgTable('workspace', {
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
// @deprecated - Use permissions table instead. This table is kept for backward compatibility during migration.
export const workspaceMember = pgTable(
'workspace_member',
{

View 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)
}

View File

@@ -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?' },

View File

@@ -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(

View File

@@ -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: {},

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(

View File

@@ -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',

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 }

View File

@@ -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')

View File

@@ -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,

View File

@@ -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)
})
})
})

View File

@@ -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
*/

View File

@@ -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)
})
})
})

View File

@@ -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
*/

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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 || ''

View File

@@ -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)
})
})

View File

@@ -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(

View File

@@ -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: {},

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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')

View File

@@ -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)
})
})
})

View File

@@ -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)
}
}

View File

@@ -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)
},

View File

@@ -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')

View 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 skip flow control blocks', () => {
expect(Routing.shouldSkipConnection(undefined, BlockType.PARALLEL)).toBe(true)
expect(Routing.shouldSkipConnection('source', BlockType.LOOP)).toBe(true)
})
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 not skip routing connections', () => {
expect(Routing.shouldSkipConnection('condition-test-if', BlockType.FUNCTION)).toBe(false)
expect(Routing.shouldSkipConnection('condition-test-else', BlockType.AGENT)).toBe(false)
})
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,
})
})
})
})

View File

@@ -0,0 +1,97 @@
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 blocks
if (Routing.shouldSkipInSelectiveActivation(targetBlockType)) {
return true
}
// Skip flow control specific connections
const flowControlHandles = [
'parallel-start-source',
'parallel-end-source',
'loop-start-source',
'loop-end-source',
]
return flowControlHandles.includes(sourceHandle || '')
}
}

View 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')
})
})

View 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)
})
})

View 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)
})
})

View 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)
})
})

View File

@@ -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({

View File

@@ -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')

View File

@@ -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(

View File

@@ -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' },
])
})
})
})

View File

@@ -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')

View File

@@ -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']

View File

@@ -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",

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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

File diff suppressed because it is too large Load Diff