Compare commits

..

13 Commits

Author SHA1 Message Date
waleed
c6821bf547 feat(generic): enable results tab wiring 2026-03-27 17:26:13 -07:00
Waleed
a7c1e510e6 fix(knowledge): reject non-alphanumeric file extensions from document names (#3816)
* fix(knowledge): reject non-alphanumeric file extensions from document names

* fix(knowledge): improve error message when extension is non-alphanumeric
2026-03-27 16:58:07 -07:00
Vikhyath Mondreti
271624a402 fix(linear): add default null for after cursor (#3814) 2026-03-27 15:57:40 -07:00
Vikhyath Mondreti
dda012eae9 feat(concurrency): bullmq based concurrency control system (#3605)
* feat(concurrency): bullmq based queueing system

* fix bun lock

* remove manual execs off queues

* address comments

* fix legacy team limits

* cleanup enterprise typing code

* inline child triggers

* fix status check

* address more comments

* optimize reconciler scan

* remove dead code

* add to landing page

* Add load testing framework

* update bullmq

* fix

* fix headless path

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-27 13:11:35 -07:00
Vikhyath Mondreti
2dd6d3d1e6 fix(import): dedup workflow name (#3813) 2026-03-27 13:09:49 -07:00
Waleed
b90bb75cda fix(knowledge): connector spinner race condition + connectors column (#3812)
* fix(knowledge): scope sync/update state per-connector to prevent race conditions

* feat(knowledge): add connectors column to knowledge base list

* refactor(knowledge): extract set helpers, handleTogglePause, and filter-before-map

* refactor(knowledge): use onSettled for syncingIds cleanup, consistent with updatingIds
2026-03-27 12:54:14 -07:00
Waleed
fb233d003d fix(flyout): align inline rename with non-rename styling (#3811) 2026-03-27 12:39:23 -07:00
Waleed
34df3333d1 fix(knowledge): fix search input flicker on clear and plan display name fallback (#3810) 2026-03-27 12:23:41 -07:00
Waleed
23677d41a0 improvement(sidebar): collapsed sidebar UX, quick-create, hover consistency, and UI polish (#3807)
* improvement(sidebar): collapsed sidebar UX, quick-create, hover consistency, and UI polish

Made-with: Cursor

* fix(sidebar): use stable handlers for root workflow items instead of inline lambdas

Made-with: Cursor

* fix(sidebar): reset actionsOpen state before triggering rename in collapsed dropdown

Made-with: Cursor
2026-03-27 12:08:17 -07:00
Waleed
a489f91085 fix(knowledge): show spinner on connector chip while syncing (#3808)
* fix(knowledge): show spinner on connector chip while syncing

* fix(knowledge): scope sync spinner to mutation lifetime, not cooldown
2026-03-27 12:04:11 -07:00
Adithya Krishna
ed6e7845cc chore: fix rerenders on files (#3805)
* chore: fix rerenders on files

* chore: fix review changes
2026-03-27 11:48:51 -07:00
Adithya Krishna
e698f9fe14 chore: remove font antialiasing (#3806)
* chore: fix antialiasing

* chore: remove antialiasing
2026-03-27 11:29:37 -07:00
Adithya Krishna
db1798267e feat: update sidebar and knowledge (#3804)
* feat: update sidebar and knowledge

* chore: fix rernders on knowledge

* chore: fix review changes

* chore: fix review changes
2026-03-27 09:39:41 -07:00
165 changed files with 7846 additions and 1892 deletions

View File

@@ -195,6 +195,17 @@ By default, your usage is capped at the credits included in your plan. To allow
Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
### Concurrent Execution Limits
| Plan | Concurrent Executions |
|------|----------------------|
| **Free** | 5 |
| **Pro** | 50 |
| **Max / Team** | 200 |
| **Enterprise** | 200 (customizable) |
Concurrent execution limits control how many workflow executions can run simultaneously within a workspace. When the limit is reached, new executions are queued and admitted as running executions complete. Manual runs from the editor are not subject to these limits.
### File Storage
| Plan | Storage |

View File

@@ -25,6 +25,7 @@ const PRICING_TIERS: PricingTier[] = [
'5GB file storage',
'3 tables · 1,000 rows each',
'5 min execution limit',
'5 concurrent/workspace',
'7-day log retention',
'CLI/SDK/MCP Access',
],
@@ -42,6 +43,7 @@ const PRICING_TIERS: PricingTier[] = [
'50GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 150 runs/min',
'50 concurrent/workspace',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
@@ -59,6 +61,7 @@ const PRICING_TIERS: PricingTier[] = [
'500GB file storage',
'25 tables · 5,000 rows each',
'50 min execution · 300 runs/min',
'200 concurrent/workspace',
'Unlimited log retention',
'CLI/SDK/MCP Access',
],
@@ -75,6 +78,7 @@ const PRICING_TIERS: PricingTier[] = [
'Custom file storage',
'10,000 tables · 1M rows each',
'Custom execution limits',
'Custom concurrency limits',
'Unlimited log retention',
'SSO & SCIM · SOC2 & HIPAA',
'Self hosting · Dedicated support',

View File

@@ -188,7 +188,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--border-1: #e0e0e0; /* stronger border */
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
--surface-7: #d9d9d9;
--surface-active: #ececec; /* hover/active state */
--surface-hover: #f2f2f2; /* hover state */
--surface-active: #ececec; /* active/selected state */
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
@@ -342,7 +343,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--border-1: #3d3d3d;
--surface-6: #454545;
--surface-7: #505050;
--surface-active: #2c2c2c; /* hover/active state */
--surface-hover: #262626; /* hover state */
--surface-active: #2c2c2c; /* active/selected state */
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
@@ -501,9 +503,6 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
caret-color: var(--text-primary);
}
body {
@apply antialiased;
}
::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);

View File

@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
@@ -539,10 +540,26 @@ export async function POST(req: NextRequest) {
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
const nsExecutionId = crypto.randomUUID()
const nsRunId = crypto.randomUUID()
if (actualChatId) {
await createRunSegment({
id: nsRunId,
executionId: nsExecutionId,
chatId: actualChatId,
userId: authenticatedUserId,
workflowId,
streamId: userMessageIdToUse,
}).catch(() => {})
}
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
userId: authenticatedUserId,
workflowId,
chatId: actualChatId,
executionId: nsExecutionId,
runId: nsRunId,
goRoute: '/api/copilot',
autoExecuteTools: true,
interactive: true,

View File

@@ -0,0 +1,160 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckHybridAuth,
mockGetDispatchJobRecord,
mockGetJobQueue,
mockVerifyWorkflowAccess,
mockGetWorkflowById,
} = vi.hoisted(() => ({
mockCheckHybridAuth: vi.fn(),
mockGetDispatchJobRecord: vi.fn(),
mockGetJobQueue: vi.fn(),
mockVerifyWorkflowAccess: vi.fn(),
mockGetWorkflowById: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
}))
vi.mock('@/lib/core/async-jobs', () => ({
JOB_STATUS: {
PENDING: 'pending',
PROCESSING: 'processing',
COMPLETED: 'completed',
FAILED: 'failed',
},
getJobQueue: mockGetJobQueue,
}))
vi.mock('@/lib/core/workspace-dispatch/store', () => ({
getDispatchJobRecord: mockGetDispatchJobRecord,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('request-1'),
}))
vi.mock('@/socket/middleware/permissions', () => ({
verifyWorkflowAccess: mockVerifyWorkflowAccess,
}))
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: mockGetWorkflowById,
}))
import { GET } from './route'
function createMockRequest(): NextRequest {
return {
headers: {
get: () => null,
},
} as NextRequest
}
describe('GET /api/jobs/[jobId]', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckHybridAuth.mockResolvedValue({
success: true,
userId: 'user-1',
apiKeyType: undefined,
workspaceId: undefined,
})
mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true })
mockGetWorkflowById.mockResolvedValue({
id: 'workflow-1',
workspaceId: 'workspace-1',
})
mockGetJobQueue.mockResolvedValue({
getJob: vi.fn().mockResolvedValue(null),
})
})
it('returns dispatcher-aware waiting status with metadata', async () => {
mockGetDispatchJobRecord.mockResolvedValue({
id: 'dispatch-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: {},
metadata: {
workflowId: 'workflow-1',
},
priority: 10,
status: 'waiting',
createdAt: 1000,
admittedAt: 2000,
})
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'dispatch-1' }),
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.status).toBe('waiting')
expect(body.metadata.queueName).toBe('workflow-execution')
expect(body.metadata.lane).toBe('runtime')
expect(body.metadata.workspaceId).toBe('workspace-1')
})
it('returns completed output from dispatch state', async () => {
mockGetDispatchJobRecord.mockResolvedValue({
id: 'dispatch-2',
workspaceId: 'workspace-1',
lane: 'interactive',
queueName: 'workflow-execution',
bullmqJobName: 'direct-workflow-execution',
bullmqPayload: {},
metadata: {
workflowId: 'workflow-1',
},
priority: 1,
status: 'completed',
createdAt: 1000,
startedAt: 2000,
completedAt: 7000,
output: { success: true },
})
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'dispatch-2' }),
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.status).toBe('completed')
expect(body.output).toEqual({ success: true })
expect(body.metadata.duration).toBe(5000)
})
it('returns 404 when neither dispatch nor BullMQ job exists', async () => {
mockGetDispatchJobRecord.mockResolvedValue(null)
const response = await GET(createMockRequest(), {
params: Promise.resolve({ jobId: 'missing-job' }),
})
expect(response.status).toBe(404)
})
})

View File

@@ -1,8 +1,10 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue, JOB_STATUS } from '@/lib/core/async-jobs'
import { getJobQueue } from '@/lib/core/async-jobs'
import { generateRequestId } from '@/lib/core/utils/request'
import { presentDispatchOrJobStatus } from '@/lib/core/workspace-dispatch/status'
import { getDispatchJobRecord } from '@/lib/core/workspace-dispatch/store'
import { createErrorResponse } from '@/app/api/workflows/utils'
const logger = createLogger('TaskStatusAPI')
@@ -23,68 +25,54 @@ export async function GET(
const authenticatedUserId = authResult.userId
const dispatchJob = await getDispatchJobRecord(taskId)
const jobQueue = await getJobQueue()
const job = await jobQueue.getJob(taskId)
const job = dispatchJob ? null : await jobQueue.getJob(taskId)
if (!job) {
if (!job && !dispatchJob) {
return createErrorResponse('Task not found', 404)
}
if (job.metadata?.workflowId) {
const metadataToCheck = dispatchJob?.metadata ?? job?.metadata
if (metadataToCheck?.workflowId) {
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
const accessCheck = await verifyWorkflowAccess(
authenticatedUserId,
job.metadata.workflowId as string
metadataToCheck.workflowId as string
)
if (!accessCheck.hasAccess) {
logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`)
logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
return createErrorResponse('Access denied', 403)
}
if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) {
const { getWorkflowById } = await import('@/lib/workflows/utils')
const workflow = await getWorkflowById(job.metadata.workflowId as string)
const workflow = await getWorkflowById(metadataToCheck.workflowId as string)
if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) {
return createErrorResponse('API key is not authorized for this workspace', 403)
}
}
} else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`)
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
return createErrorResponse('Access denied', 403)
} else if (!job.metadata?.userId && !job.metadata?.workflowId) {
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
logger.warn(`[${requestId}] Access denied to job ${taskId}`)
return createErrorResponse('Access denied', 403)
}
const mappedStatus = job.status === JOB_STATUS.PENDING ? 'queued' : job.status
const presented = presentDispatchOrJobStatus(dispatchJob, job)
const response: any = {
success: true,
taskId,
status: mappedStatus,
metadata: {
startedAt: job.startedAt,
},
status: presented.status,
metadata: presented.metadata,
}
if (job.status === JOB_STATUS.COMPLETED) {
response.output = job.output
response.metadata.completedAt = job.completedAt
if (job.startedAt && job.completedAt) {
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
}
}
if (job.status === JOB_STATUS.FAILED) {
response.error = job.error
response.metadata.completedAt = job.completedAt
if (job.startedAt && job.completedAt) {
response.metadata.duration = job.completedAt.getTime() - job.startedAt.getTime()
}
}
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
response.estimatedDuration = 300000
if (presented.output !== undefined) response.output = presented.output
if (presented.error !== undefined) response.error = presented.error
if (presented.estimatedDuration !== undefined) {
response.estimatedDuration = presented.estimatedDuration
}
return NextResponse.json(response)

View File

@@ -18,6 +18,7 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent'
@@ -727,10 +728,25 @@ async function handleBuildToolCall(
chatId,
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
const messageId = requestPayload.messageId as string
await createRunSegment({
id: runId,
executionId,
chatId,
userId,
workflowId: resolved.workflowId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workflowId: resolved.workflowId,
chatId,
executionId,
runId,
goRoute: '/api/mcp',
autoExecuteTools: true,
timeout: ORCHESTRATION_TIMEOUT_MS,

View File

@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
@@ -71,10 +72,24 @@ export async function POST(req: NextRequest) {
...(userPermission ? { userPermission } : {}),
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
await createRunSegment({
id: runId,
executionId,
chatId: effectiveChatId,
userId,
workspaceId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId,
workspaceId,
chatId: effectiveChatId,
executionId,
runId,
goRoute: '/api/mothership/execute',
autoExecuteTools: true,
interactive: false,

View File

@@ -9,10 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockVerifyCronAuth,
mockExecuteScheduleJob,
mockExecuteJobInline,
mockFeatureFlags,
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -22,6 +24,7 @@ const {
const mockDbSet = vi.fn().mockReturnValue({ where: mockDbWhere })
const mockDbUpdate = vi.fn().mockReturnValue({ set: mockDbSet })
const mockEnqueue = vi.fn().mockResolvedValue('job-id-1')
const mockEnqueueWorkspaceDispatch = vi.fn().mockResolvedValue('job-id-1')
const mockStartJob = vi.fn().mockResolvedValue(undefined)
const mockCompleteJob = vi.fn().mockResolvedValue(undefined)
const mockMarkJobFailed = vi.fn().mockResolvedValue(undefined)
@@ -29,6 +32,7 @@ const {
return {
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
mockFeatureFlags: {
isTriggerDevEnabled: false,
isHosted: false,
@@ -38,6 +42,7 @@ const {
mockDbReturning,
mockDbUpdate,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
mockStartJob,
mockCompleteJob,
mockMarkJobFailed,
@@ -50,6 +55,8 @@ vi.mock('@/lib/auth/internal', () => ({
vi.mock('@/background/schedule-execution', () => ({
executeScheduleJob: mockExecuteScheduleJob,
executeJobInline: mockExecuteJobInline,
releaseScheduleLock: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags)
@@ -68,6 +75,22 @@ vi.mock('@/lib/core/async-jobs', () => ({
shouldExecuteInline: vi.fn().mockReturnValue(false),
}))
vi.mock('@/lib/core/bullmq', () => ({
isBullMQEnabled: vi.fn().mockReturnValue(true),
createBullMQJobData: vi.fn((payload: unknown) => ({ payload })),
}))
vi.mock('@/lib/core/workspace-dispatch', () => ({
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
}))
vi.mock('@/lib/workflows/utils', () => ({
getWorkflowById: vi.fn().mockResolvedValue({
id: 'workflow-1',
workspaceId: 'workspace-1',
}),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -142,6 +165,18 @@ const MULTIPLE_SCHEDULES = [
},
]
const SINGLE_JOB = [
{
id: 'job-1',
cronExpression: '0 * * * *',
failedCount: 0,
lastQueuedAt: undefined,
sourceUserId: 'user-1',
sourceWorkspaceId: 'workspace-1',
sourceType: 'job',
},
]
function createMockRequest(): NextRequest {
const mockHeaders = new Map([
['authorization', 'Bearer test-cron-secret'],
@@ -211,30 +246,44 @@ describe('Scheduled Workflow Execution API Route', () => {
expect(data).toHaveProperty('executedCount', 2)
})
it('should queue mothership jobs to BullMQ when available', async () => {
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB)
const response = await GET(createMockRequest())
expect(response.status).toBe(200)
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'mothership-job-execution',
bullmqJobName: 'mothership-job-execution',
bullmqPayload: {
payload: {
scheduleId: 'job-1',
cronExpression: '0 * * * *',
failedCount: 0,
now: expect.any(String),
},
},
})
)
expect(mockExecuteJobInline).not.toHaveBeenCalled()
})
it('should enqueue preassigned correlation metadata for schedules', async () => {
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
const response = await GET(createMockRequest())
expect(response.status).toBe(200)
expect(mockEnqueue).toHaveBeenCalledWith(
'schedule-execution',
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
scheduleId: 'schedule-1',
workflowId: 'workflow-1',
executionId: 'schedule-execution-1',
requestId: 'test-request-id',
correlation: {
executionId: 'schedule-execution-1',
requestId: 'test-request-id',
source: 'schedule',
workflowId: 'workflow-1',
scheduleId: 'schedule-1',
triggerType: 'schedule',
scheduledFor: '2025-01-01T00:00:00.000Z',
},
}),
{
id: 'schedule-execution-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'schedule-execution',
bullmqJobName: 'schedule-execution',
metadata: {
workflowId: 'workflow-1',
correlation: {
@@ -247,7 +296,7 @@ describe('Scheduled Workflow Execution API Route', () => {
scheduledFor: '2025-01-01T00:00:00.000Z',
},
},
}
})
)
})
})

View File

@@ -5,7 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
import { generateRequestId } from '@/lib/core/utils/request'
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
import {
executeJobInline,
executeScheduleJob,
@@ -73,6 +75,8 @@ export async function GET(request: NextRequest) {
cronExpression: workflowSchedule.cronExpression,
failedCount: workflowSchedule.failedCount,
lastQueuedAt: workflowSchedule.lastQueuedAt,
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
sourceUserId: workflowSchedule.sourceUserId,
sourceType: workflowSchedule.sourceType,
})
@@ -111,9 +115,40 @@ export async function GET(request: NextRequest) {
}
try {
const jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
})
const { getWorkflowById } = await import('@/lib/workflows/utils')
const resolvedWorkflow = schedule.workflowId
? await getWorkflowById(schedule.workflowId)
: null
const resolvedWorkspaceId = resolvedWorkflow?.workspaceId
let jobId: string
if (isBullMQEnabled()) {
if (!resolvedWorkspaceId) {
throw new Error(
`Missing workspace for scheduled workflow ${schedule.workflowId}; refusing to bypass workspace admission`
)
}
jobId = await enqueueWorkspaceDispatch({
id: executionId,
workspaceId: resolvedWorkspaceId,
lane: 'runtime',
queueName: 'schedule-execution',
bullmqJobName: 'schedule-execution',
bullmqPayload: createBullMQJobData(payload, {
workflowId: schedule.workflowId ?? undefined,
correlation,
}),
metadata: {
workflowId: schedule.workflowId ?? undefined,
correlation,
},
})
} else {
jobId = await jobQueue.enqueue('schedule-execution', payload, {
metadata: { workflowId: schedule.workflowId ?? undefined, correlation },
})
}
logger.info(
`[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}`
)
@@ -165,7 +200,7 @@ export async function GET(request: NextRequest) {
}
})
// Jobs always execute inline (no TriggerDev)
// Mothership jobs use BullMQ when available, otherwise direct inline execution.
const jobPromises = dueJobs.map(async (job) => {
const queueTime = job.lastQueuedAt ?? queuedAt
const payload = {
@@ -176,7 +211,24 @@ export async function GET(request: NextRequest) {
}
try {
await executeJobInline(payload)
if (isBullMQEnabled()) {
if (!job.sourceWorkspaceId || !job.sourceUserId) {
throw new Error(`Mothership job ${job.id} is missing workspace/user ownership`)
}
await enqueueWorkspaceDispatch({
workspaceId: job.sourceWorkspaceId!,
lane: 'runtime',
queueName: 'mothership-job-execution',
bullmqJobName: 'mothership-job-execution',
bullmqPayload: createBullMQJobData(payload),
metadata: {
userId: job.sourceUserId,
},
})
} else {
await executeJobInline(payload)
}
} catch (error) {
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
error: error instanceof Error ? error.message : String(error),

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createRunSegment } from '@/lib/copilot/async-runs/repository'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
@@ -104,10 +105,24 @@ export async function POST(req: NextRequest) {
chatId,
}
const executionId = crypto.randomUUID()
const runId = crypto.randomUUID()
await createRunSegment({
id: runId,
executionId,
chatId,
userId: auth.userId,
workflowId: resolved.workflowId,
streamId: messageId,
}).catch(() => {})
const result = await orchestrateCopilotStream(requestPayload, {
userId: auth.userId,
workflowId: resolved.workflowId,
chatId,
executionId,
runId,
goRoute: '/api/mcp',
autoExecuteTools: parsed.autoExecuteTools,
timeout: parsed.timeout,

View File

@@ -1,6 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { DispatchQueueFullError } from '@/lib/core/workspace-dispatch'
import {
checkWebhookPreprocessing,
findAllWebhooksForPath,
@@ -41,10 +43,25 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string }> }
) {
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleWebhookPost(request, params)
} finally {
ticket.release()
}
}
async function handleWebhookPost(
request: NextRequest,
params: Promise<{ path: string }>
): Promise<NextResponse> {
const requestId = generateRequestId()
const { path } = await params
// Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.)
const earlyChallenge = await handleProviderChallenges({}, request, requestId, path)
if (earlyChallenge) {
return earlyChallenge
@@ -140,17 +157,30 @@ export async function POST(
continue
}
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
})
responses.push(response)
try {
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
})
responses.push(response)
} catch (error) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
throw error
}
}
// Return the last successful response, or a combined response for multiple webhooks
if (responses.length === 0) {
return new NextResponse('No webhooks processed successfully', { status: 500 })
}

View File

@@ -10,15 +10,18 @@ const {
mockAuthorizeWorkflowByWorkspacePermission,
mockPreprocessExecution,
mockEnqueue,
mockEnqueueWorkspaceDispatch,
} = vi.hoisted(() => ({
mockCheckHybridAuth: vi.fn(),
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockPreprocessExecution: vi.fn(),
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
mockEnqueueWorkspaceDispatch: vi.fn().mockResolvedValue('job-123'),
}))
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
hasExternalApiCredentials: vi.fn().mockReturnValue(true),
AuthType: {
SESSION: 'session',
API_KEY: 'api_key',
@@ -44,6 +47,16 @@ vi.mock('@/lib/core/async-jobs', () => ({
markJobFailed: vi.fn(),
}),
shouldExecuteInline: vi.fn().mockReturnValue(false),
shouldUseBullMQ: vi.fn().mockReturnValue(true),
}))
vi.mock('@/lib/core/bullmq', () => ({
createBullMQJobData: vi.fn((payload: unknown, metadata?: unknown) => ({ payload, metadata })),
}))
vi.mock('@/lib/core/workspace-dispatch', () => ({
enqueueWorkspaceDispatch: mockEnqueueWorkspaceDispatch,
waitForDispatchJob: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
@@ -132,22 +145,13 @@ describe('workflow execute async route', () => {
expect(response.status).toBe(202)
expect(body.executionId).toBe('execution-123')
expect(body.jobId).toBe('job-123')
expect(mockEnqueue).toHaveBeenCalledWith(
'workflow-execution',
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: 'workflow-1',
userId: 'actor-1',
executionId: 'execution-123',
requestId: 'req-12345678',
correlation: {
executionId: 'execution-123',
requestId: 'req-12345678',
source: 'workflow',
workflowId: 'workflow-1',
triggerType: 'manual',
},
}),
{
id: 'execution-123',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
metadata: {
workflowId: 'workflow-1',
userId: 'actor-1',
@@ -159,7 +163,7 @@ describe('workflow execute async route', () => {
triggerType: 'manual',
},
},
}
})
)
})
})

View File

@@ -2,8 +2,10 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { getJobQueue, shouldExecuteInline, shouldUseBullMQ } from '@/lib/core/async-jobs'
import { createBullMQJobData } from '@/lib/core/bullmq'
import {
createTimeoutAbortController,
getTimeoutErrorMessage,
@@ -12,6 +14,13 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
DispatchQueueFullError,
enqueueWorkspaceDispatch,
type WorkspaceDispatchLane,
waitForDispatchJob,
} from '@/lib/core/workspace-dispatch'
import { createBufferedExecutionStream } from '@/lib/execution/buffered-stream'
import {
buildNextCallChain,
parseCallChain,
@@ -33,6 +42,11 @@ import {
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import {
DIRECT_WORKFLOW_JOB_NAME,
type QueuedWorkflowExecutionPayload,
type QueuedWorkflowExecutionResult,
} from '@/lib/workflows/executor/queued-workflow-execution'
import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
@@ -104,6 +118,8 @@ const ExecuteWorkflowSchema = z.object({
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const INLINE_TRIGGER_TYPES = new Set<CoreTriggerType>(['manual', 'workflow'])
function resolveOutputIds(
selectedOutputs: string[] | undefined,
blocks: Record<string, any>
@@ -161,6 +177,7 @@ type AsyncExecutionParams = {
requestId: string
workflowId: string
userId: string
workspaceId: string
input: any
triggerType: CoreTriggerType
executionId: string
@@ -168,7 +185,8 @@ type AsyncExecutionParams = {
}
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params
const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
params
const correlation = {
executionId,
@@ -181,6 +199,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
const payload: WorkflowExecutionPayload = {
workflowId,
userId,
workspaceId,
input,
triggerType,
executionId,
@@ -190,22 +209,42 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
}
try {
const jobQueue = await getJobQueue()
const jobId = await jobQueue.enqueue('workflow-execution', payload, {
metadata: { workflowId, userId, correlation },
})
const useBullMQ = shouldUseBullMQ()
const jobQueue = useBullMQ ? null : await getJobQueue()
const jobId = useBullMQ
? await enqueueWorkspaceDispatch({
id: executionId,
workspaceId,
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: createBullMQJobData(payload, {
workflowId,
userId,
correlation,
}),
metadata: {
workflowId,
userId,
correlation,
},
})
: await jobQueue!.enqueue('workflow-execution', payload, {
metadata: { workflowId, userId, correlation },
})
logger.info(`[${requestId}] Queued async workflow execution`, {
workflowId,
jobId,
})
if (shouldExecuteInline()) {
if (shouldExecuteInline() && jobQueue) {
const inlineJobQueue = jobQueue
void (async () => {
try {
await jobQueue.startJob(jobId)
await inlineJobQueue.startJob(jobId)
const output = await executeWorkflowJob(payload)
await jobQueue.completeJob(jobId, output)
await inlineJobQueue.completeJob(jobId, output)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`[${requestId}] Async workflow execution failed`, {
@@ -213,7 +252,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
error: errorMessage,
})
try {
await jobQueue.markJobFailed(jobId, errorMessage)
await inlineJobQueue.markJobFailed(jobId, errorMessage)
} catch (markFailedError) {
logger.error(`[${requestId}] Failed to mark job as failed`, {
jobId,
@@ -239,6 +278,17 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
{ status: 202 }
)
} catch (error: any) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
logger.error(`[${requestId}] Failed to queue async execution`, error)
return NextResponse.json(
{ error: `Failed to queue async execution: ${error.message}` },
@@ -247,6 +297,31 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
}
}
async function enqueueDirectWorkflowExecution(
payload: QueuedWorkflowExecutionPayload,
priority: number,
lane: WorkspaceDispatchLane
) {
return enqueueWorkspaceDispatch({
id: payload.metadata.executionId,
workspaceId: payload.metadata.workspaceId,
lane,
queueName: 'workflow-execution',
bullmqJobName: DIRECT_WORKFLOW_JOB_NAME,
bullmqPayload: createBullMQJobData(payload, {
workflowId: payload.metadata.workflowId,
userId: payload.metadata.userId,
correlation: payload.metadata.correlation,
}),
metadata: {
workflowId: payload.metadata.workflowId,
userId: payload.metadata.userId,
correlation: payload.metadata.correlation,
},
priority,
})
}
/**
* POST /api/workflows/[id]/execute
*
@@ -254,6 +329,27 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
* Supports both SSE streaming (for interactive/manual runs) and direct JSON responses (for background jobs).
*/
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const isSessionRequest = req.headers.has('cookie') && !hasExternalApiCredentials(req.headers)
if (isSessionRequest) {
return handleExecutePost(req, params)
}
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}
try {
return await handleExecutePost(req, params)
} finally {
ticket.release()
}
}
async function handleExecutePost(
req: NextRequest,
params: Promise<{ id: string }>
): Promise<NextResponse | Response> {
const requestId = generateRequestId()
const { id: workflowId } = await params
@@ -584,6 +680,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
workflowId,
userId: actorUserId,
workspaceId,
input,
triggerType: loggingTriggerType,
executionId,
@@ -676,30 +773,116 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
if (!enableSSE) {
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
if (shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)) {
try {
const dispatchJobId = await enqueueDirectWorkflowExecution(
{
workflow,
metadata,
input: processedInput,
variables: executionVariables,
selectedOutputs,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
timeoutMs: preprocessResult.executionTimeout?.sync,
runFromBlock: resolvedRunFromBlock,
},
5,
'interactive'
)
const resultRecord = await waitForDispatchJob(
dispatchJobId,
(preprocessResult.executionTimeout?.sync ?? 300000) + 30000
)
if (resultRecord.status === 'failed') {
return NextResponse.json(
{
success: false,
executionId,
error: resultRecord.error ?? 'Workflow execution failed',
},
{ status: 500 }
)
}
const result = resultRecord.output as QueuedWorkflowExecutionResult
const resultForResponseBlock = {
success: result.success,
logs: result.logs,
output: result.output,
}
if (
auth.authType !== AuthType.INTERNAL_JWT &&
workflowHasResponseBlock(resultForResponseBlock)
) {
return createHttpResponseFromBlock(resultForResponseBlock)
}
return NextResponse.json(
{
success: result.success,
executionId,
output: result.output,
error: result.error,
metadata: result.metadata,
},
{ status: result.statusCode ?? 200 }
)
} catch (error: unknown) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Queued non-SSE execution failed: ${errorMessage}`)
return NextResponse.json(
{
success: false,
error: errorMessage,
},
{ status: 500 }
)
}
}
const timeoutController = createTimeoutAbortController(
preprocessResult.executionTimeout?.sync
)
try {
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
const snapshot = new ExecutionSnapshot(
metadata,
workflow,
@@ -809,6 +992,53 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
if (shouldUseDraftState) {
const shouldDispatchViaQueue = shouldUseBullMQ() && !INLINE_TRIGGER_TYPES.has(triggerType)
if (shouldDispatchViaQueue) {
const metadata: ExecutionMetadata = {
requestId,
executionId,
workflowId,
workspaceId,
userId: actorUserId,
sessionUserId: isClientSession ? userId : undefined,
workflowUserId: workflow.userId,
triggerType,
useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride,
callChain,
}
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
await enqueueDirectWorkflowExecution(
{
workflow,
metadata,
input: processedInput,
variables: executionVariables,
selectedOutputs,
includeFileBase64,
base64MaxBytes,
stopAfterBlockId,
timeoutMs: preprocessResult.executionTimeout?.sync,
runFromBlock: resolvedRunFromBlock,
streamEvents: true,
},
1,
'interactive'
)
return new NextResponse(createBufferedExecutionStream(executionId), {
headers: {
...SSE_HEADERS,
'X-Execution-Id': executionId,
},
})
}
logger.info(`[${requestId}] Using SSE console log streaming (manual execution)`)
} else {
logger.info(`[${requestId}] Using streaming API response`)
@@ -1277,6 +1507,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
},
})
} catch (error: any) {
if (error instanceof DispatchQueueFullError) {
return NextResponse.json(
{
error: 'Service temporarily at capacity',
message: error.message,
retryAfterSeconds: 10,
},
{ status: 503, headers: { 'Retry-After': '10' } }
)
}
logger.error(`[${requestId}] Failed to start workflow execution:`, error)
return NextResponse.json(
{ error: error.message || 'Failed to start workflow execution' },

View File

@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -25,6 +25,7 @@ const CreateWorkflowSchema = z.object({
workspaceId: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),
deduplicate: z.boolean().optional(),
})
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
@@ -126,12 +127,13 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const {
id: clientId,
name,
name: requestedName,
description,
color,
workspaceId,
folderId,
sortOrder: providedSortOrder,
deduplicate,
} = CreateWorkflowSchema.parse(body)
if (!workspaceId) {
@@ -162,19 +164,6 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})
let sortOrder: number
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
@@ -214,30 +203,49 @@ export async function POST(req: NextRequest) {
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, name),
]
let name = requestedName
if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
if (deduplicate) {
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
} else {
duplicateConditions.push(isNull(workflow.folderId))
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, requestedName),
]
if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
} else {
duplicateConditions.push(isNull(workflow.folderId))
}
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${requestedName}" already exists in this folder` },
{ status: 409 }
)
}
}
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${name}" already exists in this folder` },
{ status: 409 }
)
}
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
})
.catch(() => {
// Silently fail
})
await db.insert(workflow).values({
id: workflowId,

View File

@@ -1,7 +1,13 @@
import { memo } from 'react'
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
import type { WorkspaceMember } from '@/hooks/queries/workspace'
function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
interface OwnerAvatarProps {
name: string
image: string | null
}
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
if (image) {
return (
<img
@@ -18,7 +24,7 @@ function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
{name.charAt(0).toUpperCase()}
</span>
)
}
})
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.

View File

@@ -11,6 +11,8 @@ import {
import { cn } from '@/lib/core/utils/cn'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
const HEADER_PLUS_ICON = <Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
export interface DropdownOption {
label: string
icon?: React.ElementType
@@ -122,7 +124,7 @@ export const ResourceHeader = memo(function ResourceHeader({
variant='subtle'
className='px-2 py-1 text-caption'
>
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{HEADER_PLUS_ICON}
{create.label}
</Button>
)}
@@ -132,19 +134,21 @@ export const ResourceHeader = memo(function ResourceHeader({
)
})
function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
}: {
interface BreadcrumbSegmentProps {
icon?: React.ElementType
label: string
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
}) {
}
const BreadcrumbSegment = memo(function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
}: BreadcrumbSegmentProps) {
if (editing?.isEditing) {
return (
<span className='inline-flex items-center px-2 py-1'>
@@ -203,4 +207,4 @@ function BreadcrumbSegment({
{content}
</span>
)
}
})

View File

@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
@@ -16,6 +16,12 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
const SEARCH_ICON = (
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
)
const FILTER_ICON = <ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
const SORT_ICON = <ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
@@ -79,56 +85,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center justify-between'>
{search && (
<div className='relative flex flex-1 items-center'>
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i &&
'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={search.onClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)}
{search && <SearchSection search={search} />}
<div className='flex items-center gap-1.5'>
{extras}
{filterTags?.map((tag) => (
@@ -146,7 +103,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{FILTER_ICON}
Filter
</Button>
</PopoverPrimitive.Trigger>
@@ -170,14 +127,94 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
)
})
function SortDropdown({ config }: { config: SortConfig }) {
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
const [localValue, setLocalValue] = useState(search.value)
const lastReportedRef = useRef(search.value)
if (search.value !== lastReportedRef.current) {
setLocalValue(search.value)
lastReportedRef.current = search.value
}
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value
setLocalValue(next)
search.onChange(next)
},
[search.onChange]
)
const handleClearAll = useCallback(() => {
setLocalValue('')
lastReportedRef.current = ''
if (search.onClearAll) {
search.onClearAll()
} else {
search.onChange('')
}
}, [search.onClearAll, search.onChange])
return (
<div className='relative flex flex-1 items-center'>
{SEARCH_ICON}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pl-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Button
key={`${tag.label}-${tag.value}-${i}`}
variant='subtle'
className={cn(
'shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i && 'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
{tag.label}: {tag.value}
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
</Button>
))}
<input
ref={search.inputRef}
type='text'
value={localValue}
onChange={handleInputChange}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
{search.tags?.length || localValue ? (
<button
type='button'
className='mr-0.5 flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
onClick={handleClearAll}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)
})
const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) {
const { options, active, onSort, onClear } = config
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{SORT_ICON}
Sort
</Button>
</DropdownMenuTrigger>
@@ -218,4 +255,4 @@ function SortDropdown({ config }: { config: SortConfig }) {
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -8,6 +8,8 @@ import { ResourceHeader } from './components/resource-header'
import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar'
import { ResourceOptionsBar } from './components/resource-options-bar'
const CREATE_ROW_PLUS_ICON = <Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
export interface ResourceColumn {
id: string
header: string
@@ -69,11 +71,13 @@ interface ResourceProps {
const EMPTY_CELL_PLACEHOLDER = '- - -'
const SKELETON_ROW_COUNT = 5
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation()
/**
* Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
* Renders the header, toolbar with search, and a data table from column/row definitions.
*/
export function Resource({
export const Resource = memo(function Resource({
icon,
title,
breadcrumbs,
@@ -135,7 +139,7 @@ export function Resource({
/>
</div>
)
}
})
export interface ResourceTableProps {
columns: ResourceColumn[]
@@ -229,6 +233,13 @@ export const ResourceTable = memo(function ResourceTable({
const hasCheckbox = selectable != null
const totalColSpan = columns.length + (hasCheckbox ? 1 : 0)
const handleSelectAll = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectAll(checked as boolean)
},
[selectable]
)
if (isLoading) {
return (
<DataTableSkeleton
@@ -259,7 +270,7 @@ export const ResourceTable = memo(function ResourceTable({
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
onCheckedChange={handleSelectAll}
disabled={selectable.disabled}
aria-label='Select all'
/>
@@ -306,68 +317,20 @@ export const ResourceTable = memo(function ResourceTable({
<table className='w-full table-fixed text-small'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
{displayRows.map((row) => {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
return (
<tr
key={row.id}
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{hasCheckbox && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={(checked) =>
selectable.onSelectRow(row.id, checked as boolean)
}
disabled={selectable.disabled}
aria-label='Select row'
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})}
{create && (
<tr
className={cn(
'transition-colors',
create.disabled
? 'cursor-not-allowed'
: 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</td>
</tr>
)}
{displayRows.map((row) => (
<DataRow
key={row.id}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
hasCheckbox={hasCheckbox}
/>
))}
{create && <CreateRow create={create} totalColSpan={totalColSpan} />}
</tbody>
</table>
{hasMore && (
@@ -390,7 +353,7 @@ export const ResourceTable = memo(function ResourceTable({
)
})
function Pagination({
const Pagination = memo(function Pagination({
currentPage,
totalPages,
onPageChange,
@@ -447,10 +410,17 @@ function Pagination({
</div>
</div>
)
})
interface CellContentProps {
icon?: ReactNode
label: string
content?: ReactNode
primary?: boolean
}
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
if (cell.content) return <>{cell.content}</>
const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) {
if (content) return <>{content}</>
return (
<span
className={cn(
@@ -458,19 +428,132 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
<span className='truncate'>{cell.label}</span>
{icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{icon}</span>}
<span className='truncate'>{label}</span>
</span>
)
})
interface DataRowProps {
row: ResourceRow
columns: ResourceColumn[]
selectedRowId?: string | null
selectable?: SelectableConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
hasCheckbox: boolean
}
function ResourceColGroup({
const DataRow = memo(function DataRow({
row,
columns,
selectedRowId,
selectable,
onRowClick,
onRowHover,
onRowContextMenu,
hasCheckbox,
}: {
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
const handleClick = useCallback(() => {
onRowClick?.(row.id)
}, [onRowClick, row.id])
const handleMouseEnter = useCallback(() => {
onRowHover?.(row.id)
}, [onRowHover, row.id])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
onRowContextMenu?.(e, row.id)
},
[onRowContextMenu, row.id]
)
const handleSelectRow = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectRow(row.id, checked as boolean)
},
[selectable, row.id]
)
return (
<tr
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
)}
onClick={onRowClick ? handleClick : undefined}
onMouseEnter={handleMouseEnter}
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
>
{hasCheckbox && selectable && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={handleSelectRow}
disabled={selectable.disabled}
aria-label='Select row'
onClick={stopPropagation}
/>
</td>
)}
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
<CellContent
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
)
})
interface CreateRowProps {
create: CreateAction
totalColSpan: number
}
const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) {
return (
<tr
className={cn(
'transition-colors',
create.disabled ? 'cursor-not-allowed' : 'cursor-pointer hover-hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={totalColSpan} className='px-6 py-2.5 align-middle'>
<span className='flex items-center gap-3 font-medium text-[var(--text-secondary)] text-sm'>
{CREATE_ROW_PLUS_ICON}
{create.label}
</span>
</td>
</tr>
)
})
interface ResourceColGroupProps {
columns: ResourceColumn[]
hasCheckbox?: boolean
}) {
}
const ResourceColGroup = memo(function ResourceColGroup({
columns,
hasCheckbox,
}: ResourceColGroupProps) {
return (
<colgroup>
{hasCheckbox && <col className='w-[52px]' />}
@@ -486,17 +569,19 @@ function ResourceColGroup({
))}
</colgroup>
)
}
})
function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: {
interface DataTableSkeletonProps {
columns: ResourceColumn[]
rowCount: number
hasCheckbox?: boolean
}) {
}
const DataTableSkeleton = memo(function DataTableSkeleton({
columns,
rowCount,
hasCheckbox,
}: DataTableSkeletonProps) {
return (
<>
<div className='overflow-hidden'>
@@ -549,4 +634,4 @@ function DataTableSkeleton({
</div>
</>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -183,6 +183,8 @@ function TextEditor({
} = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs')
const updateContent = useUpdateWorkspaceFileContent()
const updateContentRef = useRef(updateContent)
updateContentRef.current = updateContent
const [content, setContent] = useState('')
const [savedContent, setSavedContent] = useState('')
@@ -230,14 +232,14 @@ function TextEditor({
const currentContent = contentRef.current
if (currentContent === savedContentRef.current) return
await updateContent.mutateAsync({
await updateContentRef.current.mutateAsync({
workspaceId,
fileId: file.id,
content: currentContent,
})
setSavedContent(currentContent)
savedContentRef.current = currentContent
}, [workspaceId, file.id, updateContent])
}, [workspaceId, file.id])
const { saveStatus, saveImmediately, isDirty } = useAutosave({
content,
@@ -402,7 +404,7 @@ function TextEditor({
)
}
function IframePreview({ file }: { file: WorkspaceFileRecord }) {
const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
@@ -417,9 +419,9 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) {
/>
</div>
)
}
})
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
return (
@@ -432,7 +434,7 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
/>
</div>
)
}
})
const pptxSlideCache = new Map<string, string[]>()
@@ -701,7 +703,11 @@ function PptxPreview({
)
}
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {
file: WorkspaceFileRecord
}) {
const ext = getFileExtension(file.name)
return (
@@ -714,4 +720,4 @@ function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
</p>
</div>
)
}
})

View File

@@ -42,7 +42,12 @@ interface PreviewPanelProps {
isStreaming?: boolean
}
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
export const PreviewPanel = memo(function PreviewPanel({
content,
mimeType,
filename,
isStreaming,
}: PreviewPanelProps) {
const previewType = resolvePreviewType(mimeType, filename)
if (previewType === 'markdown')
@@ -52,7 +57,7 @@ export function PreviewPanel({ content, mimeType, filename, isStreaming }: Previ
if (previewType === 'svg') return <SvgPreview content={content} />
return null
}
})
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
@@ -197,7 +202,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
)
})
function HtmlPreview({ content }: { content: string }) {
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
return (
<div className='h-full overflow-hidden'>
<iframe
@@ -208,9 +213,9 @@ function HtmlPreview({ content }: { content: string }) {
/>
</div>
)
}
})
function SvgPreview({ content }: { content: string }) {
const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
const wrappedContent = useMemo(
() =>
`<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`,
@@ -227,9 +232,9 @@ function SvgPreview({ content }: { content: string }) {
/>
</div>
)
}
})
function CsvPreview({ content }: { content: string }) {
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
const { headers, rows } = useMemo(() => parseCsv(content), [content])
if (headers.length === 0) {
@@ -271,7 +276,7 @@ function CsvPreview({ content }: { content: string }) {
</div>
</div>
)
}
})
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
const lines = text.split('\n').filter((line) => line.trim().length > 0)

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,7 +19,7 @@ interface FilesListContextMenuProps {
disableUpload?: boolean
}
export function FilesListContextMenu({
export const FilesListContextMenu = memo(function FilesListContextMenu({
isOpen,
position,
onClose,
@@ -64,4 +65,4 @@ export function FilesListContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import {
@@ -41,6 +41,7 @@ import type {
HeaderAction,
ResourceColumn,
ResourceRow,
SearchConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
@@ -159,11 +160,29 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [searchTerm, setSearchTerm] = useState('')
const [inputValue, setInputValue] = useState('')
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchTerm(value)
}, 200)
}, [])
const [creatingFile, setCreatingFile] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
if (fileIdFromRoute) {
const file = files.find((f) => f.id === fileIdFromRoute)
if (file && isPreviewable(file)) return 'preview'
return 'editor'
}
return 'preview'
})
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
@@ -183,59 +202,105 @@ export function Files() {
() => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null),
[fileIdFromRoute, files]
)
const selectedFileRef = useRef(selectedFile)
selectedFileRef.current = selectedFile
const filteredFiles = useMemo(() => {
if (!searchTerm) return files
const q = searchTerm.toLowerCase()
if (!debouncedSearchTerm) return files
const q = debouncedSearchTerm.toLowerCase()
return files.filter((f) => f.name.toLowerCase().includes(q))
}, [files, searchTerm])
}, [files, debouncedSearchTerm])
const rows: ResourceRow[] = useMemo(
() =>
filteredFiles.map((file) => {
const Icon = getDocumentIcon(file.type || '', file.name)
return {
id: file.id,
cells: {
name: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: file.name,
content:
listRename.editingId === file.id ? (
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<Icon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
) : undefined,
},
size: {
label: formatFileSize(file.size, { includeBytes: true }),
},
type: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: formatFileType(file.type, file.name),
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
}),
[filteredFiles, members, listRename.editingId, listRename.editValue]
const rowCacheRef = useRef(
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
)
const baseRows: ResourceRow[] = useMemo(() => {
const prevCache = rowCacheRef.current
const nextCache = new Map<
string,
{ row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }
>()
const result = filteredFiles.map((file) => {
const cached = prevCache.get(file.id)
if (cached && cached.file === file && cached.members === members) {
nextCache.set(file.id, cached)
return cached.row
}
const Icon = getDocumentIcon(file.type || '', file.name)
const row: ResourceRow = {
id: file.id,
cells: {
name: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: file.name,
},
size: {
label: formatFileSize(file.size, { includeBytes: true }),
},
type: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: formatFileType(file.type, file.name),
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
sortValues: {
size: file.size,
created: -new Date(file.uploadedAt).getTime(),
updated: -new Date(file.uploadedAt).getTime(),
},
}
nextCache.set(file.id, { row, file, members })
return row
})
rowCacheRef.current = nextCache
return result
}, [filteredFiles, members])
const rows: ResourceRow[] = useMemo(() => {
if (!listRename.editingId) return baseRows
return baseRows.map((row) => {
if (row.id !== listRename.editingId) return row
const file = filteredFiles.find((f) => f.id === row.id)
if (!file) return row
const Icon = getDocumentIcon(file.type || '', file.name)
return {
...row,
cells: {
...row.cells,
name: {
...row.cells.name,
content: (
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<Icon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
),
},
},
}
})
}, [
baseRows,
listRename.editingId,
listRename.editValue,
listRename.setEditValue,
listRename.submitRename,
listRename.cancelRename,
filteredFiles,
])
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files
@@ -288,8 +353,13 @@ export function Files() {
}
}, [])
const deleteTargetFileRef = useRef(deleteTargetFile)
deleteTargetFileRef.current = deleteTargetFile
const fileIdFromRouteRef = useRef(fileIdFromRoute)
fileIdFromRouteRef.current = fileIdFromRoute
const handleDelete = useCallback(async () => {
const target = deleteTargetFile
const target = deleteTargetFileRef.current
if (!target) return
try {
@@ -299,7 +369,7 @@ export function Files() {
})
setShowDeleteConfirm(false)
setDeleteTargetFile(null)
if (fileIdFromRoute === target.id) {
if (fileIdFromRouteRef.current === target.id) {
setIsDirty(false)
setSaveStatus('idle')
router.push(`/workspace/${workspaceId}/files`)
@@ -307,36 +377,44 @@ export function Files() {
} catch (err) {
logger.error('Failed to delete file:', err)
}
}, [deleteTargetFile, workspaceId, fileIdFromRoute, router])
}, [workspaceId, router])
const isDirtyRef = useRef(isDirty)
isDirtyRef.current = isDirty
const saveStatusRef = useRef(saveStatus)
saveStatusRef.current = saveStatus
const handleSave = useCallback(async () => {
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return
await saveRef.current()
}, [isDirty, saveStatus])
}, [])
const handleBackAttempt = useCallback(() => {
if (isDirty) {
if (isDirtyRef.current) {
setShowUnsavedChangesAlert(true)
} else {
setPreviewMode('editor')
router.push(`/workspace/${workspaceId}/files`)
}
}, [isDirty, router, workspaceId])
}, [router, workspaceId])
const handleStartHeaderRename = useCallback(() => {
if (selectedFile) headerRename.startRename(selectedFile.id, selectedFile.name)
}, [selectedFile, headerRename.startRename])
const file = selectedFileRef.current
if (file) headerRename.startRename(file.id, file.name)
}, [headerRename.startRename])
const handleDownloadSelected = useCallback(() => {
if (selectedFile) handleDownload(selectedFile)
}, [selectedFile, handleDownload])
const file = selectedFileRef.current
if (file) handleDownload(file)
}, [handleDownload])
const handleDeleteSelected = useCallback(() => {
if (selectedFile) {
setDeleteTargetFile(selectedFile)
const file = selectedFileRef.current
if (file) {
setDeleteTargetFile(file)
setShowDeleteConfirm(true)
}
}, [selectedFile])
}, [])
const fileDetailBreadcrumbs = useMemo(
() =>
@@ -379,9 +457,6 @@ export function Files() {
handleBackAttempt,
headerRename.editingId,
headerRename.editValue,
headerRename.setEditValue,
headerRename.submitRename,
headerRename.cancelRename,
handleStartHeaderRename,
handleDownloadSelected,
handleDeleteSelected,
@@ -396,12 +471,15 @@ export function Files() {
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const creatingFileRef = useRef(creatingFile)
creatingFileRef.current = creatingFile
const handleCreateFile = useCallback(async () => {
if (creatingFile) return
if (creatingFileRef.current) return
setCreatingFile(true)
try {
const existingNames = new Set(files.map((f) => f.name))
const existingNames = new Set(filesRef.current.map((f) => f.name))
let name = 'untitled.md'
let counter = 1
while (existingNames.has(name)) {
@@ -423,42 +501,49 @@ export function Files() {
} finally {
setCreatingFile(false)
}
}, [creatingFile, files, workspaceId, router])
}, [workspaceId, router])
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const file = files.find((f) => f.id === rowId)
const file = filesRef.current.find((f) => f.id === rowId)
if (file) {
setContextMenuFile(file)
openContextMenu(e)
}
},
[files, openContextMenu]
[openContextMenu]
)
const contextMenuFileRef = useRef(contextMenuFile)
contextMenuFileRef.current = contextMenuFile
const handleContextMenuOpen = useCallback(() => {
if (!contextMenuFile) return
router.push(`/workspace/${workspaceId}/files/${contextMenuFile.id}`)
const file = contextMenuFileRef.current
if (!file) return
router.push(`/workspace/${workspaceId}/files/${file.id}`)
closeContextMenu()
}, [contextMenuFile, closeContextMenu, router, workspaceId])
}, [closeContextMenu, router, workspaceId])
const handleContextMenuDownload = useCallback(() => {
if (!contextMenuFile) return
handleDownload(contextMenuFile)
const file = contextMenuFileRef.current
if (!file) return
handleDownload(file)
closeContextMenu()
}, [contextMenuFile, handleDownload, closeContextMenu])
}, [handleDownload, closeContextMenu])
const handleContextMenuRename = useCallback(() => {
if (contextMenuFile) listRename.startRename(contextMenuFile.id, contextMenuFile.name)
const file = contextMenuFileRef.current
if (file) listRename.startRename(file.id, file.name)
closeContextMenu()
}, [contextMenuFile, listRename.startRename, closeContextMenu])
}, [listRename.startRename, closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (!contextMenuFile) return
setDeleteTargetFile(contextMenuFile)
const file = contextMenuFileRef.current
if (!file) return
setDeleteTargetFile(file)
setShowDeleteConfirm(true)
closeContextMenu()
}, [contextMenuFile, closeContextMenu])
}, [closeContextMenu])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
@@ -479,41 +564,46 @@ export function Files() {
closeListContextMenu()
}, [closeListContextMenu])
useEffect(() => {
const prevFileIdRef = useRef(fileIdFromRoute)
if (fileIdFromRoute !== prevFileIdRef.current) {
prevFileIdRef.current = fileIdFromRoute
const isJustCreated =
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
if (isJustCreated) {
setPreviewMode('editor')
} else {
const file = fileIdFromRoute ? filesRef.current.find((f) => f.id === fileIdFromRoute) : null
const canPreview = file ? isPreviewable(file) : false
setPreviewMode(canPreview ? 'preview' : 'editor')
const nextMode: PreviewMode = isJustCreated
? 'editor'
: (() => {
const file = fileIdFromRoute
? filesRef.current.find((f) => f.id === fileIdFromRoute)
: null
return file && isPreviewable(file) ? 'preview' : 'editor'
})()
if (nextMode !== previewMode) {
setPreviewMode(nextMode)
}
}, [fileIdFromRoute])
}
useEffect(() => {
if (!selectedFile) return
const handleKeyDown = (e: KeyboardEvent) => {
if (!fileIdFromRouteRef.current) return
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedFile, handleSave])
useEffect(() => {
if (!isDirty) return
const handler = (e: BeforeUnloadEvent) => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (!isDirtyRef.current) return
e.preventDefault()
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [isDirty])
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [handleSave])
const handleCyclePreviewMode = useCallback(() => {
setPreviewMode((prev) => {
@@ -592,27 +682,92 @@ export function Files() {
selectedFile,
saveStatus,
previewMode,
isDirty,
handleCyclePreviewMode,
handleTogglePreview,
handleSave,
isDirty,
handleDownloadSelected,
handleDeleteSelected,
])
/** Stable refs for values used in callbacks to avoid dependency churn */
const listRenameRef = useRef(listRename)
listRenameRef.current = listRename
const headerRenameRef = useRef(headerRename)
headerRenameRef.current = headerRename
const handleRowClick = useCallback(
(id: string) => {
if (listRenameRef.current.editingId !== id && !headerRenameRef.current.editingId) {
router.push(`/workspace/${workspaceId}/files/${id}`)
}
},
[router, workspaceId]
)
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const canEdit = userPermissions.canEdit === true
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const searchConfig: SearchConfig = useMemo(
() => ({
value: inputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
placeholder: 'Search files...',
}),
[inputValue, handleSearchChange, handleSearchClearAll]
)
const createConfig = useMemo(
() => ({
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || !canEdit,
}),
[handleCreateFile, uploading, creatingFile, canEdit]
)
const uploadButtonLabel = useMemo(
() =>
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload',
[uploading, uploadProgress.completed, uploadProgress.total]
)
const headerActionsConfig = useMemo(
() => [
{
label: uploadButtonLabel,
icon: Upload,
onClick: handleUploadClick,
},
],
[uploadButtonLabel, handleUploadClick]
)
const handleNavigateToFiles = useCallback(() => {
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const loadingBreadcrumbs = useMemo(
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
[handleNavigateToFiles]
)
if (fileIdFromRoute && !selectedFile) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<ResourceHeader
icon={FilesIcon}
breadcrumbs={[
{
label: 'Files',
onClick: () => router.push(`/workspace/${workspaceId}/files`),
},
{ label: '...' },
]}
/>
<ResourceHeader icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
<div className='flex flex-1 items-center justify-center'>
<Skeleton className='h-[16px] w-[200px]' />
</div>
@@ -633,7 +788,7 @@ export function Files() {
key={selectedFile.id}
file={selectedFile}
workspaceId={workspaceId}
canEdit={userPermissions.canEdit === true}
canEdit={canEdit}
previewMode={previewMode}
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
onDirtyChange={setIsDirty}
@@ -672,43 +827,18 @@ export function Files() {
)
}
const uploadButtonLabel =
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload'
return (
<>
<Resource
icon={FilesIcon}
title='Files'
create={{
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || userPermissions.canEdit !== true,
}}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search files...',
}}
create={createConfig}
search={searchConfig}
defaultSort='created'
headerActions={[
{
label: uploadButtonLabel,
icon: Upload,
onClick: () => fileInputRef.current?.click(),
},
]}
headerActions={headerActionsConfig}
columns={COLUMNS}
rows={rows}
onRowClick={(id) => {
if (listRename.editingId !== id && !headerRename.editingId) {
router.push(`/workspace/${workspaceId}/files/${id}`)
}
}}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
onContextMenu={handleContentContextMenu}
@@ -720,58 +850,20 @@ export function Files() {
onClose={closeListContextMenu}
onCreateFile={handleCreateFile}
onUploadFile={handleListUploadFile}
disableCreate={uploading || creatingFile || userPermissions.canEdit !== true}
disableUpload={uploading || userPermissions.canEdit !== true}
disableCreate={uploading || creatingFile || !canEdit}
disableUpload={uploading || !canEdit}
/>
<DropdownMenu
open={isContextMenuOpen}
onOpenChange={(open) => !open && closeContextMenu()}
modal={false}
>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={handleContextMenuOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleContextMenuDownload}>
<Download />
Download
</DropdownMenuItem>
{userPermissions.canEdit === true && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleContextMenuRename}>
<Pencil />
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleContextMenuDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<FileRowContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
onClose={closeContextMenu}
onOpen={handleContextMenuOpen}
onDownload={handleContextMenuDownload}
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
canEdit={canEdit}
/>
<DeleteConfirmModal
open={showDeleteConfirm}
@@ -794,6 +886,75 @@ export function Files() {
)
}
interface FileRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen: () => void
onDownload: () => void
onRename: () => void
onDelete: () => void
canEdit: boolean
}
const FileRowContextMenu = memo(function FileRowContextMenu({
isOpen,
position,
onClose,
onOpen,
onDownload,
onRename,
onDelete,
canEdit,
}: FileRowContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDownload}>
<Download />
Download
</DropdownMenuItem>
{canEdit && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
interface DeleteConfirmModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -802,7 +963,7 @@ interface DeleteConfirmModalProps {
isPending: boolean
}
function DeleteConfirmModal({
const DeleteConfirmModal = memo(function DeleteConfirmModal({
open,
onOpenChange,
fileName,
@@ -833,4 +994,4 @@ function DeleteConfirmModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -82,6 +82,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
create_job: Calendar,
manage_job: Calendar,
update_job_history: Calendar,
job_respond: Calendar,
// Management
manage_mcp_tool: Settings,
manage_skill: Asterisk,

View File

@@ -54,6 +54,7 @@ export function Home({ chatId }: HomeProps = {}) {
description,
color,
workspaceId,
deduplicate: true,
}),
})

View File

@@ -1213,31 +1213,30 @@ export function useChat(
}
flush()
// TODO: Uncomment when rich UI for Results tab is ready
// if (shouldOpenGenericResource(name)) {
// if (!genericEntryMap.has(id)) {
// const entryIdx = appendGenericEntry({
// toolCallId: id,
// toolName: name,
// displayTitle: displayTitle ?? name,
// status: 'executing',
// params: args,
// })
// genericEntryMap.set(id, entryIdx)
// const opened = addResource({ type: 'generic', id: 'results', title: 'Results' })
// if (opened) onResourceEventRef.current?.()
// else setActiveResourceId('results')
// } else {
// const entryIdx = genericEntryMap.get(id)
// if (entryIdx !== undefined) {
// updateGenericEntry(entryIdx, {
// toolName: name,
// ...(displayTitle && { displayTitle }),
// ...(args && { params: args }),
// })
// }
// }
// }
if (shouldOpenGenericResource(name)) {
if (!genericEntryMap.has(id)) {
const entryIdx = appendGenericEntry({
toolCallId: id,
toolName: name,
displayTitle: displayTitle ?? name,
status: 'executing',
params: args,
})
genericEntryMap.set(id, entryIdx)
const opened = addResource({ type: 'generic', id: 'results', title: 'Results' })
if (opened) onResourceEventRef.current?.()
else setActiveResourceId('results')
} else {
const entryIdx = genericEntryMap.get(id)
if (entryIdx !== undefined) {
updateGenericEntry(entryIdx, {
toolName: name,
...(displayTitle && { displayTitle }),
...(args && { params: args }),
})
}
}
}
if (
parsed.type === 'tool_call' &&
@@ -1334,18 +1333,17 @@ export function useChat(
flush()
}
// TODO: Uncomment when rich UI for Results tab is ready
// if (toolName && shouldOpenGenericResource(toolName)) {
// const entryIdx = genericEntryMap.get(id)
// if (entryIdx !== undefined) {
// const entry = genericResourceDataRef.current.entries[entryIdx]
// if (entry) {
// updateGenericEntry(entryIdx, {
// streamingArgs: (entry.streamingArgs ?? '') + delta,
// })
// }
// }
// }
if (toolName && shouldOpenGenericResource(toolName)) {
const entryIdx = genericEntryMap.get(id)
if (entryIdx !== undefined) {
const entry = genericResourceDataRef.current.entries[entryIdx]
if (entry) {
updateGenericEntry(entryIdx, {
streamingArgs: (entry.streamingArgs ?? '') + delta,
})
}
}
}
break
}
@@ -1454,33 +1452,32 @@ export function useChat(
}
}
// TODO: Uncomment when rich UI for Results tab is ready
// if (
// shouldOpenGenericResource(tc.name) ||
// (isDeferredResourceTool(tc.name) && extractedResources.length === 0)
// ) {
// const entryIdx = genericEntryMap.get(id)
// if (entryIdx !== undefined) {
// updateGenericEntry(entryIdx, {
// status: tc.status,
// result: tc.result ?? undefined,
// streamingArgs: undefined,
// })
// } else {
// const newIdx = appendGenericEntry({
// toolCallId: id,
// toolName: tc.name,
// displayTitle: tc.displayTitle ?? tc.name,
// status: tc.status,
// params: toolArgsMap.get(id) as Record<string, unknown> | undefined,
// result: tc.result ?? undefined,
// })
// genericEntryMap.set(id, newIdx)
// if (addResource({ type: 'generic', id: 'results', title: 'Results' })) {
// onResourceEventRef.current?.()
// }
// }
// }
if (
shouldOpenGenericResource(tc.name) ||
(isDeferredResourceTool(tc.name) && extractedResources.length === 0)
) {
const entryIdx = genericEntryMap.get(id)
if (entryIdx !== undefined) {
updateGenericEntry(entryIdx, {
status: tc.status,
result: tc.result ?? undefined,
streamingArgs: undefined,
})
} else {
const newIdx = appendGenericEntry({
toolCallId: id,
toolName: tc.name,
displayTitle: tc.displayTitle ?? tc.name,
status: tc.status,
params: toolArgsMap.get(id) as Record<string, unknown> | undefined,
result: tc.result ?? undefined,
})
genericEntryMap.set(id, newIdx)
if (addResource({ type: 'generic', id: 'results', title: 'Results' })) {
onResourceEventRef.current?.()
}
}
}
}
break
@@ -1577,13 +1574,12 @@ export function useChat(
}
flush()
// TODO: Uncomment when rich UI for Results tab is ready
// if (toolCallName && shouldOpenGenericResource(toolCallName)) {
// const entryIdx = genericEntryMap.get(id)
// if (entryIdx !== undefined) {
// updateGenericEntry(entryIdx, { status: 'error', streamingArgs: undefined })
// }
// }
if (toolCallName && shouldOpenGenericResource(toolCallName)) {
const entryIdx = genericEntryMap.get(id)
if (entryIdx !== undefined) {
updateGenericEntry(entryIdx, { status: 'error', streamingArgs: undefined })
}
}
}
break
}

View File

@@ -98,6 +98,7 @@ export type MothershipToolName =
| 'create_job'
| 'complete_job'
| 'update_job_history'
| 'job_respond'
| 'download_to_workspace_file'
| 'materialize_file'
| 'context_write'
@@ -393,6 +394,7 @@ export const TOOL_UI_METADATA: Record<MothershipToolName, ToolUIMetadata> = {
create_job: { title: 'Creating job', phaseLabel: 'Resource', phase: 'resource' },
manage_job: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
update_job_history: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' },
job_respond: { title: 'Explaining job scheduled', phaseLabel: 'Execution', phase: 'execution' },
// Management
manage_mcp_tool: { title: 'Updating integration', phaseLabel: 'Management', phase: 'management' },
manage_skill: { title: 'Updating skill', phaseLabel: 'Management', phase: 'management' },

View File

@@ -1138,9 +1138,12 @@ export function Document({
<span className='font-medium text-[var(--text-primary)]'>
{effectiveDocumentName}
</span>
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.
</span>{' '}
{documentData?.connectorId ? (
<span className='text-[var(--text-error)]'>
This document is synced from a connector. Deleting it will permanently exclude it

View File

@@ -1106,8 +1106,10 @@ export function KnowledgeBase({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
The knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it will be removed.{' '}
<span className='text-[var(--text-error)]'>
The knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>
@@ -1147,7 +1149,9 @@ export function KnowledgeBase({
it from future syncs. To temporarily hide it from search, disable it instead.
</span>
) : (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the document.
</span>
)}
</p>
)
@@ -1177,7 +1181,10 @@ export function KnowledgeBase({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete {selectedDocuments.size} document
{selectedDocuments.size === 1 ? '' : 's'}?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the selected document
{selectedDocuments.size === 1 ? '' : 's'}.
</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -416,9 +416,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalBody>
<div className='space-y-2'>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
Are you sure you want to delete the "{selectedTag?.displayName}" tag?{' '}
<span className='text-[var(--text-error)]'>
This will remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>

View File

@@ -73,12 +73,26 @@ export function ConnectorsSection({
isLoading,
canEdit,
}: ConnectorsSectionProps) {
const { mutate: triggerSync, isPending: isSyncing } = useTriggerSync()
const { mutate: updateConnector, isPending: isUpdating } = useUpdateConnector()
const { mutate: triggerSync } = useTriggerSync()
const { mutate: updateConnector } = useUpdateConnector()
const { mutate: deleteConnector, isPending: isDeleting } = useDeleteConnector()
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [editingConnector, setEditingConnector] = useState<ConnectorData | null>(null)
const [error, setError] = useState<string | null>(null)
const [syncingIds, setSyncingIds] = useState<Set<string>>(() => new Set())
const [updatingIds, setUpdatingIds] = useState<Set<string>>(() => new Set())
const addToSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => new Set(prev).add(id))
}, [])
const removeFromSet = useCallback((setter: typeof setSyncingIds, id: string) => {
setter((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
const syncTriggeredAt = useRef<Record<string, number>>({})
const cooldownTimers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
@@ -103,6 +117,7 @@ export function ConnectorsSection({
if (isSyncOnCooldown(connectorId)) return
syncTriggeredAt.current[connectorId] = Date.now()
addToSet(setSyncingIds, connectorId)
triggerSync(
{ knowledgeBaseId, connectorId },
@@ -121,10 +136,35 @@ export function ConnectorsSection({
delete syncTriggeredAt.current[connectorId]
forceUpdate((n) => n + 1)
},
onSettled: () => removeFromSet(setSyncingIds, connectorId),
}
)
},
[knowledgeBaseId, triggerSync, isSyncOnCooldown]
[knowledgeBaseId, triggerSync, isSyncOnCooldown, addToSet, removeFromSet]
)
const handleTogglePause = useCallback(
(connector: ConnectorData) => {
addToSet(setUpdatingIds, connector.id)
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
},
},
{
onSettled: () => removeFromSet(setUpdatingIds, connector.id),
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
},
[knowledgeBaseId, updateConnector, addToSet, removeFromSet]
)
if (connectors.length === 0 && !canEdit && !isLoading) return null
@@ -163,28 +203,11 @@ export function ConnectorsSection({
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
canEdit={canEdit}
isSyncing={isSyncing}
isUpdating={isUpdating}
isSyncPending={syncingIds.has(connector.id)}
isUpdating={updatingIds.has(connector.id)}
syncCooldown={isSyncOnCooldown(connector.id)}
onSync={() => handleSync(connector.id)}
onTogglePause={() =>
updateConnector(
{
knowledgeBaseId,
connectorId: connector.id,
updates: {
status: connector.status === 'paused' ? 'active' : 'paused',
},
},
{
onSuccess: () => setError(null),
onError: (err) => {
logger.error('Toggle pause failed', { error: err.message })
setError(err.message)
},
}
)
}
onTogglePause={() => handleTogglePause(connector)}
onEdit={() => setEditingConnector(connector)}
onDelete={() => setDeleteTarget(connector.id)}
/>
@@ -206,8 +229,13 @@ export function ConnectorsSection({
<ModalHeader>Delete Connector</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-sm'>
Are you sure you want to remove this connected source? Documents already synced will
remain in the knowledge base.
Are you sure you want to remove this connected source?{' '}
<span className='text-[var(--text-error)]'>
This will stop future syncs from this source.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
Documents already synced will remain in the knowledge base.
</span>
</p>
</ModalBody>
<ModalFooter>
@@ -250,7 +278,7 @@ interface ConnectorCardProps {
workspaceId: string
knowledgeBaseId: string
canEdit: boolean
isSyncing: boolean
isSyncPending: boolean
isUpdating: boolean
syncCooldown: boolean
onSync: () => void
@@ -264,7 +292,7 @@ function ConnectorCard({
workspaceId,
knowledgeBaseId,
canEdit,
isSyncing,
isSyncPending,
isUpdating,
syncCooldown,
onSync,
@@ -306,13 +334,13 @@ function ConnectorCard({
{Icon && <Icon className='h-5 w-5 flex-shrink-0' />}
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[var(--text-primary)] text-small'>
<span className='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
{connectorDef?.name || connector.connectorType}
{(isSyncPending || connector.status === 'syncing') && (
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-muted)]' />
)}
</span>
<Badge variant={statusConfig.variant} className='text-micro'>
{connector.status === 'syncing' && (
<Loader2 className='mr-1 h-3 w-3 animate-spin' />
)}
{statusConfig.label}
</Badge>
</div>
@@ -356,7 +384,7 @@ function ConnectorCard({
variant='ghost'
className='h-7 w-7 p-0'
onClick={onSync}
disabled={connector.status === 'syncing' || isSyncing || syncCooldown}
disabled={connector.status === 'syncing' || isSyncPending || syncCooldown}
>
<RefreshCw
className={cn(

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { Loader2, RotateCcw, X } from 'lucide-react'
@@ -78,7 +78,10 @@ interface SubmitStatus {
message: string
}
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
export const CreateBaseModal = memo(function CreateBaseModal({
open,
onOpenChange,
}: CreateBaseModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -543,4 +546,4 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
interface DeleteKnowledgeBaseModalProps {
@@ -29,7 +30,7 @@ interface DeleteKnowledgeBaseModalProps {
* Delete confirmation modal for knowledge base items.
* Displays a warning message and confirmation buttons.
*/
export function DeleteKnowledgeBaseModal({
export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
isOpen,
onClose,
onConfirm,
@@ -46,10 +47,17 @@ export function DeleteKnowledgeBaseModal({
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
All associated documents, chunks, and embeddings will be removed.
<span className='text-[var(--text-error)]'>
All associated documents, chunks, and embeddings will be removed.
</span>
</>
) : (
'Are you sure you want to delete this knowledge base? All associated documents, chunks, and embeddings will be removed.'
<>
Are you sure you want to delete this knowledge base?{' '}
<span className='text-[var(--text-error)]'>
All associated documents, chunks, and embeddings will be removed.
</span>
</>
)}{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
@@ -67,4 +75,4 @@ export function DeleteKnowledgeBaseModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { memo, useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLogger } from '@sim/logger'
import { useForm } from 'react-hook-form'
@@ -43,7 +43,7 @@ type FormValues = z.infer<typeof FormSchema>
/**
* Modal for editing knowledge base name and description
*/
export function EditKnowledgeBaseModal({
export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
open,
onOpenChange,
knowledgeBaseId,
@@ -172,4 +172,4 @@ export function EditKnowledgeBaseModal({
</ModalContent>
</Modal>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,7 +31,7 @@ interface KnowledgeBaseContextMenuProps {
* Context menu component for knowledge base cards.
* Displays open in new tab, view tags, edit, and delete options.
*/
export function KnowledgeBaseContextMenu({
export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
isOpen,
position,
onClose,
@@ -114,4 +115,4 @@ export function KnowledgeBaseContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,5 +1,6 @@
'use client'
import { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,7 +21,7 @@ interface KnowledgeListContextMenuProps {
* Context menu component for the knowledge base list page.
* Displays "Add knowledge base" option when right-clicking on empty space.
*/
export function KnowledgeListContextMenu({
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
isOpen,
position,
onClose,
@@ -58,4 +59,4 @@ export function KnowledgeListContextMenu({
</DropdownMenuContent>
</DropdownMenu>
)
}
})

View File

@@ -1,11 +1,18 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Database } from '@/components/emcn/icons'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import type {
CreateAction,
ResourceCell,
ResourceColumn,
ResourceRow,
SearchConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
@@ -18,10 +25,10 @@ import {
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge')
@@ -33,11 +40,48 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'connectors', header: 'Connectors' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const DATABASE_ICON = <Database className='h-[14px] w-[14px]' />
function connectorCell(connectorTypes?: string[]): ResourceCell {
if (!connectorTypes || connectorTypes.length === 0) {
return { label: '—' }
}
const entries = connectorTypes
.map((type) => ({ type, def: CONNECTOR_REGISTRY[type] }))
.filter((e): e is { type: string; def: NonNullable<(typeof CONNECTOR_REGISTRY)[string]> } =>
Boolean(e.def?.icon)
)
if (entries.length === 0) return { label: '—' }
return {
content: (
<div className='flex items-center gap-1'>
{entries.map(({ type, def }) => {
const Icon = def.icon
return (
<Tooltip.Root key={type}>
<Tooltip.Trigger asChild>
<span className='flex-shrink-0'>
<Icon className='h-3.5 w-3.5' />
</span>
</Tooltip.Trigger>
<Tooltip.Content>{def.name}</Tooltip.Content>
</Tooltip.Root>
)
})}
</div>
),
}
}
export function Knowledge() {
const params = useParams()
const router = useRouter()
@@ -54,8 +98,22 @@ export function Knowledge() {
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [searchInputValue, setSearchInputValue] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const handleSearchChange = useCallback((value: string) => {
setSearchInputValue(value)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setDebouncedSearchQuery(value)
}, 300)
}, [])
const handleSearchClearAll = useCallback(() => {
handleSearchChange('')
}, [handleSearchChange])
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
@@ -69,7 +127,6 @@ export function Knowledge() {
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
menuRef: listMenuRef,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
@@ -77,11 +134,19 @@ export function Knowledge() {
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
menuRef: rowMenuRef,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen)
isRowContextMenuOpenRef.current = isRowContextMenuOpen
const knowledgeBasesRef = useRef(knowledgeBases)
knowledgeBasesRef.current = knowledgeBases
const activeKnowledgeBaseRef = useRef(activeKnowledgeBase)
activeKnowledgeBaseRef.current = activeKnowledgeBase
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -96,7 +161,7 @@ export function Knowledge() {
[handleListContextMenu]
)
const handleAddKnowledgeBase = useCallback(() => {
const handleOpenCreateModal = useCallback(() => {
setIsCreateModalOpen(true)
}, [])
@@ -132,7 +197,7 @@ export function Knowledge() {
id: kb.id,
cells: {
name: {
icon: <Database className='h-[14px] w-[14px]' />,
icon: DATABASE_ICON,
label: kb.name,
},
documents: {
@@ -141,6 +206,7 @@ export function Knowledge() {
tokens: {
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
},
connectors: connectorCell(kb.connectorTypes),
created: timeCell(kb.createdAt),
owner: ownerCell(kb.userId, members),
updated: timeCell(kb.updatedAt),
@@ -148,6 +214,7 @@ export function Knowledge() {
sortValues: {
documents: kbWithCount.docCount || 0,
tokens: kb.tokenCount || 0,
connectors: kb.connectorTypes?.length || 0,
created: -new Date(kb.createdAt).getTime(),
updated: -new Date(kb.updatedAt).getTime(),
},
@@ -158,51 +225,98 @@ export function Knowledge() {
const handleRowClick = useCallback(
(rowId: string) => {
if (isRowContextMenuOpen) return
const kb = knowledgeBases.find((k) => k.id === rowId)
if (isRowContextMenuOpenRef.current) return
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
},
[isRowContextMenuOpen, knowledgeBases, router, workspaceId]
[router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const kb = knowledgeBases.find((k) => k.id === rowId) as KnowledgeBaseWithDocCount | undefined
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
| KnowledgeBaseWithDocCount
| undefined
setActiveKnowledgeBase(kb ?? null)
handleRowCtxMenu(e)
},
[knowledgeBases, handleRowCtxMenu]
[handleRowCtxMenu]
)
const handleConfirmDelete = useCallback(async () => {
if (!activeKnowledgeBase) return
const kb = activeKnowledgeBaseRef.current
if (!kb) return
setIsDeleting(true)
try {
await handleDeleteKnowledgeBase(activeKnowledgeBase.id)
await handleDeleteKnowledgeBase(kb.id)
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
} finally {
setIsDeleting(false)
}
}, [activeKnowledgeBase, handleDeleteKnowledgeBase])
}, [handleDeleteKnowledgeBase])
const handleCloseDeleteModal = useCallback(() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}, [])
const handleOpenInNewTab = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (!kb) return
const urlParams = new URLSearchParams({ kbName: kb.name })
window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank')
}, [workspaceId])
const handleViewTags = useCallback(() => {
setIsTagsModalOpen(true)
}, [])
const handleCopyId = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (kb) {
navigator.clipboard.writeText(kb.id)
}
}, [])
const handleEdit = useCallback(() => {
setIsEditModalOpen(true)
}, [])
const handleDelete = useCallback(() => {
setIsDeleteModalOpen(true)
}, [])
const canEdit = userPermissions.canEdit === true
const createAction: CreateAction = useMemo(
() => ({
label: 'New base',
onClick: handleOpenCreateModal,
disabled: !canEdit,
}),
[handleOpenCreateModal, canEdit]
)
const searchConfig: SearchConfig = useMemo(
() => ({
value: searchInputValue,
onChange: handleSearchChange,
onClearAll: handleSearchClearAll,
placeholder: 'Search knowledge bases...',
}),
[searchInputValue, handleSearchChange, handleSearchClearAll]
)
return (
<>
<Resource
icon={Database}
title='Knowledge Base'
create={{
label: 'New base',
onClick: () => setIsCreateModalOpen(true),
disabled: userPermissions.canEdit !== true,
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Search knowledge bases...',
}}
create={createAction}
search={searchConfig}
defaultSort='created'
columns={COLUMNS}
rows={rows}
@@ -216,8 +330,8 @@ export function Knowledge() {
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onAddKnowledgeBase={handleAddKnowledgeBase}
disableAdd={userPermissions.canEdit !== true}
onAddKnowledgeBase={handleOpenCreateModal}
disableAdd={!canEdit}
/>
{activeKnowledgeBase && (
@@ -225,23 +339,17 @@ export function Knowledge() {
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpenInNewTab={() => {
const urlParams = new URLSearchParams({ kbName: activeKnowledgeBase.name })
window.open(
`/workspace/${workspaceId}/knowledge/${activeKnowledgeBase.id}?${urlParams.toString()}`,
'_blank'
)
}}
onViewTags={() => setIsTagsModalOpen(true)}
onCopyId={() => navigator.clipboard.writeText(activeKnowledgeBase.id)}
onEdit={() => setIsEditModalOpen(true)}
onDelete={() => setIsDeleteModalOpen(true)}
onOpenInNewTab={handleOpenInNewTab}
onViewTags={handleViewTags}
onCopyId={handleCopyId}
onEdit={handleEdit}
onDelete={handleDelete}
showOpenInNewTab
showViewTags
showEdit
showDelete
disableEdit={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableEdit={!canEdit}
disableDelete={!canEdit}
/>
)}
@@ -259,10 +367,7 @@ export function Knowledge() {
{activeKnowledgeBase && (
<DeleteKnowledgeBaseModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false)
setActiveKnowledgeBase(null)
}}
onClose={handleCloseDeleteModal}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
knowledgeBaseName={activeKnowledgeBase.name}

View File

@@ -1268,7 +1268,9 @@ export const NotificationSettings = memo(function NotificationSettings({
<ModalHeader>Delete Notification</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-caption'>
This will permanently remove the notification and stop all deliveries.{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove the notification and stop all deliveries.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -371,8 +371,10 @@ export function ApiKeys() {
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Deleting{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span> will
immediately revoke access for any integrations using it.{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span>{' '}
<span className='text-[var(--text-error)]'>
will immediately revoke access for any integrations using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -404,7 +404,10 @@ export function BYOK() {
<span className='font-medium text-[var(--text-primary)]'>
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
</span>{' '}
API key? This workspace will revert to using platform hosted keys.
API key?{' '}
<span className='text-[var(--text-error)]'>
This workspace will revert to using platform hosted keys.
</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -366,7 +366,9 @@ export function Copilot() {
<span className='font-medium text-[var(--text-primary)]'>
{deleteKey?.name || 'Unnamed Key'}
</span>{' '}
will immediately revoke access for any integrations using it.{' '}
<span className='text-[var(--text-error)]'>
will immediately revoke access for any integrations using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -164,8 +164,10 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
)}
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}? This will permanently remove
all data in {isSingleRow ? 'this row' : 'these rows'}.{' '}
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all data in {isSingleRow ? 'this row' : 'these rows'}.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -1809,6 +1809,9 @@ export function Table({
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>
All {tableData?.rowCount ?? 0} rows will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>
@@ -1845,8 +1848,10 @@ export function Table({
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>? This
will remove all data in this column.{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will remove all data in this column.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -320,8 +320,10 @@ export function Tables() {
<ModalBody>
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?
All {activeTable?.rowCount} rows will be removed.{' '}
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?{' '}
<span className='text-[var(--text-error)]'>
All {activeTable?.rowCount} rows will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>

View File

@@ -929,7 +929,7 @@ export function Chat() {
>
{shouldShowConfigureStartInputsButton && (
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-2.5 py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
title='Add chat inputs to Start block'
onMouseDown={(e) => {
e.stopPropagation()

View File

@@ -883,7 +883,7 @@ console.log(data);`
</p>
{missingFields.any && (
<div
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
className='flex flex-none cursor-pointer items-center whitespace-nowrap rounded-md border border-[var(--border-1)] bg-[var(--surface-5)] px-[9px] py-0.5 font-medium font-sans text-[var(--text-primary)] text-caption hover-hover:bg-[var(--surface-active)]'
title='Add required A2A input fields to Start block'
onClick={handleAddA2AInputs}
>

View File

@@ -280,7 +280,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}
return (
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
<PopoverAnchor asChild>
<div
className={cn('pointer-events-none', className)}

View File

@@ -553,7 +553,7 @@ export function FileUpload({
return (
<div
key={fileKey}
className='relative rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:bg-[var(--surface-5)] dark:hover-hover:bg-[var(--border-1)]'
className='relative rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 hover-hover:bg-[var(--surface-active)] dark:bg-[var(--surface-5)]'
>
<div className='truncate pr-6 text-sm' title={file.name}>
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>

View File

@@ -108,7 +108,7 @@ export function GroupedCheckboxList({
disabled={disabled}
className={cn(
'flex w-full cursor-pointer items-center justify-between rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-[var(--text-primary)] text-sm outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[var(--surface-5)]',
'hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]'
'hover-hover:bg-[var(--surface-active)]'
)}
>
<span className='flex flex-1 items-center gap-2 truncate text-[var(--text-muted)]'>

View File

@@ -1061,6 +1061,7 @@ try {
setShowSchemaParams(false)
}
}}
colorScheme='inverted'
>
<PopoverAnchor asChild>
<div
@@ -1178,8 +1179,11 @@ try {
<ModalHeader>Delete Custom Tool</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
This will permanently delete the tool and remove it from any workflows that are using
it. <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
<span className='text-[var(--text-error)]'>
This will permanently delete the tool and remove it from any workflows that are
using it.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -874,7 +874,10 @@ export const Panel = memo(function Panel() {
<span className='font-medium text-[var(--text-primary)]'>
{currentWorkflow?.name ?? 'this workflow'}
</span>
? All associated blocks, executions, and configuration will be removed.{' '}
?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>{' '}
<span className='text-[var(--text-tertiary)]'>
You can restore it from Recently Deleted in Settings.
</span>

View File

@@ -1822,7 +1822,7 @@ export function useWorkflowExecution() {
try {
const pointer = await loadExecutionPointer(reconnectWorkflowId)
if (cleanupRan) return
if (pointer && pointer.executionId) {
if (pointer?.executionId) {
executionId = pointer.executionId
fromEventId = pointer.lastEventId
}

View File

@@ -1,4 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { type MouseEvent as ReactMouseEvent, useState } from 'react'
import { Folder, MoreHorizontal, Plus } from 'lucide-react'
import Link from 'next/link'
import {
@@ -12,6 +12,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Pencil, SquareArrowUpRight } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -33,6 +34,7 @@ interface CollapsedSidebarMenuProps {
interface CollapsedTaskFlyoutItemProps {
task: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
isCurrentRoute: boolean
isMenuOpen?: boolean
isEditing?: boolean
editValue?: string
inputRef?: React.RefObject<HTMLInputElement | null>
@@ -56,9 +58,9 @@ interface CollapsedWorkflowFlyoutItemProps {
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
onMorePointerDown?: () => void
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
onOpenInNewTab?: () => void
onRename?: () => void
canRename?: boolean
}
const EDIT_ROW_CLASS =
@@ -68,10 +70,12 @@ function FlyoutMoreButton({
ariaLabel,
onPointerDown,
onClick,
isVisible,
}: {
ariaLabel: string
onPointerDown?: () => void
onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
isVisible?: boolean
}) {
return (
<button
@@ -79,7 +83,10 @@ function FlyoutMoreButton({
aria-label={ariaLabel}
onPointerDown={onPointerDown}
onClick={onClick}
className='-translate-y-1/2 absolute top-1/2 right-[8px] z-10 flex h-[18px] w-[18px] items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100'
className={cn(
'-translate-y-1/2 absolute top-1/2 right-[8px] z-10 flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100',
isVisible && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
@@ -154,7 +161,7 @@ export function CollapsedSidebarMenu({
<button
type='button'
aria-label={ariaLabel}
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover:bg-[var(--surface-active)]'
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover-hover:bg-[var(--surface-hover)]'
>
{icon}
</button>
@@ -180,6 +187,7 @@ export function CollapsedSidebarMenu({
export function CollapsedTaskFlyoutItem({
task,
isCurrentRoute,
isMenuOpen = false,
isEditing = false,
editValue,
inputRef,
@@ -221,12 +229,13 @@ export function CollapsedTaskFlyoutItem({
}
return (
<div className='group relative mx-0.5'>
<div className='group relative'>
<Link
href={task.href}
className={cn(
'flex min-h-[30px] min-w-0 items-center rounded-[5px] px-2 py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
isCurrentRoute && 'bg-[var(--surface-active)]'
'flex min-w-0 cursor-default select-none items-center rounded-[5px] px-2 py-2 pr-[30px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors',
!(isCurrentRoute || isMenuOpen) && 'group-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || isMenuOpen) && 'bg-[var(--surface-active)]'
)}
onContextMenu={
task.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, task.id) : undefined
@@ -236,7 +245,9 @@ export function CollapsedTaskFlyoutItem({
title={task.name}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
statusIndicatorClassName={!isCurrentRoute ? 'group-hover:hidden' : undefined}
statusIndicatorClassName={
!(isCurrentRoute || isMenuOpen) ? 'group-hover:hidden' : undefined
}
/>
</Link>
{showActions && (
@@ -248,6 +259,7 @@ export function CollapsedTaskFlyoutItem({
e.stopPropagation()
onMoreClick?.(e, task.id)
}}
isVisible={isMenuOpen}
/>
)}
</div>
@@ -265,62 +277,102 @@ export function CollapsedWorkflowFlyoutItem({
onEditValueChange,
onEditKeyDown,
onEditBlur,
onContextMenu,
onMorePointerDown,
onMoreClick,
onOpenInNewTab,
onRename,
canRename = true,
}: CollapsedWorkflowFlyoutItemProps) {
const showActions = !!onMoreClick
const hasActions = !!onOpenInNewTab || !!onRename
const [actionsOpen, setActionsOpen] = useState(false)
if (isEditing) {
return (
<div className={EDIT_ROW_CLASS}>
<WorkflowColorSwatch color={workflow.color} />
<input
aria-label={`Rename workflow ${workflow.name}`}
ref={inputRef}
value={editValue ?? workflow.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] 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()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
<div className='group relative'>
<div className='flex min-w-0 cursor-default select-none items-center gap-2 rounded-[5px] bg-[var(--surface-active)] px-2 py-2 font-medium text-[var(--text-body)] text-caption outline-none'>
<WorkflowColorSwatch color={workflow.color} />
<input
aria-label={`Rename workflow ${workflow.name}`}
ref={inputRef}
value={editValue ?? workflow.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[var(--text-body)] text-caption 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()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
</div>
)
}
return (
<div className='group relative mx-0.5'>
<div className='group relative'>
<Link
href={href}
className={cn(
'flex min-h-[30px] min-w-0 items-center gap-2 rounded-[5px] px-2 py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
isCurrentRoute && 'bg-[var(--surface-active)]'
'flex min-w-0 cursor-default select-none items-center gap-2 rounded-[5px] px-2 py-2 pr-[30px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors',
!(isCurrentRoute || actionsOpen) && 'group-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || actionsOpen) && 'bg-[var(--surface-active)]'
)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, workflow) : undefined}
onContextMenu={
hasActions
? (e) => {
e.preventDefault()
setActionsOpen(true)
}
: undefined
}
>
<WorkflowColorSwatch color={workflow.color} />
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
</Link>
{showActions && (
<FlyoutMoreButton
ariaLabel='Workflow options'
onPointerDown={onMorePointerDown}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick?.(e, workflow)
{hasActions && (
<DropdownMenuSub
open={actionsOpen}
onOpenChange={(open) => {
if (!open) setActionsOpen(false)
}}
/>
>
<DropdownMenuSubTrigger
aria-label='Workflow options'
className='-translate-y-1/2 absolute top-1/2 right-[8px] z-10 h-[18px] w-[18px] min-w-0 justify-center gap-0 rounded-sm p-0 opacity-0 transition-opacity focus:bg-transparent group-hover:opacity-100 data-[state=open]:bg-transparent data-[state=open]:opacity-100 [&>svg:last-child]:hidden [&_svg]:pointer-events-auto [&_svg]:size-[16px]'
onClick={(e) => {
e.stopPropagation()
setActionsOpen((prev) => !prev)
}}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{onOpenInNewTab && (
<DropdownMenuItem onSelect={onOpenInNewTab}>
<SquareArrowUpRight className='h-[14px] w-[14px]' />
Open in new tab
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem
disabled={!canRename}
onSelect={(e) => {
e.preventDefault()
setActionsOpen(false)
onRename()
}}
>
<Pencil className='h-[14px] w-[14px]' />
Rename
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</div>
)
@@ -338,9 +390,9 @@ export function CollapsedFolderItems({
onEditValueChange,
onEditKeyDown,
onEditBlur,
onWorkflowContextMenu,
onWorkflowMorePointerDown,
onWorkflowMoreClick,
onWorkflowOpenInNewTab,
onWorkflowRename,
canRenameWorkflow,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record<string, WorkflowMetadata[]>
@@ -353,9 +405,9 @@ export function CollapsedFolderItems({
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onWorkflowContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
onWorkflowMorePointerDown?: () => void
onWorkflowMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
onWorkflowOpenInNewTab?: (workflow: WorkflowMetadata) => void
onWorkflowRename?: (workflow: WorkflowMetadata) => void
canRenameWorkflow?: boolean
}) {
return (
<>
@@ -374,7 +426,7 @@ export function CollapsedFolderItems({
return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger>
<DropdownMenuSubTrigger className='focus:bg-[var(--surface-hover)] data-[state=open]:bg-[var(--surface-hover)]'>
<Folder className='h-[14px] w-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
@@ -391,9 +443,9 @@ export function CollapsedFolderItems({
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onWorkflowContextMenu={onWorkflowContextMenu}
onWorkflowMorePointerDown={onWorkflowMorePointerDown}
onWorkflowMoreClick={onWorkflowMoreClick}
onWorkflowOpenInNewTab={onWorkflowOpenInNewTab}
onWorkflowRename={onWorkflowRename}
canRenameWorkflow={canRenameWorkflow}
/>
{folderWorkflows.map((workflow) => (
<CollapsedWorkflowFlyoutItem
@@ -408,9 +460,11 @@ export function CollapsedFolderItems({
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onContextMenu={onWorkflowContextMenu}
onMorePointerDown={onWorkflowMorePointerDown}
onMoreClick={onWorkflowMoreClick}
onOpenInNewTab={
onWorkflowOpenInNewTab ? () => onWorkflowOpenInNewTab(workflow) : undefined
}
onRename={onWorkflowRename ? () => onWorkflowRename(workflow) : undefined}
canRename={canRenameWorkflow}
/>
))}
</DropdownMenuSubContent>

View File

@@ -191,7 +191,7 @@ export function SettingsSidebar({
<button
type='button'
onClick={handleBack}
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-active)]'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
@@ -259,7 +259,8 @@ export function SettingsSidebar({
const active = activeSection === item.id
const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
const itemClassName = cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover:bg-[var(--surface-active)]',
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px]',
!active && 'hover-hover:bg-[var(--surface-hover)]',
active && 'bg-[var(--surface-active)]'
)
const content = (

View File

@@ -75,7 +75,10 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.join(', ')}
</span>
? All associated blocks, executions, and configuration will be removed.
?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>
</>
)
}
@@ -83,12 +86,21 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? All
associated blocks, executions, and configuration will be removed.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>
</>
)
}
return 'Are you sure you want to delete this workflow? All associated blocks, executions, and configuration will be removed.'
return (
<>
Are you sure you want to delete this workflow?{' '}
<span className='text-[var(--text-error)]'>
All associated blocks, executions, and configuration will be removed.
</span>
</>
)
}
if (itemType === 'folder') {
@@ -99,8 +111,11 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.join(', ')}
</span>
? This will permanently remove all workflows, logs, and knowledge bases within these
folders.
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all workflows, logs, and knowledge bases within these
folders.
</span>
</>
)
}
@@ -108,12 +123,21 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated workflows, logs, and knowledge bases.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, logs, and knowledge bases.
</span>
</>
)
}
return 'Are you sure you want to delete this folder? This will permanently remove all associated workflows, logs, and knowledge bases.'
return (
<>
Are you sure you want to delete this folder?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, logs, and knowledge bases.
</span>
</>
)
}
if (itemType === 'task') {
@@ -124,7 +148,10 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.length} tasks
</span>
? This will permanently remove all conversation history.
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all conversation history.
</span>
</>
)
}
@@ -132,12 +159,21 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all conversation history.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all conversation history.
</span>
</>
)
}
return 'Are you sure you want to delete this task? This will permanently remove all conversation history.'
return (
<>
Are you sure you want to delete this task?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all conversation history.
</span>
</>
)
}
if (itemType === 'mixed') {
@@ -148,12 +184,23 @@ export function DeleteModal({
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.join(', ')}
</span>
? This will permanently remove all selected workflows and folders, including their
contents.
?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all selected workflows and folders, including their
contents.
</span>
</>
)
}
return 'Are you sure you want to delete the selected items? This will permanently remove all selected workflows and folders, including their contents.'
return (
<>
Are you sure you want to delete the selected items?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all selected workflows and folders, including their
contents.
</span>
</>
)
}
// workspace type
@@ -161,12 +208,22 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated workflows, folders, logs, and knowledge bases.
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, folders, logs, and knowledge
bases.
</span>
</>
)
}
return 'Are you sure you want to delete this workspace? This will permanently remove all associated workflows, folders, logs, and knowledge bases.'
return (
<>
Are you sure you want to delete this workspace?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove all associated workflows, folders, logs, and knowledge bases.
</span>
</>
)
}
return (

View File

@@ -448,8 +448,11 @@ export function FolderItem({
aria-label={`${folder.name} folder, ${isExpanded ? 'expanded' : 'collapsed'}`}
className={clsx(
'group mx-0.5 flex h-[30px] cursor-pointer items-center gap-2 rounded-lg px-2 text-sm',
!isAnyDragActive && 'hover-hover:bg-[var(--surface-active)]',
isSelected ? 'bg-[var(--surface-active)]' : '',
!isSelected &&
!isContextMenuOpen &&
!isAnyDragActive &&
'hover-hover:bg-[var(--surface-hover)]',
(isSelected || isContextMenuOpen) && 'bg-[var(--surface-active)]',
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
onClick={handleClick}
@@ -511,8 +514,9 @@ export function FolderItem({
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className={clsx(
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover-hover:bg-[var(--surface-7)]',
!isAnyDragActive && 'group-hover:opacity-100'
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity',
!isAnyDragActive && 'group-hover:opacity-100',
isContextMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />

View File

@@ -386,8 +386,11 @@ export function WorkflowItem({
data-item-id={workflow.id}
className={clsx(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
active && 'bg-[var(--surface-active)]',
!active && !isAnyDragActive && 'hover-hover:bg-[var(--surface-active)]',
(active || isContextMenuOpen) && 'bg-[var(--surface-active)]',
!active &&
!isContextMenuOpen &&
!isAnyDragActive &&
'hover-hover:bg-[var(--surface-hover)]',
isSelected && selectedWorkflows.size > 1 && !active && 'bg-[var(--surface-active)]',
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
@@ -445,8 +448,9 @@ export function WorkflowItem({
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className={clsx(
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover-hover:bg-[var(--surface-7)]',
!isAnyDragActive && 'group-hover:opacity-100'
'flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity',
!isAnyDragActive && 'group-hover:opacity-100',
isContextMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />

View File

@@ -19,7 +19,8 @@ import {
Plus,
UserPlus,
} from '@/components/emcn'
import { getDisplayPlanName } from '@/lib/billing/plan-helpers'
import { getDisplayPlanName, isFree } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
@@ -27,6 +28,7 @@ import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
const logger = createLogger('WorkspaceHeader')
@@ -131,9 +133,17 @@ export function WorkspaceHeader({
}, [])
const { isInvitationsDisabled } = usePermissionConfig()
const { data: subscriptionResponse } = useSubscriptionData()
const rawPlanName = getDisplayPlanName(subscriptionResponse?.data?.plan)
const planDisplayName = rawPlanName.includes('for Teams') ? rawPlanName : `${rawPlanName} Plan`
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
const { navigateToSettings } = useSettingsNavigation()
const currentPlan = subscriptionResponse?.data?.plan
const showPlanInfo = isBillingEnabled && typeof currentPlan !== 'undefined'
const rawPlanName = showPlanInfo ? getDisplayPlanName(currentPlan) : ''
const planDisplayName = showPlanInfo
? rawPlanName.includes('for Teams')
? rawPlanName
: `${rawPlanName} Plan`
: ''
const isFreePlan = showPlanInfo && isFree(currentPlan)
// Listen for open-invite-modal event from context menu
useEffect(() => {
@@ -395,11 +405,30 @@ export function WorkspaceHeader({
>
{workspaceInitial}
</div>
<div className='flex min-w-0 flex-col'>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{activeWorkspace?.name || 'Loading...'}
</span>
<span className='text-[var(--text-tertiary)] text-xs'>{planDisplayName}</span>
{showPlanInfo && (
<div className='flex items-center gap-2'>
<span className='truncate text-[var(--text-tertiary)] text-xs'>
{planDisplayName}
</span>
{isFreePlan && (
<button
type='button'
className='flex-shrink-0 rounded-full bg-[color-mix(in_srgb,var(--brand-accent)_16%,transparent)] px-2 py-0.5 font-medium text-[11px] text-[var(--brand-accent)] leading-none transition-opacity hover:opacity-85'
onClick={(e) => {
e.stopPropagation()
setIsWorkspaceMenuOpen(false)
navigateToSettings({ section: 'subscription' })
}}
>
Upgrade
</button>
)}
</div>
)}
</div>
</div>
@@ -463,7 +492,9 @@ export function WorkspaceHeader({
) : (
<div
className={cn(
'group flex cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]',
'group flex cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors',
workspace.id !== workspaceId &&
'hover-hover:bg-[var(--surface-hover)]',
workspace.id === workspaceId && 'bg-[var(--surface-active)]'
)}
onClick={() => onWorkspaceSwitch(workspace)}
@@ -482,7 +513,7 @@ export function WorkspaceHeader({
const rect = e.currentTarget.getBoundingClientRect()
openContextMenuAt(workspace, rect.right, rect.top)
}}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover-hover:bg-[var(--surface-7)] group-hover:opacity-100'
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100'
>
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
</button>
@@ -496,7 +527,7 @@ export function WorkspaceHeader({
<div className='mt-1 flex flex-col gap-0.5'>
<button
type='button'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)] disabled:pointer-events-none disabled:opacity-50'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-hover)] disabled:pointer-events-none disabled:opacity-50'
onClick={(e) => {
e.stopPropagation()
setIsWorkspaceMenuOpen(false)
@@ -514,7 +545,7 @@ export function WorkspaceHeader({
<DropdownMenuSeparator />
<button
type='button'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]'
className='flex w-full cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-body)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-hover)]'
onClick={() => {
setIsInviteModalOpen(true)
setIsWorkspaceMenuOpen(false)

View File

@@ -113,6 +113,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected,
isActive,
isUnread,
isMenuOpen,
showCollapsedTooltips,
onMultiSelectClick,
onContextMenu,
@@ -124,6 +125,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected: boolean
isActive: boolean
isUnread: boolean
isMenuOpen: boolean
showCollapsedTooltips: boolean
onMultiSelectClick: (taskId: string, shiftKey: boolean, metaKey: boolean) => void
onContextMenu: (e: React.MouseEvent, taskId: string) => void
@@ -136,8 +138,10 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
<Link
href={task.href}
className={cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-active)]',
(isCurrentRoute || isSelected) && 'bg-[var(--surface-active)]'
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
!(isCurrentRoute || isSelected || isMenuOpen) &&
'hover-hover:bg-[var(--surface-hover)]',
(isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]'
)}
onClick={(e) => {
if (task.id === 'new') return
@@ -177,7 +181,10 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
e.stopPropagation()
onMoreClick(e, task.id)
}}
className='flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 hover-hover:bg-[var(--surface-7)] group-hover:opacity-100'
className={cn(
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
isMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
@@ -214,8 +221,8 @@ const SidebarNavItem = memo(function SidebarNavItem({
onContextMenu?: (e: React.MouseEvent, href: string) => void
}) {
const Icon = item.icon
const baseClasses =
'group flex h-[30px] items-center gap-2 rounded-lg mx-0.5 px-2 text-sm hover-hover:bg-[var(--surface-active)]'
const baseClasses = 'group flex h-[30px] items-center gap-2 rounded-lg mx-0.5 px-2 text-sm'
const hoverClasses = !active ? 'hover-hover:bg-[var(--surface-hover)]' : ''
const activeClasses = active ? 'bg-[var(--surface-active)]' : ''
const content = (
@@ -230,7 +237,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
href={item.href}
data-item-id={item.id}
data-tour={`nav-${item.id}`}
className={`${baseClasses} ${activeClasses}`}
className={`${baseClasses} ${hoverClasses} ${activeClasses}`}
onClick={
item.onClick
? (e) => {
@@ -249,7 +256,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
type='button'
data-item-id={item.id}
data-tour={`nav-${item.id}`}
className={`${baseClasses} ${activeClasses}`}
className={`${baseClasses} ${hoverClasses} ${activeClasses}`}
onClick={item.onClick}
>
{content}
@@ -310,6 +317,11 @@ export const Sidebar = memo(function Sidebar() {
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
const isOnWorkflowPage = !!workflowId
const isCollapsedRef = useRef(isCollapsed)
useLayoutEffect(() => {
isCollapsedRef.current = isCollapsed
}, [isCollapsed])
// Delay collapsed tooltips until the width transition finishes.
const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
@@ -485,6 +497,11 @@ export const Sidebar = memo(function Sidebar() {
taskIds: [],
names: [],
})
const [menuOpenTaskId, setMenuOpenTaskId] = useState<string | null>(null)
useEffect(() => {
if (!isTaskContextMenuOpen) setMenuOpenTaskId(null)
}, [isTaskContextMenuOpen])
const captureTaskSelection = useCallback((taskId: string) => {
const { selectedTasks, selectTaskOnly } = useFolderStore.getState()
@@ -502,6 +519,7 @@ export const Sidebar = memo(function Sidebar() {
const handleTaskContextMenu = useCallback(
(e: React.MouseEvent, taskId: string) => {
captureTaskSelection(taskId)
setMenuOpenTaskId(taskId)
tasksHover.setLocked(true)
preventTaskDismiss()
handleTaskContextMenuBase(e)
@@ -523,6 +541,7 @@ export const Sidebar = memo(function Sidebar() {
}
tasksHover.setLocked(true)
captureTaskSelection(taskId)
setMenuOpenTaskId(taskId)
const rect = e.currentTarget.getBoundingClientRect()
handleTaskContextMenuBase({
preventDefault: () => {},
@@ -540,77 +559,6 @@ export const Sidebar = memo(function Sidebar() {
]
)
const {
isOpen: isCollapsedWorkflowContextMenuOpen,
position: collapsedWorkflowContextMenuPosition,
menuRef: collapsedWorkflowMenuRef,
handleContextMenu: handleCollapsedWorkflowContextMenuBase,
closeMenu: closeCollapsedWorkflowContextMenu,
preventDismiss: preventCollapsedWorkflowDismiss,
} = useContextMenu()
const collapsedWorkflowContextMenuRef = useRef<{
workflowId: string
workflowName: string
} | null>(null)
const captureCollapsedWorkflowSelection = useCallback(
(workflow: { id: string; name: string }) => {
collapsedWorkflowContextMenuRef.current = {
workflowId: workflow.id,
workflowName: workflow.name,
}
},
[]
)
const handleCollapsedWorkflowContextMenu = useCallback(
(e: React.MouseEvent, workflow: { id: string; name: string }) => {
captureCollapsedWorkflowSelection(workflow)
workflowsHover.setLocked(true)
preventCollapsedWorkflowDismiss()
handleCollapsedWorkflowContextMenuBase(e)
},
[
captureCollapsedWorkflowSelection,
handleCollapsedWorkflowContextMenuBase,
preventCollapsedWorkflowDismiss,
workflowsHover,
]
)
const handleCollapsedWorkflowMorePointerDown = useCallback(() => {
if (isCollapsedWorkflowContextMenuOpen) {
preventCollapsedWorkflowDismiss()
}
}, [isCollapsedWorkflowContextMenuOpen, preventCollapsedWorkflowDismiss])
const handleCollapsedWorkflowMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>, workflow: { id: string; name: string }) => {
if (isCollapsedWorkflowContextMenuOpen) {
closeCollapsedWorkflowContextMenu()
return
}
workflowsHover.setLocked(true)
captureCollapsedWorkflowSelection(workflow)
const rect = e.currentTarget.getBoundingClientRect()
handleCollapsedWorkflowContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[
isCollapsedWorkflowContextMenuOpen,
closeCollapsedWorkflowContextMenu,
captureCollapsedWorkflowSelection,
handleCollapsedWorkflowContextMenuBase,
workflowsHover,
]
)
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
workspaceId,
})
@@ -709,14 +657,14 @@ export const Sidebar = memo(function Sidebar() {
icon: Settings,
href: getSettingsHref(),
onClick: () => {
if (!isCollapsed) {
if (!isCollapsedRef.current) {
setSidebarWidth(SIDEBAR_WIDTH.MIN)
}
navigateToSettings()
},
},
],
[workspaceId, navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth]
[navigateToSettings, getSettingsHref, setSidebarWidth]
)
const handleStartTour = useCallback(() => {
@@ -810,12 +758,12 @@ export const Sidebar = memo(function Sidebar() {
const navigateToPage = useCallback(
(path: string) => {
if (!isCollapsed) {
if (!isCollapsedRef.current) {
setSidebarWidth(SIDEBAR_WIDTH.MIN)
}
router.push(path)
},
[isCollapsed, setSidebarWidth, router]
[setSidebarWidth, router]
)
const handleConfirmDeleteTasks = useCallback(() => {
@@ -854,10 +802,6 @@ export const Sidebar = memo(function Sidebar() {
itemType: 'workflow',
onSave: async (workflowIdToRename, name) => {
await updateWorkflow(workflowIdToRename, { name })
collapsedWorkflowContextMenuRef.current = {
workflowId: workflowIdToRename,
workflowName: name,
}
},
})
@@ -866,8 +810,8 @@ export const Sidebar = memo(function Sidebar() {
}, [isTaskContextMenuOpen, taskFlyoutRename.editingId, tasksHover.setLocked])
useEffect(() => {
workflowsHover.setLocked(isCollapsedWorkflowContextMenuOpen || !!workflowFlyoutRename.editingId)
}, [isCollapsedWorkflowContextMenuOpen, workflowFlyoutRename.editingId, workflowsHover.setLocked])
workflowsHover.setLocked(!!workflowFlyoutRename.editingId)
}, [workflowFlyoutRename.editingId, workflowsHover.setLocked])
const handleTaskOpenInNewTab = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
@@ -897,22 +841,20 @@ export const Sidebar = memo(function Sidebar() {
taskFlyoutRename.startRename({ id: taskId, name: task.name })
}, [taskFlyoutRename, tasks, tasksHover])
const handleCollapsedWorkflowOpenInNewTab = useCallback(() => {
const workflow = collapsedWorkflowContextMenuRef.current
if (!workflow) return
window.open(
`/workspace/${workspaceId}/w/${workflow.workflowId}`,
'_blank',
'noopener,noreferrer'
)
}, [workspaceId])
const handleCollapsedWorkflowOpenInNewTab = useCallback(
(workflow: { id: string }) => {
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank', 'noopener,noreferrer')
},
[workspaceId]
)
const handleStartCollapsedWorkflowRename = useCallback(() => {
const workflow = collapsedWorkflowContextMenuRef.current
if (!workflow) return
workflowsHover.setLocked(true)
workflowFlyoutRename.startRename({ id: workflow.workflowId, name: workflow.workflowName })
}, [workflowFlyoutRename, workflowsHover])
const handleCollapsedWorkflowRename = useCallback(
(workflow: { id: string; name: string }) => {
workflowsHover.setLocked(true)
workflowFlyoutRename.startRename({ id: workflow.id, name: workflow.name })
},
[workflowFlyoutRename, workflowsHover]
)
const [hasOverflowTop, setHasOverflowTop] = useState(false)
const [hasOverflowBottom, setHasOverflowBottom] = useState(false)
@@ -1064,6 +1006,88 @@ export const Sidebar = memo(function Sidebar() {
[importWorkspace]
)
// ── Memoised elements & objects for collapsed menus ──
// Prevents new JSX/object references on every render, which would defeat
// React.memo on CollapsedSidebarMenu and its children.
const tasksCollapsedIcon = useMemo(
() => <Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />,
[]
)
const workflowIconStyle = useMemo<React.CSSProperties>(
() => ({
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}),
[]
)
const workflowsCollapsedIcon = useMemo(
() => (
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={workflowIconStyle}
/>
),
[workflowIconStyle]
)
const tasksPrimaryAction = useMemo(
() => ({
label: 'New task',
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
}),
[navigateToPage, workspaceId]
)
const workflowsPrimaryAction = useMemo(
() => ({
label: 'New workflow',
onSelect: handleCreateWorkflow,
}),
[handleCreateWorkflow]
)
// Stable no-op for collapsed workflow context menu delete (never changes)
const noop = useCallback(() => {}, [])
// Stable callback for the "New task" button in expanded mode
const handleNewTask = useCallback(
() => navigateToPage(`/workspace/${workspaceId}/home`),
[navigateToPage, workspaceId]
)
// Stable callback for "See more" tasks
const handleSeeMoreTasks = useCallback(() => setVisibleTaskCount((prev) => prev + 5), [])
// Stable callback for DeleteModal close
const handleCloseTaskDeleteModal = useCallback(() => setIsTaskDeleteModalOpen(false), [])
// Stable handler for help modal open from dropdown
const handleOpenHelpFromMenu = useCallback(() => setIsHelpModalOpen(true), [])
// Stable handler for opening docs
const handleOpenDocs = useCallback(
() => window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer'),
[]
)
// Stable blur handlers for inline rename inputs
const handleTaskRenameBlur = useCallback(
() => void taskFlyoutRename.saveRename(),
[taskFlyoutRename.saveRename]
)
const handleWorkflowRenameBlur = useCallback(
() => void workflowFlyoutRename.saveRename(),
[workflowFlyoutRename.saveRename]
)
// Stable style for hidden file inputs
const hiddenStyle = useMemo(() => ({ display: 'none' }) as const, [])
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
if (workspaceId) return workspaceId
if (typeof window === 'undefined') return undefined
@@ -1150,7 +1174,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='relative flex h-[30px] items-center'>
<Link
href={`/workspace/${workspaceId}/home`}
className='sidebar-collapse-hide sidebar-collapse-remove flex h-[30px] items-center rounded-[8px] px-1.5 hover:bg-[var(--surface-active)]'
className='sidebar-collapse-hide sidebar-collapse-remove flex h-[30px] items-center rounded-[8px] px-1.5 hover-hover:bg-[var(--surface-hover)]'
tabIndex={isCollapsed ? -1 : 0}
>
{brand.logoUrl ? (
@@ -1172,7 +1196,7 @@ export const Sidebar = memo(function Sidebar() {
<button
type='button'
onClick={toggleCollapsed}
className='sidebar-collapse-show group absolute left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
className='sidebar-collapse-show group absolute left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover-hover:bg-[var(--surface-hover)]'
aria-label='Expand sidebar'
tabIndex={isCollapsed ? 0 : -1}
>
@@ -1204,7 +1228,7 @@ export const Sidebar = memo(function Sidebar() {
type='button'
onClick={toggleCollapsed}
className={cn(
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-active)]',
'sidebar-collapse-btn ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-lg transition-all duration-200 hover-hover:bg-[var(--surface-hover)]',
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
)}
aria-label='Collapse sidebar'
@@ -1256,7 +1280,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='mt-2.5 flex flex-shrink-0 flex-col gap-0.5 px-2'>
{topNavItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1273,7 +1297,7 @@ export const Sidebar = memo(function Sidebar() {
<div className='flex flex-col gap-0.5 px-2'>
{workspaceNavItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1301,8 +1325,8 @@ export const Sidebar = memo(function Sidebar() {
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleNewTask}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
@@ -1316,16 +1340,11 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
}
icon={tasksCollapsedIcon}
hover={tasksHover}
ariaLabel='Tasks'
className='mt-1.5'
primaryAction={{
label: 'New task',
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
}}
primaryAction={tasksPrimaryAction}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
@@ -1338,13 +1357,14 @@ export const Sidebar = memo(function Sidebar() {
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isMenuOpen={menuOpenTaskId === task.id}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={() => void taskFlyoutRename.saveRename()}
onEditBlur={handleTaskRenameBlur}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
@@ -1375,7 +1395,7 @@ export const Sidebar = memo(function Sidebar() {
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={() => void taskFlyoutRename.saveRename()}
onBlur={handleTaskRenameBlur}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
@@ -1390,6 +1410,7 @@ export const Sidebar = memo(function Sidebar() {
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
isMenuOpen={menuOpenTaskId === task.id}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
@@ -1401,8 +1422,8 @@ export const Sidebar = memo(function Sidebar() {
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-active)]'
onClick={handleSeeMoreTasks}
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-base'>See more</span>
@@ -1429,7 +1450,7 @@ export const Sidebar = memo(function Sidebar() {
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
@@ -1469,7 +1490,7 @@ export const Sidebar = memo(function Sidebar() {
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
@@ -1485,23 +1506,11 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isCollapsed ? (
<CollapsedSidebarMenu
icon={
<div
className='h-[16px] w-[16px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: 'var(--text-icon)',
borderColor: 'color-mix(in srgb, var(--text-icon) 60%, transparent)',
backgroundClip: 'padding-box',
}}
/>
}
icon={workflowsCollapsedIcon}
hover={workflowsHover}
ariaLabel='Workflows'
className='mt-1.5'
primaryAction={{
label: 'New workflow',
onSelect: handleCreateWorkflow,
}}
primaryAction={workflowsPrimaryAction}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
@@ -1523,10 +1532,10 @@ export const Sidebar = memo(function Sidebar() {
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onWorkflowContextMenu={handleCollapsedWorkflowContextMenu}
onWorkflowMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onWorkflowMoreClick={handleCollapsedWorkflowMoreClick}
onEditBlur={handleWorkflowRenameBlur}
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onWorkflowRename={handleCollapsedWorkflowRename}
canRenameWorkflow={canEdit}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<CollapsedWorkflowFlyoutItem
@@ -1540,10 +1549,10 @@ export const Sidebar = memo(function Sidebar() {
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onContextMenu={handleCollapsedWorkflowContextMenu}
onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onMoreClick={handleCollapsedWorkflowMoreClick}
onEditBlur={handleWorkflowRenameBlur}
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
onRename={() => handleCollapsedWorkflowRename(workflow)}
canRename={canEdit}
/>
))}
</>
@@ -1585,7 +1594,7 @@ export const Sidebar = memo(function Sidebar() {
<button
type='button'
data-item-id='help'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover:bg-[var(--surface-active)]'
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover-hover:bg-[var(--surface-hover)]'
>
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
@@ -1601,15 +1610,11 @@ export const Sidebar = memo(function Sidebar() {
)}
</Tooltip.Root>
<DropdownMenuContent align='start' side='top' sideOffset={4}>
<DropdownMenuItem
onSelect={() =>
window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
}
>
<DropdownMenuItem onSelect={handleOpenDocs}>
<BookOpen className='h-[14px] w-[14px]' />
Docs
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsHelpModalOpen(true)}>
<DropdownMenuItem onSelect={handleOpenHelpFromMenu}>
<HelpCircle className='h-[14px] w-[14px]' />
Report an issue
</DropdownMenuItem>
@@ -1622,7 +1627,7 @@ export const Sidebar = memo(function Sidebar() {
{footerItems.map((item) => (
<SidebarNavItem
key={`${item.id}-${isCollapsed}`}
key={item.id}
item={item}
active={false}
showCollapsedTooltips={showCollapsedTooltips}
@@ -1666,26 +1671,10 @@ export const Sidebar = memo(function Sidebar() {
disableDelete={!canEdit}
/>
<ContextMenu
isOpen={isCollapsedWorkflowContextMenuOpen}
position={collapsedWorkflowContextMenuPosition}
menuRef={collapsedWorkflowMenuRef}
onClose={closeCollapsedWorkflowContextMenu}
onOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onRename={handleStartCollapsedWorkflowRename}
onDelete={() => {}}
showOpenInNewTab={true}
showRename={true}
showDuplicate={false}
showColorChange={false}
showDelete={false}
disableRename={!canEdit}
/>
{/* Task Delete Confirmation Modal */}
<DeleteModal
isOpen={isTaskDeleteModalOpen}
onClose={() => setIsTaskDeleteModalOpen(false)}
onClose={handleCloseTaskDeleteModal}
onConfirm={handleConfirmDeleteTasks}
isDeleting={deleteTaskMutation.isPending || deleteTasksMutation.isPending}
itemType='task'
@@ -1732,7 +1721,7 @@ export const Sidebar = memo(function Sidebar() {
ref={workspaceFileInputRef}
type='file'
accept='.zip'
style={{ display: 'none' }}
style={hiddenStyle}
onChange={handleWorkspaceFileChange}
/>
</>

View File

@@ -56,6 +56,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
workspaceId,
folderId,
sortOrder,
deduplicate: true,
}),
})

View File

@@ -176,6 +176,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
color: workflowColor,
workspaceId: newWorkspace.id,
folderId: targetFolderId,
deduplicate: true,
}),
})

View File

@@ -303,6 +303,7 @@ async function runWorkflowExecution({
export type ScheduleExecutionPayload = {
scheduleId: string
workflowId: string
workspaceId?: string
executionId?: string
requestId?: string
correlation?: AsyncExecutionCorrelation

View File

@@ -36,6 +36,7 @@ export function buildWorkflowCorrelation(
export type WorkflowExecutionPayload = {
workflowId: string
userId: string
workspaceId?: string
input?: any
triggerType?: CoreTriggerType
executionId?: string

View File

@@ -1,5 +1,5 @@
import { createHmac } from 'crypto'
import { db } from '@sim/db'
import { db, workflowExecutionLogs } from '@sim/db'
import {
account,
workspaceNotificationDelivery,
@@ -17,11 +17,14 @@ import {
import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
import { acquireLock } from '@/lib/core/config/redis'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
import type { AlertConfig } from '@/lib/notifications/alert-rules'
@@ -32,6 +35,7 @@ const logger = createLogger('WorkspaceNotificationDelivery')
const MAX_ATTEMPTS = 5
const RETRY_DELAYS = [5 * 1000, 15 * 1000, 60 * 1000, 3 * 60 * 1000, 10 * 60 * 1000]
const NOTIFICATION_DISPATCH_LOCK_TTL_SECONDS = 3
function getRetryDelayWithJitter(baseDelay: number): number {
const jitter = Math.random() * 0.1 * baseDelay
@@ -486,12 +490,170 @@ async function updateDeliveryStatus(
export interface NotificationDeliveryParams {
deliveryId: string
subscriptionId: string
workspaceId: string
notificationType: 'webhook' | 'email' | 'slack'
log: WorkflowExecutionLog
alertConfig?: AlertConfig
}
export async function executeNotificationDelivery(params: NotificationDeliveryParams) {
export type NotificationDeliveryResult =
| { status: 'success' | 'skipped' | 'failed' }
| { status: 'retry'; retryDelayMs: number }
async function buildRetryLog(params: NotificationDeliveryParams): Promise<WorkflowExecutionLog> {
const conditions = [eq(workflowExecutionLogs.executionId, params.log.executionId)]
if (params.log.workflowId) {
conditions.push(eq(workflowExecutionLogs.workflowId, params.log.workflowId))
}
const [storedLog] = await db
.select()
.from(workflowExecutionLogs)
.where(and(...conditions))
.limit(1)
if (storedLog) {
return storedLog as unknown as WorkflowExecutionLog
}
const now = new Date().toISOString()
return {
id: `retry_log_${params.deliveryId}`,
workflowId: params.log.workflowId,
executionId: params.log.executionId,
stateSnapshotId: '',
level: 'info',
trigger: 'system',
startedAt: now,
endedAt: now,
totalDurationMs: 0,
executionData: {},
cost: { total: 0 },
createdAt: now,
}
}
export async function enqueueNotificationDeliveryDispatch(
params: NotificationDeliveryParams
): Promise<boolean> {
if (!isBullMQEnabled()) {
return false
}
const lockAcquired = await acquireLock(
`workspace-notification-dispatch:${params.deliveryId}`,
params.deliveryId,
NOTIFICATION_DISPATCH_LOCK_TTL_SECONDS
)
if (!lockAcquired) {
return false
}
await enqueueWorkspaceDispatch({
workspaceId: params.workspaceId,
lane: 'lightweight',
queueName: 'workspace-notification-delivery',
bullmqJobName: 'workspace-notification-delivery',
bullmqPayload: createBullMQJobData(params),
metadata: {
workflowId: params.log.workflowId ?? undefined,
},
})
return true
}
const STUCK_IN_PROGRESS_THRESHOLD_MS = 5 * 60 * 1000
export async function sweepPendingNotificationDeliveries(limit = 50): Promise<number> {
if (!isBullMQEnabled()) {
return 0
}
const stuckThreshold = new Date(Date.now() - STUCK_IN_PROGRESS_THRESHOLD_MS)
await db
.update(workspaceNotificationDelivery)
.set({
status: 'pending',
updatedAt: new Date(),
})
.where(
and(
eq(workspaceNotificationDelivery.status, 'in_progress'),
lte(workspaceNotificationDelivery.lastAttemptAt, stuckThreshold)
)
)
const dueDeliveries = await db
.select({
deliveryId: workspaceNotificationDelivery.id,
subscriptionId: workspaceNotificationDelivery.subscriptionId,
workflowId: workspaceNotificationDelivery.workflowId,
executionId: workspaceNotificationDelivery.executionId,
workspaceId: workspaceNotificationSubscription.workspaceId,
alertConfig: workspaceNotificationSubscription.alertConfig,
notificationType: workspaceNotificationSubscription.notificationType,
})
.from(workspaceNotificationDelivery)
.innerJoin(
workspaceNotificationSubscription,
eq(workspaceNotificationDelivery.subscriptionId, workspaceNotificationSubscription.id)
)
.where(
and(
eq(workspaceNotificationDelivery.status, 'pending'),
or(
isNull(workspaceNotificationDelivery.nextAttemptAt),
lte(workspaceNotificationDelivery.nextAttemptAt, new Date())
)
)
)
.limit(limit)
let enqueued = 0
for (const delivery of dueDeliveries) {
const params: NotificationDeliveryParams = {
deliveryId: delivery.deliveryId,
subscriptionId: delivery.subscriptionId,
workspaceId: delivery.workspaceId,
notificationType: delivery.notificationType,
log: await buildRetryLog({
deliveryId: delivery.deliveryId,
subscriptionId: delivery.subscriptionId,
workspaceId: delivery.workspaceId,
notificationType: delivery.notificationType,
log: {
id: '',
workflowId: delivery.workflowId,
executionId: delivery.executionId,
stateSnapshotId: '',
level: 'info',
trigger: 'system',
startedAt: '',
endedAt: '',
totalDurationMs: 0,
executionData: {},
cost: { total: 0 },
createdAt: '',
},
alertConfig: (delivery.alertConfig as AlertConfig | null) ?? undefined,
}),
alertConfig: (delivery.alertConfig as AlertConfig | null) ?? undefined,
}
if (await enqueueNotificationDeliveryDispatch(params)) {
enqueued += 1
}
}
return enqueued
}
export async function executeNotificationDelivery(
params: NotificationDeliveryParams
): Promise<NotificationDeliveryResult> {
const { deliveryId, subscriptionId, notificationType, log, alertConfig } = params
try {
@@ -504,7 +666,7 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
if (!subscription || !subscription.active) {
logger.warn(`Subscription ${subscriptionId} not found or inactive`)
await updateDeliveryStatus(deliveryId, 'failed', 'Subscription not found or inactive')
return
return { status: 'failed' }
}
const claimed = await db
@@ -529,7 +691,7 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
if (claimed.length === 0) {
logger.info(`Delivery ${deliveryId} not claimable`)
return
return { status: 'skipped' }
}
const attempts = claimed[0].attempts
@@ -539,7 +701,7 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
if (!payload) {
await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was archived or deleted')
logger.info(`Skipping delivery ${deliveryId} - workflow was archived or deleted`)
return
return { status: 'failed' }
}
let result: { success: boolean; status?: number; error?: string }
@@ -561,39 +723,35 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
if (result.success) {
await updateDeliveryStatus(deliveryId, 'success', undefined, result.status)
logger.info(`${notificationType} notification delivered successfully`, { deliveryId })
} else {
if (attempts < MAX_ATTEMPTS) {
const retryDelay = getRetryDelayWithJitter(
RETRY_DELAYS[attempts - 1] || RETRY_DELAYS[RETRY_DELAYS.length - 1]
)
const nextAttemptAt = new Date(Date.now() + retryDelay)
return { status: 'success' }
}
if (attempts < MAX_ATTEMPTS) {
const retryDelay = getRetryDelayWithJitter(
RETRY_DELAYS[attempts - 1] || RETRY_DELAYS[RETRY_DELAYS.length - 1]
)
const nextAttemptAt = new Date(Date.now() + retryDelay)
await updateDeliveryStatus(
deliveryId,
'pending',
result.error,
result.status,
nextAttemptAt
)
await updateDeliveryStatus(deliveryId, 'pending', result.error, result.status, nextAttemptAt)
logger.info(
`${notificationType} notification failed, scheduled retry ${attempts}/${MAX_ATTEMPTS}`,
{
deliveryId,
error: result.error,
}
)
} else {
await updateDeliveryStatus(deliveryId, 'failed', result.error, result.status)
logger.error(`${notificationType} notification failed after ${MAX_ATTEMPTS} attempts`, {
logger.info(
`${notificationType} notification failed, scheduled retry ${attempts}/${MAX_ATTEMPTS}`,
{
deliveryId,
error: result.error,
})
}
}
)
return { status: 'retry', retryDelayMs: retryDelay }
}
await updateDeliveryStatus(deliveryId, 'failed', result.error, result.status)
logger.error(`${notificationType} notification failed after ${MAX_ATTEMPTS} attempts`, {
deliveryId,
error: result.error,
})
return { status: 'failed' }
} catch (error) {
logger.error('Notification delivery failed', { deliveryId, error })
await updateDeliveryStatus(deliveryId, 'failed', 'Internal error')
return { status: 'failed' }
}
}

View File

@@ -1532,7 +1532,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
projectId: effectiveProjectId || undefined,
includeArchived: params.includeArchived,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_get_issue':
@@ -1599,7 +1599,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
teamId: effectiveTeamId,
includeArchived: params.includeArchived,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_add_label_to_issue':
@@ -1650,7 +1650,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
issueId: params.issueId.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_list_projects':
@@ -1659,7 +1659,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
teamId: effectiveTeamId,
includeArchived: params.includeArchived,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_get_project':
@@ -1714,7 +1714,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_get_viewer':
@@ -1725,7 +1725,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
teamId: effectiveTeamId,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_create_label':
@@ -1764,7 +1764,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
teamId: effectiveTeamId,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_create_workflow_state':
@@ -1795,7 +1795,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
teamId: effectiveTeamId,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_get_cycle':
@@ -1860,7 +1860,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
issueId: params.issueId.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_update_attachment':
@@ -1901,7 +1901,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
issueId: params.issueId.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_delete_issue_relation':
@@ -1927,7 +1927,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_create_project_update':
@@ -1949,14 +1949,14 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
projectId: effectiveProjectId,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_list_notifications':
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_update_notification':
@@ -1988,7 +1988,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
includeArchived: false,
}
@@ -2023,7 +2023,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
includeArchived: false,
}
@@ -2117,7 +2117,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
// Customer Tier Operations
@@ -2159,7 +2159,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
// Project Management Operations
@@ -2212,7 +2212,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
projectId: effectiveProjectId || undefined,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
case 'linear_add_label_to_project':
@@ -2277,7 +2277,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
...baseParams,
projectId: params.projectIdForMilestone.trim(),
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
// Project Status Operations
@@ -2328,7 +2328,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
return {
...baseParams,
first: params.first ? Number(params.first) : undefined,
after: params.after,
after: params.after?.trim() || undefined,
}
default:

View File

@@ -21,7 +21,7 @@ import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
const comboboxVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50 hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]',
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
@@ -520,7 +520,7 @@ const Combobox = memo(
<Input
ref={inputRef}
className={cn(
'w-full pr-10 font-medium transition-colors hover-hover:bg-[var(--surface-7)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]',
'w-full pr-10 font-medium transition-colors',
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
SelectedIcon && !overlayContent && 'pl-7',
open && 'focus-visible:border-[var(--border-1)]',
@@ -747,8 +747,8 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--border-1)]',
(isHighlighted || isSelected) && 'bg-[var(--border-1)]',
'hover-hover:bg-[var(--surface-active)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
option.disabled && 'cursor-not-allowed opacity-50'
)}
>
@@ -787,8 +787,8 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--border-1)]',
!multiSelectValues?.length && 'bg-[var(--border-1)]'
'hover-hover:bg-[var(--surface-active)]',
!multiSelectValues?.length && 'bg-[var(--surface-active)]'
)}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
@@ -821,8 +821,8 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-medium font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
'hover-hover:bg-[var(--border-1)]',
(isHighlighted || isSelected) && 'bg-[var(--border-1)]',
'hover-hover:bg-[var(--surface-active)]',
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
option.disabled && 'cursor-not-allowed opacity-50'
)}
>

View File

@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
* Matches the combobox and input styling patterns.
*/
const datePickerVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50 hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)]',
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -26,7 +26,7 @@ import { cn } from '@/lib/core/utils/cn'
* Currently supports a 'default' variant.
*/
const inputVariants = cva(
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -116,8 +116,8 @@ const STYLES = {
/** Interactive state styles: default, secondary (brand), inverted (dark bg in light mode) */
states: {
default: {
active: 'bg-[var(--border-1)]',
hover: 'hover-hover:bg-[var(--border-1)]',
active: 'bg-[var(--surface-active)]',
hover: 'hover-hover:bg-[var(--surface-active)]',
},
secondary: {
active: 'bg-[var(--brand-secondary)] text-white [&_svg]:text-white',

View File

@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/core/utils/cn'
const textareaVariants = cva(
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-2 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] resize-none overflow-auto disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-2 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none resize-none overflow-auto disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
* Matches the input and combobox styling patterns.
*/
const timePickerVariants = cva(
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50 hover-hover:border-[var(--surface-7)] hover-hover:bg-[var(--surface-5)] dark:hover-hover:border-[var(--surface-7)] dark:hover-hover:bg-[var(--border-1)] transition-colors',
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors',
{
variants: {
variant: {
@@ -256,7 +256,7 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
<div className='flex items-center gap-1.5'>
<input
ref={hourInputRef}
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)] focus-visible:border-[var(--text-muted)]'
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)]'
value={hour}
onChange={handleHourChange}
onBlur={handleHourBlur}
@@ -268,7 +268,7 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
/>
<span className='font-medium text-[var(--text-muted)] text-small'>:</span>
<input
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)] focus-visible:border-[var(--text-muted)]'
className='w-[40px] rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-1.5 py-[5px] text-center font-medium font-sans text-[var(--text-primary)] text-small outline-none transition-colors placeholder:text-[var(--text-muted)]'
value={minute}
onChange={handleMinuteChange}
onBlur={handleMinuteBlur}
@@ -291,7 +291,7 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
'px-2 py-[5px] font-medium font-sans text-caption transition-colors',
ampm === period
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover-hover:bg-[var(--surface-7)] hover-hover:text-[var(--text-primary)] dark:hover-hover:bg-[var(--surface-5)]'
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)]'
)}
>
{period}

View File

@@ -1287,7 +1287,9 @@ export function AccessControl() {
<p className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingGroup?.name}</span>?
All members will be removed from this group.{' '}
<span className='text-[var(--text-error)]'>
All members will be removed from this group.
</span>{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>

View File

@@ -164,6 +164,7 @@ interface CreateWorkflowVariables {
folderId?: string | null
sortOrder?: number
id?: string
deduplicate?: boolean
}
interface CreateWorkflowResult {
@@ -300,7 +301,8 @@ export function useCreateWorkflow() {
return useMutation({
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
const { workspaceId, name, description, color, folderId, sortOrder, id } = variables
const { workspaceId, name, description, color, folderId, sortOrder, id, deduplicate } =
variables
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
@@ -315,6 +317,7 @@ export function useCreateWorkflow() {
workspaceId,
folderId: folderId || null,
sortOrder,
deduplicate,
}),
})

View File

@@ -14,6 +14,20 @@ export const AuthType = {
export type AuthTypeValue = (typeof AuthType)[keyof typeof AuthType]
const API_KEY_HEADER = 'x-api-key'
const BEARER_PREFIX = 'Bearer '
/**
* Lightweight header-only check for whether a request carries external API credentials.
* Does NOT validate the credentials — only inspects headers to classify the request
* as programmatic API traffic vs interactive session traffic.
*/
export function hasExternalApiCredentials(headers: Headers): boolean {
if (headers.has(API_KEY_HEADER)) return true
const auth = headers.get('authorization')
return auth !== null && auth.startsWith(BEARER_PREFIX)
}
export interface AuthResult {
success: boolean
userId?: string

View File

@@ -13,7 +13,7 @@ import {
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { parseEnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { env } from '@/lib/core/config/env'
export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const
@@ -80,27 +80,15 @@ export function checkEnterprisePlan(subscription: any): boolean {
return isEnterprise(subscription?.plan) && hasPaidSubscriptionStatus(subscription?.status)
}
/**
* Type guard to check if metadata is valid EnterpriseSubscriptionMetadata
*/
function isEnterpriseMetadata(metadata: unknown): metadata is EnterpriseSubscriptionMetadata {
return (
!!metadata &&
typeof metadata === 'object' &&
'seats' in metadata &&
typeof (metadata as EnterpriseSubscriptionMetadata).seats === 'string'
)
}
export function getEffectiveSeats(subscription: any): number {
if (!subscription) {
return 0
}
if (isEnterprise(subscription.plan)) {
const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | null
if (isEnterpriseMetadata(metadata)) {
return Number.parseInt(metadata.seats, 10)
const metadata = parseEnterpriseSubscriptionMetadata(subscription.metadata)
if (metadata) {
return metadata.seats
}
return 0
}

View File

@@ -2,18 +2,47 @@
* Billing System Types
* Centralized type definitions for the billing system
*/
import { z } from 'zod'
export interface EnterpriseSubscriptionMetadata {
plan: 'enterprise'
export const enterpriseSubscriptionMetadataSchema = z.object({
plan: z
.string()
.transform((v) => v.toLowerCase())
.pipe(z.literal('enterprise')),
// The referenceId must be provided in Stripe metadata to link to the organization
// This gets stored in the subscription.referenceId column
referenceId: string
referenceId: z.string().min(1),
// The fixed monthly price for this enterprise customer (as string from Stripe metadata)
// This will be used to set the organization's usage limit
monthlyPrice: string
// Number of seats for invitation limits (not for billing) (as string from Stripe metadata)
// We set Stripe quantity to 1 and use this for actual seat count
seats: string
monthlyPrice: z.coerce.number().positive(),
// Number of seats for invitation limits (not for billing)
seats: z.coerce.number().int().positive(),
// Optional custom workspace concurrency limit for enterprise workspaces
workspaceConcurrencyLimit: z.coerce.number().int().positive().optional(),
})
export type EnterpriseSubscriptionMetadata = z.infer<typeof enterpriseSubscriptionMetadataSchema>
const enterpriseWorkspaceConcurrencyMetadataSchema = z.object({
workspaceConcurrencyLimit: z.coerce.number().int().positive().optional(),
})
export type EnterpriseWorkspaceConcurrencyMetadata = z.infer<
typeof enterpriseWorkspaceConcurrencyMetadataSchema
>
export function parseEnterpriseSubscriptionMetadata(
value: unknown
): EnterpriseSubscriptionMetadata | null {
const result = enterpriseSubscriptionMetadataSchema.safeParse(value)
return result.success ? result.data : null
}
export function parseEnterpriseWorkspaceConcurrencyMetadata(
value: unknown
): EnterpriseWorkspaceConcurrencyMetadata | null {
const result = enterpriseWorkspaceConcurrencyMetadataSchema.safeParse(value)
return result.success ? result.data : null
}
export interface UsageData {

View File

@@ -6,26 +6,10 @@ import type Stripe from 'stripe'
import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import type { EnterpriseSubscriptionMetadata } from '../types'
import { parseEnterpriseSubscriptionMetadata } from '../types'
const logger = createLogger('BillingEnterprise')
function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata {
return (
!!value &&
typeof value === 'object' &&
'plan' in value &&
'referenceId' in value &&
'monthlyPrice' in value &&
'seats' in value &&
typeof value.plan === 'string' &&
value.plan.toLowerCase() === 'enterprise' &&
typeof value.referenceId === 'string' &&
typeof value.monthlyPrice === 'string' &&
typeof value.seats === 'string'
)
}
export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
const stripeSubscription = event.data.object as Stripe.Subscription
@@ -63,37 +47,16 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
throw new Error('Unable to resolve referenceId for subscription')
}
if (!isEnterpriseMetadata(metadata)) {
const enterpriseMetadata = parseEnterpriseSubscriptionMetadata(metadata)
if (!enterpriseMetadata) {
logger.error('[subscription.created] Invalid enterprise metadata shape', {
subscriptionId: stripeSubscription.id,
metadata,
})
throw new Error('Invalid enterprise metadata for subscription')
}
const enterpriseMetadata = metadata
const metadataJson: Record<string, unknown> = { ...enterpriseMetadata }
// Extract and parse seats and monthly price from metadata (they come as strings from Stripe)
const seats = Number.parseInt(enterpriseMetadata.seats, 10)
const monthlyPrice = Number.parseFloat(enterpriseMetadata.monthlyPrice)
if (!seats || seats <= 0 || Number.isNaN(seats)) {
logger.error('[subscription.created] Invalid or missing seats in enterprise metadata', {
subscriptionId: stripeSubscription.id,
seatsRaw: enterpriseMetadata.seats,
seatsParsed: seats,
})
throw new Error('Enterprise subscription must include valid seats in metadata')
}
if (!monthlyPrice || monthlyPrice <= 0 || Number.isNaN(monthlyPrice)) {
logger.error('[subscription.created] Invalid or missing monthlyPrice in enterprise metadata', {
subscriptionId: stripeSubscription.id,
monthlyPriceRaw: enterpriseMetadata.monthlyPrice,
monthlyPriceParsed: monthlyPrice,
})
throw new Error('Enterprise subscription must include valid monthlyPrice in metadata')
}
const { seats, monthlyPrice } = enterpriseMetadata
// Get the first subscription item which contains the period information
const referenceItem = stripeSubscription.items?.data?.[0]
@@ -117,7 +80,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
? new Date(stripeSubscription.trial_start * 1000)
: null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
metadata: metadataJson,
metadata: metadata as Record<string, unknown>,
}
const existing = await db

View File

@@ -0,0 +1,146 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetHighestPrioritySubscription,
mockGetWorkspaceBilledAccountUserId,
mockFeatureFlags,
mockRedisGet,
mockRedisSet,
mockRedisDel,
mockRedisKeys,
mockGetRedisClient,
} = vi.hoisted(() => ({
mockGetHighestPrioritySubscription: vi.fn(),
mockGetWorkspaceBilledAccountUserId: vi.fn(),
mockFeatureFlags: {
isBillingEnabled: true,
},
mockRedisGet: vi.fn(),
mockRedisSet: vi.fn(),
mockRedisDel: vi.fn(),
mockRedisKeys: vi.fn(),
mockGetRedisClient: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/lib/billing/core/plan', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags)
import {
getWorkspaceConcurrencyLimit,
resetWorkspaceConcurrencyLimitCache,
} from '@/lib/billing/workspace-concurrency'
describe('workspace concurrency billing', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFeatureFlags.isBillingEnabled = true
mockRedisGet.mockResolvedValue(null)
mockRedisSet.mockResolvedValue('OK')
mockRedisDel.mockResolvedValue(1)
mockRedisKeys.mockResolvedValue([])
mockGetRedisClient.mockReturnValue({
get: mockRedisGet,
set: mockRedisSet,
del: mockRedisDel,
keys: mockRedisKeys,
})
})
it('returns free tier when no billed account exists', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null)
await expect(getWorkspaceConcurrencyLimit('workspace-1')).resolves.toBe(5)
})
it('returns pro limit for pro billing accounts', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
mockGetHighestPrioritySubscription.mockResolvedValue({
plan: 'pro_6000',
metadata: null,
})
await expect(getWorkspaceConcurrencyLimit('workspace-1')).resolves.toBe(50)
})
it('returns max limit for max plan tiers', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
mockGetHighestPrioritySubscription.mockResolvedValue({
plan: 'pro_25000',
metadata: null,
})
await expect(getWorkspaceConcurrencyLimit('workspace-1')).resolves.toBe(200)
})
it('returns max limit for legacy team plans', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
mockGetHighestPrioritySubscription.mockResolvedValue({
plan: 'team',
metadata: null,
})
await expect(getWorkspaceConcurrencyLimit('workspace-1')).resolves.toBe(200)
})
it('returns enterprise metadata override when present', async () => {
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
mockGetHighestPrioritySubscription.mockResolvedValue({
plan: 'enterprise',
metadata: {
workspaceConcurrencyLimit: '350',
},
})
await expect(getWorkspaceConcurrencyLimit('workspace-1')).resolves.toBe(350)
})
it('uses free-tier limit when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
mockGetHighestPrioritySubscription.mockResolvedValue({
plan: 'pro_25000',
metadata: {
workspaceConcurrencyLimit: 999,
},
})
await expect(getWorkspaceConcurrencyLimit('workspace-1')).resolves.toBe(5)
})
it('uses redis cache when available', async () => {
mockRedisGet.mockResolvedValueOnce('123')
await expect(getWorkspaceConcurrencyLimit('workspace-1')).resolves.toBe(123)
expect(mockGetWorkspaceBilledAccountUserId).not.toHaveBeenCalled()
})
it('can clear a specific workspace cache entry', async () => {
await resetWorkspaceConcurrencyLimitCache('workspace-1')
expect(mockRedisDel).toHaveBeenCalledWith('workspace-concurrency-limit:workspace-1')
})
})

View File

@@ -0,0 +1,170 @@
import { createLogger } from '@sim/logger'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getPlanTierCredits, isEnterprise, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { parseEnterpriseWorkspaceConcurrencyMetadata } from '@/lib/billing/types'
import { env } from '@/lib/core/config/env'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { getRedisClient } from '@/lib/core/config/redis'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
const logger = createLogger('WorkspaceConcurrencyBilling')
const CACHE_TTL_MS = 60_000
const CACHE_TTL_SECONDS = Math.floor(CACHE_TTL_MS / 1000)
interface CacheEntry {
value: number
expiresAt: number
}
const inMemoryConcurrencyCache = new Map<string, CacheEntry>()
function cacheKey(workspaceId: string): string {
return `workspace-concurrency-limit:${workspaceId}`
}
function parsePositiveLimit(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value)
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10)
if (Number.isFinite(parsed) && parsed > 0) {
return parsed
}
}
return null
}
function getFreeConcurrencyLimit(): number {
return Number.parseInt(env.WORKSPACE_CONCURRENCY_FREE, 10) || 5
}
function getProConcurrencyLimit(): number {
return Number.parseInt(env.WORKSPACE_CONCURRENCY_PRO, 10) || 50
}
function getTeamConcurrencyLimit(): number {
return Number.parseInt(env.WORKSPACE_CONCURRENCY_TEAM, 10) || 200
}
function getEnterpriseDefaultConcurrencyLimit(): number {
return Number.parseInt(env.WORKSPACE_CONCURRENCY_ENTERPRISE, 10) || 200
}
function getEnterpriseConcurrencyLimit(metadata: unknown): number {
const enterpriseMetadata = parseEnterpriseWorkspaceConcurrencyMetadata(metadata)
return enterpriseMetadata?.workspaceConcurrencyLimit ?? getEnterpriseDefaultConcurrencyLimit()
}
function getPlanConcurrencyLimit(plan: string | null | undefined, metadata: unknown): number {
if (!isBillingEnabled) {
return getFreeConcurrencyLimit()
}
if (!plan) {
return getFreeConcurrencyLimit()
}
if (isEnterprise(plan)) {
return getEnterpriseConcurrencyLimit(metadata)
}
if (isTeam(plan)) {
return getTeamConcurrencyLimit()
}
const credits = getPlanTierCredits(plan)
if (credits >= 25_000) {
return getTeamConcurrencyLimit()
}
if (isPro(plan)) {
return getProConcurrencyLimit()
}
return getFreeConcurrencyLimit()
}
export async function getWorkspaceConcurrencyLimit(workspaceId: string): Promise<number> {
const redis = getRedisClient()
if (redis) {
const cached = await redis.get(cacheKey(workspaceId))
const cachedValue = parsePositiveLimit(cached)
if (cachedValue !== null) {
return cachedValue
}
} else {
const cached = inMemoryConcurrencyCache.get(workspaceId)
if (cached && cached.expiresAt > Date.now()) {
return cached.value
}
}
try {
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
if (!billedAccountUserId) {
if (redis) {
await redis.set(
cacheKey(workspaceId),
String(getFreeConcurrencyLimit()),
'EX',
CACHE_TTL_SECONDS
)
} else {
inMemoryConcurrencyCache.set(workspaceId, {
value: getFreeConcurrencyLimit(),
expiresAt: Date.now() + CACHE_TTL_MS,
})
}
return getFreeConcurrencyLimit()
}
const subscription = await getHighestPrioritySubscription(billedAccountUserId)
const limit = getPlanConcurrencyLimit(subscription?.plan, subscription?.metadata)
if (redis) {
await redis.set(cacheKey(workspaceId), String(limit), 'EX', CACHE_TTL_SECONDS)
} else {
inMemoryConcurrencyCache.set(workspaceId, {
value: limit,
expiresAt: Date.now() + CACHE_TTL_MS,
})
}
return limit
} catch (error) {
logger.error('Failed to resolve workspace concurrency limit, using free tier', {
workspaceId,
error,
})
return getFreeConcurrencyLimit()
}
}
export async function resetWorkspaceConcurrencyLimitCache(workspaceId?: string): Promise<void> {
if (!workspaceId) {
inMemoryConcurrencyCache.clear()
} else {
inMemoryConcurrencyCache.delete(workspaceId)
}
const redis = getRedisClient()
if (!redis) {
return
}
if (workspaceId) {
await redis.del(cacheKey(workspaceId))
return
}
const keys = await redis.keys('workspace-concurrency-limit:*')
if (keys.length > 0) {
await redis.del(...keys)
}
}

View File

@@ -0,0 +1,62 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
const logger = createLogger('AdmissionGate')
const MAX_INFLIGHT = Number.parseInt(env.ADMISSION_GATE_MAX_INFLIGHT ?? '') || 500
let inflight = 0
export interface AdmissionTicket {
release: () => void
}
/**
* Attempts to admit a request through the in-process gate.
* Returns a ticket with a release() handle on success, or null if at capacity.
* Zero external calls — purely in-process atomic counter. Each pod maintains its
* own counter, so the effective aggregate limit across N pods is N × MAX_INFLIGHT.
* Configure ADMISSION_GATE_MAX_INFLIGHT per pod based on what each pod can sustain.
*/
export function tryAdmit(): AdmissionTicket | null {
if (inflight >= MAX_INFLIGHT) {
return null
}
inflight++
let released = false
return {
release() {
if (released) return
released = true
inflight--
},
}
}
/**
* Returns a 429 response for requests rejected by the admission gate.
*/
export function admissionRejectedResponse(): NextResponse {
logger.warn('Admission gate rejecting request', { inflight, maxInflight: MAX_INFLIGHT })
return NextResponse.json(
{
error: 'Too many requests',
message: 'Server is at capacity. Please retry shortly.',
retryAfterSeconds: 5,
},
{
status: 429,
headers: { 'Retry-After': '5' },
}
)
}
/**
* Returns the current gate metrics for observability.
*/
export function getAdmissionGateStatus(): { inflight: number; maxInflight: number } {
return { inflight, maxInflight: MAX_INFLIGHT }
}

View File

@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import type { Job as BullMQJob } from 'bullmq'
import {
type EnqueueOptions,
JOB_STATUS,
type Job,
type JobQueueBackend,
type JobStatus,
type JobType,
} from '@/lib/core/async-jobs/types'
import { type BullMQJobData, createBullMQJobData, getBullMQQueue } from '@/lib/core/bullmq'
const logger = createLogger('BullMQJobQueue')
function mapBullMQStatus(status: string): JobStatus {
switch (status) {
case 'active':
return JOB_STATUS.PROCESSING
case 'completed':
return JOB_STATUS.COMPLETED
case 'failed':
return JOB_STATUS.FAILED
default:
return JOB_STATUS.PENDING
}
}
async function toJob(
queueType: JobType,
bullJob: BullMQJob<BullMQJobData<unknown>> | null
): Promise<Job | null> {
if (!bullJob) {
return null
}
const status = mapBullMQStatus(await bullJob.getState())
return {
id: bullJob.id ?? '',
type: queueType,
payload: bullJob.data.payload,
status,
createdAt: new Date(bullJob.timestamp),
startedAt: bullJob.processedOn ? new Date(bullJob.processedOn) : undefined,
completedAt: bullJob.finishedOn ? new Date(bullJob.finishedOn) : undefined,
attempts: bullJob.attemptsMade,
maxAttempts: bullJob.opts.attempts ?? 1,
error: bullJob.failedReason || undefined,
output: bullJob.returnvalue,
metadata: bullJob.data.metadata ?? {},
}
}
export class BullMQJobQueue implements JobQueueBackend {
async enqueue<TPayload>(
type: JobType,
payload: TPayload,
options?: EnqueueOptions
): Promise<string> {
const queue = getBullMQQueue(type)
const job = await queue.add(
options?.name ?? type,
createBullMQJobData(payload, options?.metadata),
{
jobId: options?.jobId,
attempts: options?.maxAttempts,
priority: options?.priority,
delay: options?.delayMs,
}
)
logger.debug('Enqueued job via BullMQ', {
jobId: job.id,
type,
name: options?.name ?? type,
})
return String(job.id)
}
async getJob(jobId: string): Promise<Job | null> {
const workflowJob = await getBullMQQueue('workflow-execution').getJob(jobId)
if (workflowJob) {
return toJob('workflow-execution', workflowJob)
}
const webhookJob = await getBullMQQueue('webhook-execution').getJob(jobId)
if (webhookJob) {
return toJob('webhook-execution', webhookJob)
}
const scheduleJob = await getBullMQQueue('schedule-execution').getJob(jobId)
if (scheduleJob) {
return toJob('schedule-execution', scheduleJob)
}
return null
}
async startJob(_jobId: string): Promise<void> {}
async completeJob(_jobId: string, _output: unknown): Promise<void> {}
async markJobFailed(_jobId: string, _error: string): Promise<void> {}
}

View File

@@ -1,3 +1,3 @@
export { BullMQJobQueue } from './bullmq'
export { DatabaseJobQueue } from './database'
export { RedisJobQueue } from './redis'
export { TriggerDevJobQueue } from './trigger-dev'

View File

@@ -1,176 +0,0 @@
/**
* @vitest-environment node
*/
import { createMockRedis, loggerMock, type MockRedis } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/logger', () => loggerMock)
import {
JOB_MAX_LIFETIME_SECONDS,
JOB_RETENTION_SECONDS,
JOB_STATUS,
} from '@/lib/core/async-jobs/types'
import { RedisJobQueue } from './redis'
describe('RedisJobQueue', () => {
let mockRedis: MockRedis
let queue: RedisJobQueue
beforeEach(() => {
vi.clearAllMocks()
mockRedis = createMockRedis()
queue = new RedisJobQueue(mockRedis as never)
})
describe('enqueue', () => {
it.concurrent('should create a job with pending status', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
const jobId = await localQueue.enqueue('workflow-execution', { test: 'data' })
expect(jobId).toMatch(/^run_/)
expect(localRedis.hset).toHaveBeenCalledTimes(1)
const [key, data] = localRedis.hset.mock.calls[0]
expect(key).toBe(`async-jobs:job:${jobId}`)
expect(data.status).toBe(JOB_STATUS.PENDING)
expect(data.type).toBe('workflow-execution')
})
it.concurrent('should set max lifetime TTL on enqueue', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
const jobId = await localQueue.enqueue('workflow-execution', { test: 'data' })
expect(localRedis.expire).toHaveBeenCalledWith(
`async-jobs:job:${jobId}`,
JOB_MAX_LIFETIME_SECONDS
)
})
})
describe('completeJob', () => {
it.concurrent('should set status to completed and set TTL', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
const jobId = 'run_test123'
await localQueue.completeJob(jobId, { result: 'success' })
expect(localRedis.hset).toHaveBeenCalledWith(`async-jobs:job:${jobId}`, {
status: JOB_STATUS.COMPLETED,
completedAt: expect.any(String),
output: JSON.stringify({ result: 'success' }),
updatedAt: expect.any(String),
})
expect(localRedis.expire).toHaveBeenCalledWith(
`async-jobs:job:${jobId}`,
JOB_RETENTION_SECONDS
)
})
it.concurrent('should set TTL to 24 hours (86400 seconds)', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
await localQueue.completeJob('run_test123', {})
expect(localRedis.expire).toHaveBeenCalledWith(expect.any(String), 86400)
})
})
describe('markJobFailed', () => {
it.concurrent('should set status to failed and set TTL', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
const jobId = 'run_test456'
const error = 'Something went wrong'
await localQueue.markJobFailed(jobId, error)
expect(localRedis.hset).toHaveBeenCalledWith(`async-jobs:job:${jobId}`, {
status: JOB_STATUS.FAILED,
completedAt: expect.any(String),
error,
updatedAt: expect.any(String),
})
expect(localRedis.expire).toHaveBeenCalledWith(
`async-jobs:job:${jobId}`,
JOB_RETENTION_SECONDS
)
})
it.concurrent('should set TTL to 24 hours (86400 seconds)', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
await localQueue.markJobFailed('run_test456', 'error')
expect(localRedis.expire).toHaveBeenCalledWith(expect.any(String), 86400)
})
})
describe('startJob', () => {
it.concurrent('should not set TTL when starting a job', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
await localQueue.startJob('run_test789')
expect(localRedis.hset).toHaveBeenCalled()
expect(localRedis.expire).not.toHaveBeenCalled()
})
})
describe('getJob', () => {
it.concurrent('should return null for non-existent job', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
localRedis.hgetall.mockResolvedValue({})
const job = await localQueue.getJob('run_nonexistent')
expect(job).toBeNull()
})
it.concurrent('should deserialize job data correctly', async () => {
const localRedis = createMockRedis()
const localQueue = new RedisJobQueue(localRedis as never)
const now = new Date()
localRedis.hgetall.mockResolvedValue({
id: 'run_test',
type: 'workflow-execution',
payload: JSON.stringify({ foo: 'bar' }),
status: JOB_STATUS.COMPLETED,
createdAt: now.toISOString(),
startedAt: now.toISOString(),
completedAt: now.toISOString(),
attempts: '1',
maxAttempts: '3',
error: '',
output: JSON.stringify({ result: 'ok' }),
metadata: JSON.stringify({ workflowId: 'wf_123' }),
})
const job = await localQueue.getJob('run_test')
expect(job).not.toBeNull()
expect(job?.id).toBe('run_test')
expect(job?.type).toBe('workflow-execution')
expect(job?.payload).toEqual({ foo: 'bar' })
expect(job?.status).toBe(JOB_STATUS.COMPLETED)
expect(job?.output).toEqual({ result: 'ok' })
expect(job?.metadata.workflowId).toBe('wf_123')
})
})
})
describe('JOB_RETENTION_SECONDS', () => {
it.concurrent('should be 24 hours in seconds', async () => {
expect(JOB_RETENTION_SECONDS).toBe(24 * 60 * 60)
expect(JOB_RETENTION_SECONDS).toBe(86400)
})
})

View File

@@ -1,146 +0,0 @@
import { createLogger } from '@sim/logger'
import type Redis from 'ioredis'
import {
type EnqueueOptions,
JOB_MAX_LIFETIME_SECONDS,
JOB_RETENTION_SECONDS,
JOB_STATUS,
type Job,
type JobMetadata,
type JobQueueBackend,
type JobStatus,
type JobType,
} from '@/lib/core/async-jobs/types'
const logger = createLogger('RedisJobQueue')
const KEYS = {
job: (id: string) => `async-jobs:job:${id}`,
} as const
function serializeJob(job: Job): Record<string, string> {
return {
id: job.id,
type: job.type,
payload: JSON.stringify(job.payload),
status: job.status,
createdAt: job.createdAt.toISOString(),
startedAt: job.startedAt?.toISOString() ?? '',
completedAt: job.completedAt?.toISOString() ?? '',
attempts: job.attempts.toString(),
maxAttempts: job.maxAttempts.toString(),
error: job.error ?? '',
output: job.output !== undefined ? JSON.stringify(job.output) : '',
metadata: JSON.stringify(job.metadata),
updatedAt: new Date().toISOString(),
}
}
function deserializeJob(data: Record<string, string>): Job | null {
if (!data || !data.id) return null
try {
return {
id: data.id,
type: data.type as JobType,
payload: JSON.parse(data.payload),
status: data.status as JobStatus,
createdAt: new Date(data.createdAt),
startedAt: data.startedAt ? new Date(data.startedAt) : undefined,
completedAt: data.completedAt ? new Date(data.completedAt) : undefined,
attempts: Number.parseInt(data.attempts, 10),
maxAttempts: Number.parseInt(data.maxAttempts, 10),
error: data.error || undefined,
output: data.output ? JSON.parse(data.output) : undefined,
metadata: JSON.parse(data.metadata) as JobMetadata,
}
} catch (error) {
logger.error('Failed to deserialize job', { error, data })
return null
}
}
export class RedisJobQueue implements JobQueueBackend {
private redis: Redis
constructor(redis: Redis) {
this.redis = redis
}
async enqueue<TPayload>(
type: JobType,
payload: TPayload,
options?: EnqueueOptions
): Promise<string> {
const jobId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`
const now = new Date()
const job: Job<TPayload> = {
id: jobId,
type,
payload,
status: JOB_STATUS.PENDING,
createdAt: now,
attempts: 0,
maxAttempts: options?.maxAttempts ?? 3,
metadata: options?.metadata ?? {},
}
const key = KEYS.job(jobId)
const serialized = serializeJob(job as Job)
await this.redis.hset(key, serialized)
await this.redis.expire(key, JOB_MAX_LIFETIME_SECONDS)
logger.debug('Enqueued job', { jobId, type })
return jobId
}
async getJob(jobId: string): Promise<Job | null> {
const data = await this.redis.hgetall(KEYS.job(jobId))
return deserializeJob(data)
}
async startJob(jobId: string): Promise<void> {
const now = new Date()
const key = KEYS.job(jobId)
await this.redis.hset(key, {
status: JOB_STATUS.PROCESSING,
startedAt: now.toISOString(),
updatedAt: now.toISOString(),
})
await this.redis.hincrby(key, 'attempts', 1)
logger.debug('Started job', { jobId })
}
async completeJob(jobId: string, output: unknown): Promise<void> {
const now = new Date()
const key = KEYS.job(jobId)
await this.redis.hset(key, {
status: JOB_STATUS.COMPLETED,
completedAt: now.toISOString(),
output: JSON.stringify(output),
updatedAt: now.toISOString(),
})
await this.redis.expire(key, JOB_RETENTION_SECONDS)
logger.debug('Completed job', { jobId })
}
async markJobFailed(jobId: string, error: string): Promise<void> {
const now = new Date()
const key = KEYS.job(jobId)
await this.redis.hset(key, {
status: JOB_STATUS.FAILED,
completedAt: now.toISOString(),
error,
updatedAt: now.toISOString(),
})
await this.redis.expire(key, JOB_RETENTION_SECONDS)
logger.debug('Marked job as failed', { jobId })
}
}

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import type { AsyncBackendType, JobQueueBackend } from '@/lib/core/async-jobs/types'
import { isBullMQEnabled } from '@/lib/core/bullmq'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('AsyncJobsConfig')
@@ -11,16 +11,15 @@ let cachedInlineBackend: JobQueueBackend | null = null
/**
* Determines which async backend to use based on environment configuration.
* Follows the fallback chain: trigger.dev → redis → database
* Follows the fallback chain: trigger.dev → bullmq → database
*/
export function getAsyncBackendType(): AsyncBackendType {
if (isTriggerDevEnabled) {
return 'trigger-dev'
}
const redis = getRedisClient()
if (redis) {
return 'redis'
if (isBullMQEnabled()) {
return 'bullmq'
}
return 'database'
@@ -43,13 +42,9 @@ export async function getJobQueue(): Promise<JobQueueBackend> {
cachedBackend = new TriggerDevJobQueue()
break
}
case 'redis': {
const redis = getRedisClient()
if (!redis) {
throw new Error('Redis client not available but redis backend was selected')
}
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
cachedBackend = new RedisJobQueue(redis)
case 'bullmq': {
const { BullMQJobQueue } = await import('@/lib/core/async-jobs/backends/bullmq')
cachedBackend = new BullMQJobQueue()
break
}
case 'database': {
@@ -62,6 +57,10 @@ export async function getJobQueue(): Promise<JobQueueBackend> {
cachedBackendType = type
logger.info(`Async job backend initialized: ${type}`)
if (!cachedBackend) {
throw new Error(`Failed to initialize async backend: ${type}`)
}
return cachedBackend
}
@@ -73,20 +72,19 @@ export function getCurrentBackendType(): AsyncBackendType | null {
}
/**
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
* Used for non-polling webhooks that should always execute inline.
* Gets a job queue backend that bypasses Trigger.dev (BullMQ -> Database).
* Used for execution paths that must avoid Trigger.dev cold starts.
*/
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
if (cachedInlineBackend) {
return cachedInlineBackend
}
const redis = getRedisClient()
let type: string
if (redis) {
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
cachedInlineBackend = new RedisJobQueue(redis)
type = 'redis'
if (isBullMQEnabled()) {
const { BullMQJobQueue } = await import('@/lib/core/async-jobs/backends/bullmq')
cachedInlineBackend = new BullMQJobQueue()
type = 'bullmq'
} else {
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
cachedInlineBackend = new DatabaseJobQueue()
@@ -98,11 +96,15 @@ export async function getInlineJobQueue(): Promise<JobQueueBackend> {
}
/**
* Checks if jobs should be executed inline (fire-and-forget).
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
* Checks if jobs should be executed inline in-process.
* Database fallback is the only mode that still relies on inline execution.
*/
export function shouldExecuteInline(): boolean {
return getAsyncBackendType() !== 'trigger-dev'
return getAsyncBackendType() === 'database'
}
export function shouldUseBullMQ(): boolean {
return isBullMQEnabled()
}
/**

View File

@@ -5,6 +5,7 @@ export {
getJobQueue,
resetJobQueueCache,
shouldExecuteInline,
shouldUseBullMQ,
} from './config'
export type {
AsyncBackendType,

View File

@@ -62,6 +62,10 @@ export interface JobMetadata {
export interface EnqueueOptions {
maxAttempts?: number
metadata?: JobMetadata
jobId?: string
priority?: number
name?: string
delayMs?: number
}
/**
@@ -95,4 +99,4 @@ export interface JobQueueBackend {
markJobFailed(jobId: string, error: string): Promise<void>
}
export type AsyncBackendType = 'trigger-dev' | 'redis' | 'database'
export type AsyncBackendType = 'trigger-dev' | 'bullmq' | 'database'

View File

@@ -0,0 +1,29 @@
import type { ConnectionOptions } from 'bullmq'
import { env } from '@/lib/core/config/env'
export function isBullMQEnabled(): boolean {
return Boolean(env.REDIS_URL)
}
export function getBullMQConnectionOptions(): ConnectionOptions {
if (!env.REDIS_URL) {
throw new Error('BullMQ requires REDIS_URL')
}
const redisUrl = new URL(env.REDIS_URL)
const isTls = redisUrl.protocol === 'rediss:'
const port = redisUrl.port ? Number.parseInt(redisUrl.port, 10) : 6379
const dbPath = redisUrl.pathname.replace('/', '')
const db = dbPath ? Number.parseInt(dbPath, 10) : undefined
return {
host: redisUrl.hostname,
port,
username: redisUrl.username || undefined,
password: redisUrl.password || undefined,
db: Number.isFinite(db) ? db : undefined,
maxRetriesPerRequest: null,
enableReadyCheck: false,
...(isTls ? { tls: {} } : {}),
}
}

View File

@@ -0,0 +1,16 @@
export { getBullMQConnectionOptions, isBullMQEnabled } from './connection'
export {
type BullMQJobData,
createBullMQJobData,
getBullMQQueue,
getBullMQQueueByName,
getKnowledgeConnectorSyncQueue,
getKnowledgeDocumentProcessingQueue,
getMothershipJobExecutionQueue,
getWorkflowQueueEvents,
getWorkspaceNotificationDeliveryQueue,
KNOWLEDGE_CONNECTOR_SYNC_QUEUE,
KNOWLEDGE_DOCUMENT_PROCESSING_QUEUE,
MOTHERSHIP_JOB_EXECUTION_QUEUE,
WORKSPACE_NOTIFICATION_DELIVERY_QUEUE,
} from './queues'

View File

@@ -0,0 +1,196 @@
import { Queue, QueueEvents } from 'bullmq'
import type { JobMetadata, JobType } from '@/lib/core/async-jobs/types'
import { getBullMQConnectionOptions } from '@/lib/core/bullmq/connection'
import type { WorkspaceDispatchQueueName } from '@/lib/core/workspace-dispatch/types'
export const KNOWLEDGE_CONNECTOR_SYNC_QUEUE = 'knowledge-connector-sync' as const
export const KNOWLEDGE_DOCUMENT_PROCESSING_QUEUE = 'knowledge-process-document' as const
export const MOTHERSHIP_JOB_EXECUTION_QUEUE = 'mothership-job-execution' as const
export const WORKSPACE_NOTIFICATION_DELIVERY_QUEUE = 'workspace-notification-delivery' as const
export interface BullMQJobData<TPayload> {
payload: TPayload
metadata?: JobMetadata
}
let workflowQueueInstance: Queue | null = null
let webhookQueueInstance: Queue | null = null
let scheduleQueueInstance: Queue | null = null
let knowledgeConnectorSyncQueueInstance: Queue | null = null
let knowledgeDocumentProcessingQueueInstance: Queue | null = null
let mothershipJobExecutionQueueInstance: Queue | null = null
let workspaceNotificationDeliveryQueueInstance: Queue | null = null
let workflowQueueEventsInstance: QueueEvents | null = null
function getQueueDefaultOptions(type: JobType) {
switch (type) {
case 'workflow-execution':
return {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 1000 },
removeOnComplete: { age: 24 * 60 * 60 },
removeOnFail: { age: 7 * 24 * 60 * 60 },
}
case 'webhook-execution':
return {
attempts: 2,
backoff: { type: 'exponential' as const, delay: 2000 },
removeOnComplete: { age: 24 * 60 * 60 },
removeOnFail: { age: 3 * 24 * 60 * 60 },
}
case 'schedule-execution':
return {
attempts: 2,
backoff: { type: 'exponential' as const, delay: 5000 },
removeOnComplete: { age: 24 * 60 * 60 },
removeOnFail: { age: 3 * 24 * 60 * 60 },
}
}
}
function createQueue(type: JobType): Queue {
return new Queue(type, {
connection: getBullMQConnectionOptions(),
defaultJobOptions: getQueueDefaultOptions(type),
})
}
function createNamedQueue(
name:
| typeof KNOWLEDGE_CONNECTOR_SYNC_QUEUE
| typeof KNOWLEDGE_DOCUMENT_PROCESSING_QUEUE
| typeof MOTHERSHIP_JOB_EXECUTION_QUEUE
| typeof WORKSPACE_NOTIFICATION_DELIVERY_QUEUE
): Queue {
switch (name) {
case KNOWLEDGE_CONNECTOR_SYNC_QUEUE:
return new Queue(name, {
connection: getBullMQConnectionOptions(),
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { age: 24 * 60 * 60 },
removeOnFail: { age: 7 * 24 * 60 * 60 },
},
})
case KNOWLEDGE_DOCUMENT_PROCESSING_QUEUE:
return new Queue(name, {
connection: getBullMQConnectionOptions(),
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 24 * 60 * 60 },
removeOnFail: { age: 7 * 24 * 60 * 60 },
},
})
case MOTHERSHIP_JOB_EXECUTION_QUEUE:
return new Queue(name, {
connection: getBullMQConnectionOptions(),
defaultJobOptions: {
attempts: 1,
removeOnComplete: { age: 24 * 60 * 60 },
removeOnFail: { age: 7 * 24 * 60 * 60 },
},
})
case WORKSPACE_NOTIFICATION_DELIVERY_QUEUE:
return new Queue(name, {
connection: getBullMQConnectionOptions(),
defaultJobOptions: {
attempts: 1,
removeOnComplete: { age: 24 * 60 * 60 },
removeOnFail: { age: 7 * 24 * 60 * 60 },
},
})
}
}
export function getBullMQQueue(type: JobType): Queue {
switch (type) {
case 'workflow-execution':
if (!workflowQueueInstance) {
workflowQueueInstance = createQueue(type)
}
return workflowQueueInstance
case 'webhook-execution':
if (!webhookQueueInstance) {
webhookQueueInstance = createQueue(type)
}
return webhookQueueInstance
case 'schedule-execution':
if (!scheduleQueueInstance) {
scheduleQueueInstance = createQueue(type)
}
return scheduleQueueInstance
}
}
export function getBullMQQueueByName(queueName: WorkspaceDispatchQueueName): Queue {
switch (queueName) {
case 'workflow-execution':
case 'webhook-execution':
case 'schedule-execution':
return getBullMQQueue(queueName)
case KNOWLEDGE_CONNECTOR_SYNC_QUEUE:
return getKnowledgeConnectorSyncQueue()
case KNOWLEDGE_DOCUMENT_PROCESSING_QUEUE:
return getKnowledgeDocumentProcessingQueue()
case MOTHERSHIP_JOB_EXECUTION_QUEUE:
return getMothershipJobExecutionQueue()
case WORKSPACE_NOTIFICATION_DELIVERY_QUEUE:
return getWorkspaceNotificationDeliveryQueue()
}
}
export function getWorkflowQueueEvents(): QueueEvents {
if (!workflowQueueEventsInstance) {
workflowQueueEventsInstance = new QueueEvents('workflow-execution', {
connection: getBullMQConnectionOptions(),
})
}
return workflowQueueEventsInstance
}
export function getKnowledgeConnectorSyncQueue(): Queue {
if (!knowledgeConnectorSyncQueueInstance) {
knowledgeConnectorSyncQueueInstance = createNamedQueue(KNOWLEDGE_CONNECTOR_SYNC_QUEUE)
}
return knowledgeConnectorSyncQueueInstance
}
export function getKnowledgeDocumentProcessingQueue(): Queue {
if (!knowledgeDocumentProcessingQueueInstance) {
knowledgeDocumentProcessingQueueInstance = createNamedQueue(KNOWLEDGE_DOCUMENT_PROCESSING_QUEUE)
}
return knowledgeDocumentProcessingQueueInstance
}
export function getMothershipJobExecutionQueue(): Queue {
if (!mothershipJobExecutionQueueInstance) {
mothershipJobExecutionQueueInstance = createNamedQueue(MOTHERSHIP_JOB_EXECUTION_QUEUE)
}
return mothershipJobExecutionQueueInstance
}
export function getWorkspaceNotificationDeliveryQueue(): Queue {
if (!workspaceNotificationDeliveryQueueInstance) {
workspaceNotificationDeliveryQueueInstance = createNamedQueue(
WORKSPACE_NOTIFICATION_DELIVERY_QUEUE
)
}
return workspaceNotificationDeliveryQueueInstance
}
export function createBullMQJobData<TPayload>(
payload: TPayload,
metadata?: JobMetadata
): BullMQJobData<TPayload> {
return {
payload,
metadata: metadata ?? {},
}
}

View File

@@ -183,6 +183,11 @@ export const env = createEnv({
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users
// Admission & Burst Protection
ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod
DISPATCH_MAX_QUEUE_PER_WORKSPACE: z.string().optional().default('1000'), // Max queued dispatch jobs per workspace
DISPATCH_MAX_QUEUE_GLOBAL: z.string().optional().default('50000'), // Max queued dispatch jobs globally
// Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
@@ -194,6 +199,10 @@ export const env = createEnv({
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('2500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute
WORKSPACE_CONCURRENCY_FREE: z.string().optional().default('5'), // Free tier concurrent workspace executions
WORKSPACE_CONCURRENCY_PRO: z.string().optional().default('50'), // Pro tier concurrent workspace executions
WORKSPACE_CONCURRENCY_TEAM: z.string().optional().default('200'), // Team/Max tier concurrent workspace executions
WORKSPACE_CONCURRENCY_ENTERPRISE: z.string().optional().default('200'), // Enterprise default concurrent workspace executions
// Timeout Configuration
EXECUTION_TIMEOUT_FREE: z.string().optional().default('300'), // 5 minutes

View File

@@ -0,0 +1,80 @@
import type {
WorkspaceDispatchClaimResult,
WorkspaceDispatchEnqueueInput,
WorkspaceDispatchJobRecord,
WorkspaceDispatchLane,
} from '@/lib/core/workspace-dispatch/types'
export interface WorkspaceDispatchStorageAdapter {
saveDispatchJob(record: WorkspaceDispatchJobRecord): Promise<void>
getDispatchJobRecord(jobId: string): Promise<WorkspaceDispatchJobRecord | null>
listDispatchJobsByStatuses(
statuses: readonly WorkspaceDispatchJobRecord['status'][]
): Promise<WorkspaceDispatchJobRecord[]>
updateDispatchJobRecord(
jobId: string,
updater: (record: WorkspaceDispatchJobRecord) => WorkspaceDispatchJobRecord
): Promise<WorkspaceDispatchJobRecord | null>
enqueueWorkspaceDispatchJob(
input: WorkspaceDispatchEnqueueInput
): Promise<WorkspaceDispatchJobRecord>
restoreWorkspaceDispatchJob(record: WorkspaceDispatchJobRecord): Promise<void>
claimWorkspaceJob(
workspaceId: string,
options: {
lanes: readonly WorkspaceDispatchLane[]
concurrencyLimit: number
leaseId: string
now: number
leaseTtlMs: number
}
): Promise<WorkspaceDispatchClaimResult>
getWorkspaceQueueDepth(
workspaceId: string,
lanes: readonly WorkspaceDispatchLane[]
): Promise<number>
getGlobalQueueDepth(): Promise<number>
reconcileGlobalQueueDepth(knownCount: number): Promise<void>
popNextWorkspaceId(): Promise<string | null>
getQueuedWorkspaceCount(): Promise<number>
hasActiveWorkspace(workspaceId: string): Promise<boolean>
ensureWorkspaceActive(workspaceId: string, readyAt?: number): Promise<void>
requeueWorkspaceId(workspaceId: string): Promise<void>
workspaceHasPendingJobs(
workspaceId: string,
lanes: readonly WorkspaceDispatchLane[]
): Promise<boolean>
getNextWorkspaceJob(
workspaceId: string,
lanes: readonly WorkspaceDispatchLane[]
): Promise<WorkspaceDispatchJobRecord | null>
removeWorkspaceJobFromLane(
workspaceId: string,
lane: WorkspaceDispatchLane,
jobId: string
): Promise<void>
cleanupExpiredWorkspaceLeases(workspaceId: string): Promise<void>
countActiveWorkspaceLeases(workspaceId: string): Promise<number>
hasWorkspaceLease(workspaceId: string, leaseId: string): Promise<boolean>
createWorkspaceLease(workspaceId: string, leaseId: string, ttlMs: number): Promise<number>
refreshWorkspaceLease(workspaceId: string, leaseId: string, ttlMs: number): Promise<number>
releaseWorkspaceLease(workspaceId: string, leaseId: string): Promise<void>
removeWorkspaceIfIdle(workspaceId: string, lanes: readonly WorkspaceDispatchLane[]): Promise<void>
markDispatchJobAdmitted(
jobId: string,
workspaceId: string,
leaseId: string,
leaseExpiresAt: number
): Promise<void>
markDispatchJobAdmitting(
jobId: string,
workspaceId: string,
leaseId: string,
leaseExpiresAt: number
): Promise<void>
markDispatchJobRunning(jobId: string): Promise<void>
markDispatchJobCompleted(jobId: string, output: unknown): Promise<void>
markDispatchJobFailed(jobId: string, error: string): Promise<void>
clear(): Promise<void>
dispose(): void
}

View File

@@ -0,0 +1,175 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetWorkspaceConcurrencyLimit, mockAcquireLock, mockReleaseLock } = vi.hoisted(() => ({
mockGetWorkspaceConcurrencyLimit: vi.fn(),
mockAcquireLock: vi.fn(),
mockReleaseLock: vi.fn(),
}))
vi.mock('@/lib/billing/workspace-concurrency', () => ({
getWorkspaceConcurrencyLimit: mockGetWorkspaceConcurrencyLimit,
}))
vi.mock('@/lib/core/config/redis', () => ({
acquireLock: mockAcquireLock,
releaseLock: mockReleaseLock,
getRedisClient: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/core/bullmq', () => ({
getBullMQQueueByName: vi.fn().mockReturnValue({
add: vi.fn().mockResolvedValue({ id: 'bullmq-1' }),
}),
}))
import { MemoryWorkspaceDispatchStorage } from '@/lib/core/workspace-dispatch/memory-store'
import {
DISPATCH_SCAN_RESULTS,
dispatchNextAdmissibleWorkspaceJob,
} from '@/lib/core/workspace-dispatch/planner'
import {
enqueueWorkspaceDispatchJob,
setWorkspaceDispatchStorageAdapter,
} from '@/lib/core/workspace-dispatch/store'
describe('workspace dispatch integration (memory-backed)', () => {
let store: MemoryWorkspaceDispatchStorage
beforeEach(async () => {
vi.clearAllMocks()
store = new MemoryWorkspaceDispatchStorage()
setWorkspaceDispatchStorageAdapter(store)
mockGetWorkspaceConcurrencyLimit.mockResolvedValue(5)
mockAcquireLock.mockResolvedValue(true)
mockReleaseLock.mockResolvedValue(true)
})
async function enqueue(
workspaceId: string,
overrides: { lane?: string; delayMs?: number; priority?: number } = {}
) {
return enqueueWorkspaceDispatchJob({
workspaceId,
lane: (overrides.lane ?? 'runtime') as 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: { payload: { workflowId: 'wf-1' } },
metadata: { workflowId: 'wf-1' },
delayMs: overrides.delayMs,
priority: overrides.priority,
})
}
it('admits jobs round-robin across workspaces', async () => {
await enqueue('ws-a')
await enqueue('ws-b')
await enqueue('ws-a')
const r1 = await dispatchNextAdmissibleWorkspaceJob()
const r2 = await dispatchNextAdmissibleWorkspaceJob()
const r3 = await dispatchNextAdmissibleWorkspaceJob()
expect(r1).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
expect(r2).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
expect(r3).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
})
it('respects workspace concurrency limits', async () => {
mockGetWorkspaceConcurrencyLimit.mockResolvedValue(1)
await enqueue('ws-a')
await enqueue('ws-a')
const r1 = await dispatchNextAdmissibleWorkspaceJob()
expect(r1).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
const r2 = await dispatchNextAdmissibleWorkspaceJob()
expect(r2).toBe(DISPATCH_SCAN_RESULTS.NO_PROGRESS)
})
it('skips delayed jobs and admits ready ones in same lane', async () => {
await enqueue('ws-a', { delayMs: 60_000 })
await enqueue('ws-a', { delayMs: 0 })
const r1 = await dispatchNextAdmissibleWorkspaceJob()
expect(r1).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
})
it('returns delayed when all jobs are delayed', async () => {
await enqueue('ws-a', { delayMs: 60_000 })
const r1 = await dispatchNextAdmissibleWorkspaceJob()
expect(r1).toBe(DISPATCH_SCAN_RESULTS.NO_PROGRESS)
})
it('returns no_workspace when queue is empty', async () => {
const result = await dispatchNextAdmissibleWorkspaceJob()
expect(result).toBe(DISPATCH_SCAN_RESULTS.NO_WORKSPACE)
})
it('lease cleanup frees capacity for new admissions', async () => {
mockGetWorkspaceConcurrencyLimit.mockResolvedValue(1)
const record = await enqueue('ws-a')
await enqueue('ws-a')
const r1 = await dispatchNextAdmissibleWorkspaceJob()
expect(r1).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
const updated = await store.getDispatchJobRecord(record.id)
if (updated?.lease) {
await store.releaseWorkspaceLease('ws-a', updated.lease.leaseId)
}
const r2 = await dispatchNextAdmissibleWorkspaceJob()
expect(r2).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
})
it('expired leases are cleaned up during claim', async () => {
mockGetWorkspaceConcurrencyLimit.mockResolvedValue(1)
await enqueue('ws-a')
await enqueue('ws-a')
const claimResult = await store.claimWorkspaceJob('ws-a', {
lanes: ['runtime'],
concurrencyLimit: 1,
leaseId: 'old-lease',
now: Date.now(),
leaseTtlMs: 1,
})
expect(claimResult.type).toBe('admitted')
await new Promise((resolve) => setTimeout(resolve, 10))
const r2 = await dispatchNextAdmissibleWorkspaceJob()
expect(r2).toBe(DISPATCH_SCAN_RESULTS.ADMITTED)
})
it('recovers job to waiting via restoreWorkspaceDispatchJob', async () => {
const record = await enqueue('ws-a')
await store.claimWorkspaceJob('ws-a', {
lanes: ['runtime'],
concurrencyLimit: 1,
leaseId: 'lease-1',
now: Date.now(),
leaseTtlMs: 1000,
})
await store.markDispatchJobAdmitted(record.id, 'ws-a', 'lease-1', Date.now() + 10000)
const admitted = await store.getDispatchJobRecord(record.id)
expect(admitted).toBeDefined()
const resetRecord = { ...admitted!, status: 'waiting' as const, lease: undefined }
await store.restoreWorkspaceDispatchJob(resetRecord)
const restored = await store.getDispatchJobRecord(record.id)
expect(restored?.status).toBe('waiting')
expect(restored?.lease).toBeUndefined()
})
})

View File

@@ -0,0 +1,156 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import {
enqueueWorkspaceDispatchJob,
getDispatchJobRecord,
getGlobalQueueDepth,
getQueuedWorkspaceCount,
getWorkspaceQueueDepth,
} from '@/lib/core/workspace-dispatch/store'
import {
WORKSPACE_DISPATCH_LANES,
type WorkspaceDispatchEnqueueInput,
type WorkspaceDispatchJobRecord,
} from '@/lib/core/workspace-dispatch/types'
import { DISPATCH_SCAN_RESULTS, dispatchNextAdmissibleWorkspaceJob } from './planner'
import { reconcileWorkspaceDispatchState } from './reconciler'
const logger = createLogger('WorkspaceDispatcher')
const WAIT_POLL_INTERVAL_MS = 250
const RECONCILE_INTERVAL_MS = 30_000
const MAX_QUEUE_PER_WORKSPACE = Number.parseInt(env.DISPATCH_MAX_QUEUE_PER_WORKSPACE ?? '') || 1000
const MAX_QUEUE_GLOBAL = Number.parseInt(env.DISPATCH_MAX_QUEUE_GLOBAL ?? '') || 50_000
let dispatcherRunning = false
let dispatcherWakePending = false
let lastReconcileAt = 0
async function runDispatcherLoop(): Promise<void> {
if (dispatcherRunning) {
dispatcherWakePending = true
return
}
dispatcherRunning = true
try {
const now = Date.now()
if (now - lastReconcileAt >= RECONCILE_INTERVAL_MS) {
await reconcileWorkspaceDispatchState()
lastReconcileAt = now
}
do {
dispatcherWakePending = false
const queuedWorkspaces = await getQueuedWorkspaceCount()
if (queuedWorkspaces === 0) {
continue
}
let admitted = 0
let scanned = 0
const loopStartMs = Date.now()
for (let index = 0; index < queuedWorkspaces; index++) {
scanned++
const result = await dispatchNextAdmissibleWorkspaceJob()
if (result === DISPATCH_SCAN_RESULTS.ADMITTED) {
admitted++
}
if (result === DISPATCH_SCAN_RESULTS.NO_WORKSPACE) {
break
}
}
if (admitted > 0) {
dispatcherWakePending = true
}
if (admitted > 0 || scanned > 0) {
logger.info('Dispatcher pass', {
admitted,
scanned,
queuedWorkspaces,
durationMs: Date.now() - loopStartMs,
})
}
} while (dispatcherWakePending)
} catch (error) {
logger.error('Workspace dispatcher loop failed', { error })
} finally {
dispatcherRunning = false
}
}
export class DispatchQueueFullError extends Error {
readonly statusCode = 503
constructor(
readonly scope: 'workspace' | 'global',
readonly depth: number,
readonly limit: number
) {
super(
scope === 'workspace'
? `Workspace queue is at capacity (${depth}/${limit})`
: `Global dispatch queue is at capacity (${depth}/${limit})`
)
this.name = 'DispatchQueueFullError'
}
}
export async function enqueueWorkspaceDispatch(
input: WorkspaceDispatchEnqueueInput
): Promise<string> {
const [workspaceDepth, globalDepth] = await Promise.all([
getWorkspaceQueueDepth(input.workspaceId, WORKSPACE_DISPATCH_LANES),
getGlobalQueueDepth(),
])
if (workspaceDepth >= MAX_QUEUE_PER_WORKSPACE) {
logger.warn('Workspace dispatch queue at capacity', {
workspaceId: input.workspaceId,
depth: workspaceDepth,
limit: MAX_QUEUE_PER_WORKSPACE,
})
throw new DispatchQueueFullError('workspace', workspaceDepth, MAX_QUEUE_PER_WORKSPACE)
}
if (globalDepth >= MAX_QUEUE_GLOBAL) {
logger.warn('Global dispatch queue at capacity', {
depth: globalDepth,
limit: MAX_QUEUE_GLOBAL,
})
throw new DispatchQueueFullError('global', globalDepth, MAX_QUEUE_GLOBAL)
}
const record = await enqueueWorkspaceDispatchJob(input)
void runDispatcherLoop()
return record.id
}
export async function wakeWorkspaceDispatcher(): Promise<void> {
await runDispatcherLoop()
}
export async function waitForDispatchJob(
dispatchJobId: string,
timeoutMs: number
): Promise<WorkspaceDispatchJobRecord> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const record = await getDispatchJobRecord(dispatchJobId)
if (!record) {
throw new Error(`Dispatch job not found: ${dispatchJobId}`)
}
if (record.status === 'completed' || record.status === 'failed') {
return record
}
await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_INTERVAL_MS))
}
throw new Error(`Timed out waiting for dispatch job ${dispatchJobId}`)
}

View File

@@ -0,0 +1,42 @@
import { createLogger } from '@sim/logger'
import { getRedisClient } from '@/lib/core/config/redis'
import type { WorkspaceDispatchStorageAdapter } from '@/lib/core/workspace-dispatch/adapter'
import { MemoryWorkspaceDispatchStorage } from '@/lib/core/workspace-dispatch/memory-store'
import { RedisWorkspaceDispatchStorage } from '@/lib/core/workspace-dispatch/redis-store'
const logger = createLogger('WorkspaceDispatchFactory')
let cachedAdapter: WorkspaceDispatchStorageAdapter | null = null
export function createWorkspaceDispatchStorageAdapter(): WorkspaceDispatchStorageAdapter {
if (cachedAdapter) {
return cachedAdapter
}
const redis = getRedisClient()
if (redis) {
logger.info('Workspace dispatcher: Using Redis storage')
const adapter = new RedisWorkspaceDispatchStorage(redis)
cachedAdapter = adapter
return adapter
}
logger.warn(
'Workspace dispatcher: Using in-memory storage; distributed fairness is disabled in multi-process deployments'
)
const adapter = new MemoryWorkspaceDispatchStorage()
cachedAdapter = adapter
return adapter
}
export function setWorkspaceDispatchStorageAdapter(adapter: WorkspaceDispatchStorageAdapter): void {
cachedAdapter = adapter
}
export function resetWorkspaceDispatchStorageAdapter(): void {
if (cachedAdapter) {
cachedAdapter.dispose()
cachedAdapter = null
}
}

View File

@@ -0,0 +1,32 @@
export type { WorkspaceDispatchStorageAdapter } from './adapter'
export {
DispatchQueueFullError,
enqueueWorkspaceDispatch,
waitForDispatchJob,
wakeWorkspaceDispatcher,
} from './dispatcher'
export {
createWorkspaceDispatchStorageAdapter,
resetWorkspaceDispatchStorageAdapter,
} from './factory'
export {
markDispatchJobAdmitted,
markDispatchJobAdmitting,
markDispatchJobCompleted,
markDispatchJobFailed,
markDispatchJobRunning,
refreshWorkspaceLease,
releaseWorkspaceLease,
} from './store'
export {
WORKSPACE_DISPATCH_LANES,
WORKSPACE_DISPATCH_STATUSES,
type WorkspaceDispatchEnqueueInput,
type WorkspaceDispatchJobContext,
type WorkspaceDispatchJobRecord,
type WorkspaceDispatchLane,
type WorkspaceDispatchLeaseInfo,
type WorkspaceDispatchQueueName,
type WorkspaceDispatchStatus,
} from './types'
export { getDispatchRuntimeMetadata, runDispatchedJob } from './worker'

View File

@@ -0,0 +1,65 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it } from 'vitest'
import { MemoryWorkspaceDispatchStorage } from '@/lib/core/workspace-dispatch/memory-store'
describe('memory workspace dispatch storage', () => {
const store = new MemoryWorkspaceDispatchStorage()
afterEach(async () => {
await store.clear()
})
it('claims a runnable job and marks it admitting with a lease', async () => {
const record = await store.enqueueWorkspaceDispatchJob({
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: { payload: { workflowId: 'workflow-1' } },
metadata: {
workflowId: 'workflow-1',
},
})
const result = await store.claimWorkspaceJob('workspace-1', {
lanes: ['runtime'],
concurrencyLimit: 1,
leaseId: 'lease-1',
now: Date.now(),
leaseTtlMs: 1000,
})
expect(result.type).toBe('admitted')
if (result.type === 'admitted') {
expect(result.record.id).toBe(record.id)
expect(result.record.status).toBe('admitting')
expect(result.record.lease?.leaseId).toBe('lease-1')
}
})
it('returns delayed when only delayed jobs exist', async () => {
await store.enqueueWorkspaceDispatchJob({
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: { payload: { workflowId: 'workflow-1' } },
metadata: {
workflowId: 'workflow-1',
},
delayMs: 5000,
})
const result = await store.claimWorkspaceJob('workspace-1', {
lanes: ['runtime'],
concurrencyLimit: 1,
leaseId: 'lease-2',
now: Date.now(),
leaseTtlMs: 1000,
})
expect(result.type).toBe('delayed')
})
})

View File

@@ -0,0 +1,505 @@
import { createLogger } from '@sim/logger'
import type { WorkspaceDispatchStorageAdapter } from '@/lib/core/workspace-dispatch/adapter'
import {
WORKSPACE_DISPATCH_CLAIM_RESULTS,
type WorkspaceDispatchClaimResult,
type WorkspaceDispatchEnqueueInput,
type WorkspaceDispatchJobRecord,
type WorkspaceDispatchLane,
} from '@/lib/core/workspace-dispatch/types'
const logger = createLogger('WorkspaceDispatchMemoryStore')
const JOB_TTL_MS = 48 * 60 * 60 * 1000
export class MemoryWorkspaceDispatchStorage implements WorkspaceDispatchStorageAdapter {
private jobs = new Map<string, WorkspaceDispatchJobRecord>()
private workspaceOrder: string[] = []
private laneQueues = new Map<string, string[]>()
private leases = new Map<string, Map<string, number>>()
private cleanupInterval: NodeJS.Timeout | null = null
constructor() {
this.cleanupInterval = setInterval(() => {
void this.clearExpiredState()
}, 60_000)
this.cleanupInterval.unref()
}
private queueKey(workspaceId: string, lane: WorkspaceDispatchLane): string {
return `${workspaceId}:${lane}`
}
private ensureWorkspaceQueued(workspaceId: string): void {
if (!this.workspaceOrder.includes(workspaceId)) {
this.workspaceOrder.push(workspaceId)
}
}
private getLaneQueue(workspaceId: string, lane: WorkspaceDispatchLane): string[] {
const key = this.queueKey(workspaceId, lane)
const existing = this.laneQueues.get(key)
if (existing) {
return existing
}
const queue: string[] = []
this.laneQueues.set(key, queue)
return queue
}
private sortQueue(queue: string[]): void {
queue.sort((leftId, rightId) => {
const left = this.jobs.get(leftId)
const right = this.jobs.get(rightId)
if (!left || !right) {
return 0
}
if (left.priority !== right.priority) {
return left.priority - right.priority
}
return left.createdAt - right.createdAt
})
}
private getLeaseMap(workspaceId: string): Map<string, number> {
const existing = this.leases.get(workspaceId)
if (existing) {
return existing
}
const leaseMap = new Map<string, number>()
this.leases.set(workspaceId, leaseMap)
return leaseMap
}
private async clearExpiredState(): Promise<void> {
const now = Date.now()
for (const [jobId, record] of this.jobs.entries()) {
if (
(record.status === 'completed' || record.status === 'failed') &&
record.completedAt &&
now - record.completedAt > JOB_TTL_MS
) {
this.jobs.delete(jobId)
}
}
for (const [workspaceId, leaseMap] of this.leases.entries()) {
for (const [leaseId, expiresAt] of leaseMap.entries()) {
if (expiresAt <= now) {
leaseMap.delete(leaseId)
}
}
if (leaseMap.size === 0) {
this.leases.delete(workspaceId)
}
}
}
async saveDispatchJob(record: WorkspaceDispatchJobRecord): Promise<void> {
this.jobs.set(record.id, record)
}
async getDispatchJobRecord(jobId: string): Promise<WorkspaceDispatchJobRecord | null> {
return this.jobs.get(jobId) ?? null
}
async listDispatchJobsByStatuses(
statuses: readonly WorkspaceDispatchJobRecord['status'][]
): Promise<WorkspaceDispatchJobRecord[]> {
return Array.from(this.jobs.values()).filter((record) => statuses.includes(record.status))
}
private static readonly TERMINAL_STATUSES = new Set(['completed', 'failed'])
async updateDispatchJobRecord(
jobId: string,
updater: (record: WorkspaceDispatchJobRecord) => WorkspaceDispatchJobRecord
): Promise<WorkspaceDispatchJobRecord | null> {
const current = this.jobs.get(jobId)
if (!current) {
return null
}
const updated = updater(current)
if (
MemoryWorkspaceDispatchStorage.TERMINAL_STATUSES.has(current.status) &&
!MemoryWorkspaceDispatchStorage.TERMINAL_STATUSES.has(updated.status)
) {
return current
}
this.jobs.set(jobId, updated)
return updated
}
async enqueueWorkspaceDispatchJob(
input: WorkspaceDispatchEnqueueInput
): Promise<WorkspaceDispatchJobRecord> {
const id = input.id ?? `dispatch_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`
const createdAt = Date.now()
const record: WorkspaceDispatchJobRecord = {
id,
workspaceId: input.workspaceId,
lane: input.lane,
queueName: input.queueName,
bullmqJobName: input.bullmqJobName,
bullmqPayload: input.bullmqPayload,
metadata: input.metadata,
priority: input.priority ?? 100,
maxAttempts: input.maxAttempts,
delayMs: input.delayMs,
status: 'waiting',
createdAt,
}
this.jobs.set(id, record)
const queue = this.getLaneQueue(record.workspaceId, record.lane)
queue.push(id)
this.sortQueue(queue)
this.ensureWorkspaceQueued(record.workspaceId)
return record
}
async restoreWorkspaceDispatchJob(record: WorkspaceDispatchJobRecord): Promise<void> {
this.jobs.set(record.id, record)
const queue = this.getLaneQueue(record.workspaceId, record.lane)
if (!queue.includes(record.id)) {
queue.push(record.id)
this.sortQueue(queue)
}
this.ensureWorkspaceQueued(record.workspaceId)
}
async claimWorkspaceJob(
workspaceId: string,
options: {
lanes: readonly WorkspaceDispatchLane[]
concurrencyLimit: number
leaseId: string
now: number
leaseTtlMs: number
}
): Promise<WorkspaceDispatchClaimResult> {
await this.cleanupExpiredWorkspaceLeases(workspaceId)
if (this.getLeaseMap(workspaceId).size >= options.concurrencyLimit) {
this.ensureWorkspaceQueued(workspaceId)
return { type: WORKSPACE_DISPATCH_CLAIM_RESULTS.LIMIT_REACHED }
}
let selectedRecord: WorkspaceDispatchJobRecord | null = null
let selectedLane: WorkspaceDispatchLane | null = null
let nextReadyAt: number | null = null
for (const lane of options.lanes) {
const queue = this.getLaneQueue(workspaceId, lane)
for (let scanIndex = 0; scanIndex < queue.length && scanIndex < 20; ) {
const jobId = queue[scanIndex]
const record = this.jobs.get(jobId)
if (!record) {
queue.splice(scanIndex, 1)
continue
}
const readyAt = record.createdAt + (record.delayMs ?? 0)
if (readyAt <= options.now) {
selectedRecord = record
selectedLane = lane
queue.splice(scanIndex, 1)
break
}
nextReadyAt = nextReadyAt ? Math.min(nextReadyAt, readyAt) : readyAt
scanIndex++
}
if (selectedRecord) {
break
}
}
if (!selectedRecord || !selectedLane) {
const hasPending = await this.workspaceHasPendingJobs(workspaceId, options.lanes)
if (!hasPending) {
this.workspaceOrder = this.workspaceOrder.filter((value) => value !== workspaceId)
return { type: WORKSPACE_DISPATCH_CLAIM_RESULTS.EMPTY }
}
this.ensureWorkspaceQueued(workspaceId)
return {
type: WORKSPACE_DISPATCH_CLAIM_RESULTS.DELAYED,
nextReadyAt: nextReadyAt ?? options.now,
}
}
const leaseExpiresAt = options.now + options.leaseTtlMs
this.getLeaseMap(workspaceId).set(options.leaseId, leaseExpiresAt)
const updatedRecord: WorkspaceDispatchJobRecord = {
...selectedRecord,
status: 'admitting',
lease: {
workspaceId,
leaseId: options.leaseId,
},
metadata: {
...selectedRecord.metadata,
dispatchLeaseExpiresAt: leaseExpiresAt,
},
}
this.jobs.set(updatedRecord.id, updatedRecord)
const hasPending = await this.workspaceHasPendingJobs(workspaceId, options.lanes)
if (hasPending) {
this.ensureWorkspaceQueued(workspaceId)
} else {
this.workspaceOrder = this.workspaceOrder.filter((value) => value !== workspaceId)
}
return {
type: WORKSPACE_DISPATCH_CLAIM_RESULTS.ADMITTED,
record: updatedRecord,
leaseId: options.leaseId,
leaseExpiresAt,
}
}
async getWorkspaceQueueDepth(
workspaceId: string,
lanes: readonly WorkspaceDispatchLane[]
): Promise<number> {
let depth = 0
for (const lane of lanes) {
depth += this.getLaneQueue(workspaceId, lane).length
}
return depth
}
async getGlobalQueueDepth(): Promise<number> {
const terminalStatuses = new Set(['completed', 'failed'])
let count = 0
for (const job of this.jobs.values()) {
if (!terminalStatuses.has(job.status)) {
count++
}
}
return count
}
async reconcileGlobalQueueDepth(_knownCount: number): Promise<void> {
// no-op: memory store computes depth on the fly
}
async popNextWorkspaceId(): Promise<string | null> {
const now = Date.now()
const maxScans = this.workspaceOrder.length
for (let i = 0; i < maxScans; i++) {
const id = this.workspaceOrder.shift()
if (!id) return null
const readyAt = this.workspaceReadyAt.get(id)
if (readyAt && readyAt > now) {
this.workspaceOrder.push(id)
continue
}
this.workspaceReadyAt.delete(id)
return id
}
return null
}
async getQueuedWorkspaceCount(): Promise<number> {
return this.workspaceOrder.length
}
async hasActiveWorkspace(workspaceId: string): Promise<boolean> {
return this.workspaceOrder.includes(workspaceId)
}
private workspaceReadyAt = new Map<string, number>()
async ensureWorkspaceActive(workspaceId: string, readyAt?: number): Promise<void> {
if (readyAt && readyAt > Date.now()) {
this.workspaceReadyAt.set(workspaceId, readyAt)
}
this.ensureWorkspaceQueued(workspaceId)
}
async requeueWorkspaceId(workspaceId: string): Promise<void> {
this.ensureWorkspaceQueued(workspaceId)
}
async workspaceHasPendingJobs(
workspaceId: string,
lanes: readonly WorkspaceDispatchLane[]
): Promise<boolean> {
return lanes.some((lane) => this.getLaneQueue(workspaceId, lane).length > 0)
}
async getNextWorkspaceJob(
workspaceId: string,
lanes: readonly WorkspaceDispatchLane[]
): Promise<WorkspaceDispatchJobRecord | null> {
for (const lane of lanes) {
const queue = this.getLaneQueue(workspaceId, lane)
while (queue.length > 0) {
const jobId = queue[0]
const job = this.jobs.get(jobId)
if (job) {
return job
}
queue.shift()
}
}
return null
}
async removeWorkspaceJobFromLane(
workspaceId: string,
lane: WorkspaceDispatchLane,
jobId: string
): Promise<void> {
const queue = this.getLaneQueue(workspaceId, lane)
const index = queue.indexOf(jobId)
if (index >= 0) {
queue.splice(index, 1)
}
}
async cleanupExpiredWorkspaceLeases(workspaceId: string): Promise<void> {
const leaseMap = this.getLeaseMap(workspaceId)
const now = Date.now()
for (const [leaseId, expiresAt] of leaseMap.entries()) {
if (expiresAt <= now) {
leaseMap.delete(leaseId)
}
}
}
async countActiveWorkspaceLeases(workspaceId: string): Promise<number> {
await this.cleanupExpiredWorkspaceLeases(workspaceId)
return this.getLeaseMap(workspaceId).size
}
async hasWorkspaceLease(workspaceId: string, leaseId: string): Promise<boolean> {
await this.cleanupExpiredWorkspaceLeases(workspaceId)
return this.getLeaseMap(workspaceId).has(leaseId)
}
async createWorkspaceLease(workspaceId: string, leaseId: string, ttlMs: number): Promise<number> {
const expiresAt = Date.now() + ttlMs
this.getLeaseMap(workspaceId).set(leaseId, expiresAt)
return expiresAt
}
async refreshWorkspaceLease(
workspaceId: string,
leaseId: string,
ttlMs: number
): Promise<number> {
return this.createWorkspaceLease(workspaceId, leaseId, ttlMs)
}
async releaseWorkspaceLease(workspaceId: string, leaseId: string): Promise<void> {
this.getLeaseMap(workspaceId).delete(leaseId)
}
async removeWorkspaceIfIdle(
workspaceId: string,
lanes: readonly WorkspaceDispatchLane[]
): Promise<void> {
const hasPending = await this.workspaceHasPendingJobs(workspaceId, lanes)
if (!hasPending) {
this.workspaceOrder = this.workspaceOrder.filter((value) => value !== workspaceId)
}
}
async markDispatchJobAdmitted(
jobId: string,
workspaceId: string,
leaseId: string,
leaseExpiresAt: number
): Promise<void> {
await this.updateDispatchJobRecord(jobId, (record) => ({
...record,
status: 'admitted',
admittedAt: Date.now(),
lease: {
workspaceId,
leaseId,
},
metadata: {
...record.metadata,
dispatchLeaseExpiresAt: leaseExpiresAt,
},
}))
}
async markDispatchJobAdmitting(
jobId: string,
workspaceId: string,
leaseId: string,
leaseExpiresAt: number
): Promise<void> {
await this.updateDispatchJobRecord(jobId, (record) => ({
...record,
status: 'admitting',
lease: {
workspaceId,
leaseId,
},
metadata: {
...record.metadata,
dispatchLeaseExpiresAt: leaseExpiresAt,
},
}))
}
async markDispatchJobRunning(jobId: string): Promise<void> {
await this.updateDispatchJobRecord(jobId, (record) => ({
...record,
status: 'running',
startedAt: record.startedAt ?? Date.now(),
}))
}
async markDispatchJobCompleted(jobId: string, output: unknown): Promise<void> {
await this.updateDispatchJobRecord(jobId, (record) => ({
...record,
status: 'completed',
completedAt: Date.now(),
output,
}))
}
async markDispatchJobFailed(jobId: string, error: string): Promise<void> {
await this.updateDispatchJobRecord(jobId, (record) => ({
...record,
status: 'failed',
completedAt: Date.now(),
error,
}))
}
async clear(): Promise<void> {
this.jobs.clear()
this.workspaceOrder = []
this.laneQueues.clear()
this.leases.clear()
this.workspaceReadyAt.clear()
}
dispose(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
void this.clear().catch((error) => {
logger.error('Failed to clear memory workspace dispatch storage', { error })
})
}
}

View File

@@ -0,0 +1,154 @@
import { createLogger } from '@sim/logger'
import { getWorkspaceConcurrencyLimit } from '@/lib/billing/workspace-concurrency'
import { type BullMQJobData, getBullMQQueueByName } from '@/lib/core/bullmq'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import {
claimWorkspaceJob,
markDispatchJobAdmitted,
popNextWorkspaceId,
releaseWorkspaceLease,
removeWorkspaceIfIdle,
requeueWorkspaceId,
} from '@/lib/core/workspace-dispatch/store'
import {
WORKSPACE_DISPATCH_CLAIM_RESULTS,
WORKSPACE_DISPATCH_LANES,
type WorkspaceDispatchJobRecord,
} from '@/lib/core/workspace-dispatch/types'
const logger = createLogger('WorkspaceDispatchPlanner')
const LEASE_TTL_MS = 15 * 60 * 1000
const WORKSPACE_CLAIM_LOCK_TTL_SECONDS = 10
export const DISPATCH_SCAN_RESULTS = {
NO_WORKSPACE: 'no_workspace',
NO_PROGRESS: 'no_progress',
ADMITTED: 'admitted',
} as const
export type DispatchScanResult = (typeof DISPATCH_SCAN_RESULTS)[keyof typeof DISPATCH_SCAN_RESULTS]
function attachDispatchMetadata(
bullmqPayload: unknown,
record: WorkspaceDispatchJobRecord,
leaseId: string,
leaseExpiresAt: number
): BullMQJobData<unknown> {
if (
bullmqPayload &&
typeof bullmqPayload === 'object' &&
'payload' in bullmqPayload &&
'metadata' in bullmqPayload
) {
const data = bullmqPayload as BullMQJobData<unknown>
return {
payload: data.payload,
metadata: {
...(data.metadata ?? {}),
dispatchJobId: record.id,
dispatchWorkspaceId: record.workspaceId,
dispatchLeaseId: leaseId,
dispatchLeaseExpiresAt: leaseExpiresAt,
},
}
}
return {
payload: bullmqPayload,
metadata: {
...record.metadata,
dispatchJobId: record.id,
dispatchWorkspaceId: record.workspaceId,
dispatchLeaseId: leaseId,
dispatchLeaseExpiresAt: leaseExpiresAt,
},
}
}
async function finalizeAdmittedJob(
record: WorkspaceDispatchJobRecord,
leaseId: string,
leaseExpiresAt: number
): Promise<void> {
try {
await getBullMQQueueByName(record.queueName).add(
record.bullmqJobName,
attachDispatchMetadata(record.bullmqPayload, record, leaseId, leaseExpiresAt),
{
jobId: record.id,
attempts: record.maxAttempts,
priority: record.priority,
}
)
await markDispatchJobAdmitted(record.id, record.workspaceId, leaseId, leaseExpiresAt)
} catch (error) {
await releaseWorkspaceLease(record.workspaceId, leaseId).catch(() => undefined)
throw error
}
}
export async function dispatchNextAdmissibleWorkspaceJob(): Promise<DispatchScanResult> {
const workspaceId = await popNextWorkspaceId()
if (!workspaceId) {
return DISPATCH_SCAN_RESULTS.NO_WORKSPACE
}
const lockValue = `lock_${crypto.randomUUID()}`
try {
const lockKey = `workspace-dispatch:claim-lock:${workspaceId}`
const acquired = await acquireLock(lockKey, lockValue, WORKSPACE_CLAIM_LOCK_TTL_SECONDS)
if (!acquired) {
await requeueWorkspaceId(workspaceId)
return DISPATCH_SCAN_RESULTS.NO_PROGRESS
}
const limit = await getWorkspaceConcurrencyLimit(workspaceId)
const leaseId = `lease_${crypto.randomUUID()}`
const claimResult = await claimWorkspaceJob(workspaceId, {
lanes: WORKSPACE_DISPATCH_LANES,
concurrencyLimit: limit,
leaseId,
now: Date.now(),
leaseTtlMs: LEASE_TTL_MS,
})
switch (claimResult.type) {
case WORKSPACE_DISPATCH_CLAIM_RESULTS.LIMIT_REACHED:
logger.debug('Workspace concurrency limit reached', { workspaceId, limit })
await requeueWorkspaceId(workspaceId)
return DISPATCH_SCAN_RESULTS.NO_PROGRESS
case WORKSPACE_DISPATCH_CLAIM_RESULTS.DELAYED:
logger.debug('Workspace has only delayed jobs', {
workspaceId,
nextReadyAt: claimResult.nextReadyAt,
})
return DISPATCH_SCAN_RESULTS.NO_PROGRESS
case WORKSPACE_DISPATCH_CLAIM_RESULTS.EMPTY:
await removeWorkspaceIfIdle(workspaceId, WORKSPACE_DISPATCH_LANES)
return DISPATCH_SCAN_RESULTS.NO_PROGRESS
case WORKSPACE_DISPATCH_CLAIM_RESULTS.ADMITTED:
logger.info('Admitting workspace job', {
workspaceId,
dispatchJobId: claimResult.record.id,
lane: claimResult.record.lane,
queueName: claimResult.record.queueName,
})
await finalizeAdmittedJob(
claimResult.record,
claimResult.leaseId,
claimResult.leaseExpiresAt
)
return DISPATCH_SCAN_RESULTS.ADMITTED
}
} catch (error) {
logger.error('Failed to dispatch workspace job', { workspaceId, error })
await requeueWorkspaceId(workspaceId)
return DISPATCH_SCAN_RESULTS.NO_PROGRESS
} finally {
await releaseLock(`workspace-dispatch:claim-lock:${workspaceId}`, lockValue).catch(
() => undefined
)
}
}

View File

@@ -0,0 +1,225 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetBullMQQueueByName,
mockHasActiveWorkspace,
mockEnsureWorkspaceActive,
mockHasWorkspaceLease,
mockListDispatchJobsByStatuses,
mockMarkDispatchJobAdmitted,
mockMarkDispatchJobCompleted,
mockMarkDispatchJobFailed,
mockRefreshWorkspaceLease,
mockReleaseWorkspaceLease,
mockRemoveWorkspaceJobFromLane,
mockRestoreWorkspaceDispatchJob,
mockWakeWorkspaceDispatcher,
} = vi.hoisted(() => ({
mockGetBullMQQueueByName: vi.fn(),
mockHasActiveWorkspace: vi.fn(),
mockEnsureWorkspaceActive: vi.fn(),
mockHasWorkspaceLease: vi.fn(),
mockListDispatchJobsByStatuses: vi.fn(),
mockMarkDispatchJobAdmitted: vi.fn(),
mockMarkDispatchJobCompleted: vi.fn(),
mockMarkDispatchJobFailed: vi.fn(),
mockRefreshWorkspaceLease: vi.fn(),
mockReleaseWorkspaceLease: vi.fn(),
mockRemoveWorkspaceJobFromLane: vi.fn(),
mockRestoreWorkspaceDispatchJob: vi.fn(),
mockWakeWorkspaceDispatcher: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/lib/core/bullmq', () => ({
getBullMQQueueByName: mockGetBullMQQueueByName,
}))
vi.mock('@/lib/core/workspace-dispatch/store', () => ({
ensureWorkspaceActive: mockEnsureWorkspaceActive,
hasActiveWorkspace: mockHasActiveWorkspace,
hasWorkspaceLease: mockHasWorkspaceLease,
listDispatchJobsByStatuses: mockListDispatchJobsByStatuses,
markDispatchJobAdmitted: mockMarkDispatchJobAdmitted,
markDispatchJobCompleted: mockMarkDispatchJobCompleted,
markDispatchJobFailed: mockMarkDispatchJobFailed,
reconcileGlobalQueueDepth: vi.fn().mockResolvedValue(undefined),
refreshWorkspaceLease: mockRefreshWorkspaceLease,
releaseWorkspaceLease: mockReleaseWorkspaceLease,
removeWorkspaceJobFromLane: mockRemoveWorkspaceJobFromLane,
restoreWorkspaceDispatchJob: mockRestoreWorkspaceDispatchJob,
}))
vi.mock('@/lib/core/workspace-dispatch/dispatcher', () => ({
wakeWorkspaceDispatcher: mockWakeWorkspaceDispatcher,
}))
import { reconcileWorkspaceDispatchState } from '@/lib/core/workspace-dispatch/reconciler'
describe('workspace dispatch reconciler', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasActiveWorkspace.mockResolvedValue(true)
mockRemoveWorkspaceJobFromLane.mockResolvedValue(undefined)
})
it('marks dispatch job completed when BullMQ job is completed', async () => {
mockListDispatchJobsByStatuses.mockResolvedValue([
{
id: 'dispatch-1',
workspaceId: 'workspace-1',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: {},
metadata: {},
priority: 10,
status: 'running',
createdAt: 1,
lease: {
workspaceId: 'workspace-1',
leaseId: 'lease-1',
},
},
])
mockGetBullMQQueueByName.mockReturnValue({
getJob: vi.fn().mockResolvedValue({
getState: vi.fn().mockResolvedValue('completed'),
returnvalue: { ok: true },
}),
})
await reconcileWorkspaceDispatchState()
expect(mockMarkDispatchJobCompleted).toHaveBeenCalledWith('dispatch-1', { ok: true })
expect(mockReleaseWorkspaceLease).toHaveBeenCalledWith('workspace-1', 'lease-1')
expect(mockWakeWorkspaceDispatcher).toHaveBeenCalled()
})
it('restores admitted jobs to waiting when lease and BullMQ job are gone', async () => {
mockListDispatchJobsByStatuses.mockResolvedValue([
{
id: 'dispatch-2',
workspaceId: 'workspace-2',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: {},
metadata: {},
priority: 10,
status: 'admitted',
createdAt: 1,
admittedAt: 2,
lease: {
workspaceId: 'workspace-2',
leaseId: 'lease-2',
},
},
])
mockGetBullMQQueueByName.mockReturnValue({
getJob: vi.fn().mockResolvedValue(null),
})
mockHasWorkspaceLease.mockResolvedValue(false)
await reconcileWorkspaceDispatchState()
expect(mockRestoreWorkspaceDispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
id: 'dispatch-2',
status: 'waiting',
lease: undefined,
})
)
expect(mockWakeWorkspaceDispatcher).toHaveBeenCalled()
})
it('reacquires the lease for a live admitting BullMQ job', async () => {
mockListDispatchJobsByStatuses.mockResolvedValue([
{
id: 'dispatch-3',
workspaceId: 'workspace-3',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: {},
metadata: {
dispatchLeaseExpiresAt: 12345,
},
priority: 10,
status: 'admitting',
createdAt: 1,
lease: {
workspaceId: 'workspace-3',
leaseId: 'lease-3',
},
},
])
mockGetBullMQQueueByName.mockReturnValue({
getJob: vi.fn().mockResolvedValue({
getState: vi.fn().mockResolvedValue('active'),
}),
})
mockHasWorkspaceLease.mockResolvedValue(false)
await reconcileWorkspaceDispatchState()
expect(mockRefreshWorkspaceLease).toHaveBeenCalledWith('workspace-3', 'lease-3', 15 * 60 * 1000)
expect(mockMarkDispatchJobAdmitted).toHaveBeenCalledWith(
'dispatch-3',
'workspace-3',
'lease-3',
12345
)
expect(mockRemoveWorkspaceJobFromLane).toHaveBeenCalledWith(
'workspace-3',
'runtime',
'dispatch-3'
)
})
it('releases leaked lease and restores waiting when BullMQ job is gone but lease remains', async () => {
mockListDispatchJobsByStatuses.mockResolvedValue([
{
id: 'dispatch-4',
workspaceId: 'workspace-4',
lane: 'runtime',
queueName: 'workflow-execution',
bullmqJobName: 'workflow-execution',
bullmqPayload: {},
metadata: {},
priority: 10,
status: 'running',
createdAt: 1,
lease: {
workspaceId: 'workspace-4',
leaseId: 'lease-4',
},
},
])
mockGetBullMQQueueByName.mockReturnValue({
getJob: vi.fn().mockResolvedValue(null),
})
mockHasWorkspaceLease.mockResolvedValue(true)
await reconcileWorkspaceDispatchState()
expect(mockReleaseWorkspaceLease).toHaveBeenCalledWith('workspace-4', 'lease-4')
expect(mockRestoreWorkspaceDispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
id: 'dispatch-4',
status: 'waiting',
})
)
})
})

Some files were not shown because too many files have changed in this diff Show More