mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
37 Commits
feat/gener
...
v0.6.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e615816dce | ||
|
|
ca87d7ce29 | ||
|
|
6bebbc5e29 | ||
|
|
7b572f1f61 | ||
|
|
ed9a71f0af | ||
|
|
c78c870fda | ||
|
|
19442f19e2 | ||
|
|
1731a4d7f0 | ||
|
|
9fcd02fd3b | ||
|
|
ff7b5b528c | ||
|
|
30f2d1a0fc | ||
|
|
4bd0731871 | ||
|
|
4f3bc37fe4 | ||
|
|
84d6fdc423 | ||
|
|
4c12914d35 | ||
|
|
e9bdc57616 | ||
|
|
36612ae42a | ||
|
|
1c2c2c65d4 | ||
|
|
ecd3536a72 | ||
|
|
8c0a2e04b1 | ||
|
|
6586c5ce40 | ||
|
|
3ce947566d | ||
|
|
70c36cb7aa | ||
|
|
f1ec5fe824 | ||
|
|
e07e3c34cc | ||
|
|
0d2e6ff31d | ||
|
|
4fd0989264 | ||
|
|
67f8a687f6 | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
15ace5e63f | ||
|
|
fdca73679d | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
@@ -195,17 +195,6 @@ 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 |
|
||||
|
||||
@@ -25,7 +25,6 @@ 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',
|
||||
],
|
||||
@@ -43,7 +42,6 @@ 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',
|
||||
],
|
||||
@@ -61,7 +59,6 @@ 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',
|
||||
],
|
||||
@@ -78,7 +75,6 @@ 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',
|
||||
|
||||
@@ -188,8 +188,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
--border-1: #e0e0e0; /* stronger border */
|
||||
--surface-6: #e5e5e5; /* popovers, elevated surfaces */
|
||||
--surface-7: #d9d9d9;
|
||||
--surface-hover: #f2f2f2; /* hover state */
|
||||
--surface-active: #ececec; /* active/selected state */
|
||||
--surface-active: #ececec; /* hover/active state */
|
||||
|
||||
--workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */
|
||||
|
||||
@@ -343,8 +342,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
|
||||
--border-1: #3d3d3d;
|
||||
--surface-6: #454545;
|
||||
--surface-7: #505050;
|
||||
--surface-hover: #262626; /* hover state */
|
||||
--surface-active: #2c2c2c; /* active/selected state */
|
||||
--surface-active: #2c2c2c; /* hover/active state */
|
||||
|
||||
--workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */
|
||||
|
||||
@@ -503,6 +501,9 @@ 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);
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 {
|
||||
@@ -540,26 +539,10 @@ 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,
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* @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)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,8 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getJobQueue } from '@/lib/core/async-jobs'
|
||||
import { getJobQueue, JOB_STATUS } 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')
|
||||
@@ -25,54 +23,68 @@ export async function GET(
|
||||
|
||||
const authenticatedUserId = authResult.userId
|
||||
|
||||
const dispatchJob = await getDispatchJobRecord(taskId)
|
||||
const jobQueue = await getJobQueue()
|
||||
const job = dispatchJob ? null : await jobQueue.getJob(taskId)
|
||||
const job = await jobQueue.getJob(taskId)
|
||||
|
||||
if (!job && !dispatchJob) {
|
||||
if (!job) {
|
||||
return createErrorResponse('Task not found', 404)
|
||||
}
|
||||
|
||||
const metadataToCheck = dispatchJob?.metadata ?? job?.metadata
|
||||
|
||||
if (metadataToCheck?.workflowId) {
|
||||
if (job.metadata?.workflowId) {
|
||||
const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions')
|
||||
const accessCheck = await verifyWorkflowAccess(
|
||||
authenticatedUserId,
|
||||
metadataToCheck.workflowId as string
|
||||
job.metadata.workflowId as string
|
||||
)
|
||||
if (!accessCheck.hasAccess) {
|
||||
logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`)
|
||||
logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
|
||||
if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) {
|
||||
const { getWorkflowById } = await import('@/lib/workflows/utils')
|
||||
const workflow = await getWorkflowById(metadataToCheck.workflowId as string)
|
||||
const workflow = await getWorkflowById(job.metadata.workflowId as string)
|
||||
if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) {
|
||||
return createErrorResponse('API key is not authorized for this workspace', 403)
|
||||
}
|
||||
}
|
||||
} else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) {
|
||||
logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`)
|
||||
} else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) {
|
||||
logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
} else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) {
|
||||
} else if (!job.metadata?.userId && !job.metadata?.workflowId) {
|
||||
logger.warn(`[${requestId}] Access denied to job ${taskId}`)
|
||||
return createErrorResponse('Access denied', 403)
|
||||
}
|
||||
|
||||
const presented = presentDispatchOrJobStatus(dispatchJob, job)
|
||||
const mappedStatus = job.status === JOB_STATUS.PENDING ? 'queued' : job.status
|
||||
|
||||
const response: any = {
|
||||
success: true,
|
||||
taskId,
|
||||
status: presented.status,
|
||||
metadata: presented.metadata,
|
||||
status: mappedStatus,
|
||||
metadata: {
|
||||
startedAt: job.startedAt,
|
||||
},
|
||||
}
|
||||
|
||||
if (presented.output !== undefined) response.output = presented.output
|
||||
if (presented.error !== undefined) response.error = presented.error
|
||||
if (presented.estimatedDuration !== undefined) {
|
||||
response.estimatedDuration = presented.estimatedDuration
|
||||
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
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
@@ -18,7 +18,6 @@ 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'
|
||||
@@ -728,25 +727,10 @@ 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,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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'
|
||||
@@ -72,24 +71,10 @@ 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,
|
||||
|
||||
@@ -9,12 +9,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
mockVerifyCronAuth,
|
||||
mockExecuteScheduleJob,
|
||||
mockExecuteJobInline,
|
||||
mockFeatureFlags,
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
mockEnqueueWorkspaceDispatch,
|
||||
mockStartJob,
|
||||
mockCompleteJob,
|
||||
mockMarkJobFailed,
|
||||
@@ -24,7 +22,6 @@ 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)
|
||||
@@ -32,7 +29,6 @@ const {
|
||||
return {
|
||||
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
|
||||
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
|
||||
mockFeatureFlags: {
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
@@ -42,7 +38,6 @@ const {
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
mockEnqueueWorkspaceDispatch,
|
||||
mockStartJob,
|
||||
mockCompleteJob,
|
||||
mockMarkJobFailed,
|
||||
@@ -55,8 +50,6 @@ 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)
|
||||
@@ -75,22 +68,6 @@ 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' })),
|
||||
@@ -165,18 +142,6 @@ 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'],
|
||||
@@ -246,44 +211,30 @@ 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(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'schedule-execution',
|
||||
expect.objectContaining({
|
||||
id: 'schedule-execution-1',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'schedule-execution',
|
||||
bullmqJobName: 'schedule-execution',
|
||||
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',
|
||||
},
|
||||
}),
|
||||
{
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
correlation: {
|
||||
@@ -296,7 +247,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
scheduledFor: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,9 +5,7 @@ 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,
|
||||
@@ -75,8 +73,6 @@ export async function GET(request: NextRequest) {
|
||||
cronExpression: workflowSchedule.cronExpression,
|
||||
failedCount: workflowSchedule.failedCount,
|
||||
lastQueuedAt: workflowSchedule.lastQueuedAt,
|
||||
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
|
||||
sourceUserId: workflowSchedule.sourceUserId,
|
||||
sourceType: workflowSchedule.sourceType,
|
||||
})
|
||||
|
||||
@@ -115,40 +111,9 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
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 },
|
||||
})
|
||||
}
|
||||
const 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}`
|
||||
)
|
||||
@@ -200,7 +165,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
// Mothership jobs use BullMQ when available, otherwise direct inline execution.
|
||||
// Jobs always execute inline (no TriggerDev)
|
||||
const jobPromises = dueJobs.map(async (job) => {
|
||||
const queueTime = job.lastQueuedAt ?? queuedAt
|
||||
const payload = {
|
||||
@@ -211,24 +176,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
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)
|
||||
}
|
||||
await executeJobInline(payload)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Job execution failed for ${job.id}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -105,24 +104,10 @@ 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,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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,
|
||||
@@ -43,25 +41,10 @@ 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
|
||||
@@ -157,30 +140,17 @@ async function handleWebhookPost(
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
|
||||
requestId,
|
||||
path,
|
||||
actorUserId: preprocessResult.actorUserId,
|
||||
executionId: preprocessResult.executionId,
|
||||
correlation: preprocessResult.correlation,
|
||||
})
|
||||
responses.push(response)
|
||||
}
|
||||
|
||||
// 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 })
|
||||
}
|
||||
|
||||
@@ -10,18 +10,15 @@ 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',
|
||||
@@ -47,16 +44,6 @@ 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', () => ({
|
||||
@@ -145,13 +132,22 @@ describe('workflow execute async route', () => {
|
||||
expect(response.status).toBe(202)
|
||||
expect(body.executionId).toBe('execution-123')
|
||||
expect(body.jobId).toBe('job-123')
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'workflow-execution',
|
||||
expect.objectContaining({
|
||||
id: 'execution-123',
|
||||
workspaceId: 'workspace-1',
|
||||
lane: 'runtime',
|
||||
queueName: 'workflow-execution',
|
||||
bullmqJobName: 'workflow-execution',
|
||||
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',
|
||||
},
|
||||
}),
|
||||
{
|
||||
metadata: {
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'actor-1',
|
||||
@@ -163,7 +159,7 @@ describe('workflow execute async route', () => {
|
||||
triggerType: 'manual',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,8 @@ 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, 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 { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import {
|
||||
createTimeoutAbortController,
|
||||
getTimeoutErrorMessage,
|
||||
@@ -14,13 +12,6 @@ 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,
|
||||
@@ -42,11 +33,6 @@ 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,
|
||||
@@ -118,8 +104,6 @@ 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>
|
||||
@@ -177,7 +161,6 @@ type AsyncExecutionParams = {
|
||||
requestId: string
|
||||
workflowId: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
input: any
|
||||
triggerType: CoreTriggerType
|
||||
executionId: string
|
||||
@@ -185,8 +168,7 @@ type AsyncExecutionParams = {
|
||||
}
|
||||
|
||||
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
|
||||
const { requestId, workflowId, userId, workspaceId, input, triggerType, executionId, callChain } =
|
||||
params
|
||||
const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params
|
||||
|
||||
const correlation = {
|
||||
executionId,
|
||||
@@ -199,7 +181,6 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
const payload: WorkflowExecutionPayload = {
|
||||
workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
input,
|
||||
triggerType,
|
||||
executionId,
|
||||
@@ -209,42 +190,22 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
}
|
||||
|
||||
try {
|
||||
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 },
|
||||
})
|
||||
const jobQueue = await getJobQueue()
|
||||
const jobId = await jobQueue.enqueue('workflow-execution', payload, {
|
||||
metadata: { workflowId, userId, correlation },
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Queued async workflow execution`, {
|
||||
workflowId,
|
||||
jobId,
|
||||
})
|
||||
|
||||
if (shouldExecuteInline() && jobQueue) {
|
||||
const inlineJobQueue = jobQueue
|
||||
if (shouldExecuteInline()) {
|
||||
void (async () => {
|
||||
try {
|
||||
await inlineJobQueue.startJob(jobId)
|
||||
await jobQueue.startJob(jobId)
|
||||
const output = await executeWorkflowJob(payload)
|
||||
await inlineJobQueue.completeJob(jobId, output)
|
||||
await jobQueue.completeJob(jobId, output)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logger.error(`[${requestId}] Async workflow execution failed`, {
|
||||
@@ -252,7 +213,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
error: errorMessage,
|
||||
})
|
||||
try {
|
||||
await inlineJobQueue.markJobFailed(jobId, errorMessage)
|
||||
await jobQueue.markJobFailed(jobId, errorMessage)
|
||||
} catch (markFailedError) {
|
||||
logger.error(`[${requestId}] Failed to mark job as failed`, {
|
||||
jobId,
|
||||
@@ -278,17 +239,6 @@ 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}` },
|
||||
@@ -297,31 +247,6 @@ 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
|
||||
*
|
||||
@@ -329,27 +254,6 @@ async function enqueueDirectWorkflowExecution(
|
||||
* 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
|
||||
|
||||
@@ -680,7 +584,6 @@ async function handleExecutePost(
|
||||
requestId,
|
||||
workflowId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
input,
|
||||
triggerType: loggingTriggerType,
|
||||
executionId,
|
||||
@@ -773,116 +676,30 @@ async function handleExecutePost(
|
||||
|
||||
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,
|
||||
@@ -992,53 +809,6 @@ async function handleExecutePost(
|
||||
}
|
||||
|
||||
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`)
|
||||
@@ -1507,17 +1277,6 @@ async function handleExecutePost(
|
||||
},
|
||||
})
|
||||
} 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' },
|
||||
|
||||
@@ -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 { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
|
||||
import { listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -25,7 +25,6 @@ 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)
|
||||
@@ -127,13 +126,12 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const {
|
||||
id: clientId,
|
||||
name: requestedName,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder: providedSortOrder,
|
||||
deduplicate,
|
||||
} = CreateWorkflowSchema.parse(body)
|
||||
|
||||
if (!workspaceId) {
|
||||
@@ -164,6 +162,19 @@ 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
|
||||
@@ -203,49 +214,30 @@ export async function POST(req: NextRequest) {
|
||||
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||
}
|
||||
|
||||
let name = requestedName
|
||||
const duplicateConditions = [
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
isNull(workflow.archivedAt),
|
||||
eq(workflow.name, name),
|
||||
]
|
||||
|
||||
if (deduplicate) {
|
||||
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
|
||||
if (folderId) {
|
||||
duplicateConditions.push(eq(workflow.folderId, folderId))
|
||||
} else {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
duplicateConditions.push(isNull(workflow.folderId))
|
||||
}
|
||||
|
||||
import('@/lib/core/telemetry')
|
||||
.then(({ PlatformEvents }) => {
|
||||
PlatformEvents.workflowCreated({
|
||||
workflowId,
|
||||
name,
|
||||
workspaceId: workspaceId || undefined,
|
||||
folderId: folderId || undefined,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
|
||||
import type { WorkspaceMember } from '@/hooks/queries/workspace'
|
||||
|
||||
interface OwnerAvatarProps {
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
|
||||
function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
|
||||
if (image) {
|
||||
return (
|
||||
<img
|
||||
@@ -24,7 +18,7 @@ const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps)
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
|
||||
|
||||
@@ -11,8 +11,6 @@ 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
|
||||
@@ -124,7 +122,7 @@ export const ResourceHeader = memo(function ResourceHeader({
|
||||
variant='subtle'
|
||||
className='px-2 py-1 text-caption'
|
||||
>
|
||||
{HEADER_PLUS_ICON}
|
||||
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
{create.label}
|
||||
</Button>
|
||||
)}
|
||||
@@ -134,21 +132,19 @@ export const ResourceHeader = memo(function ResourceHeader({
|
||||
)
|
||||
})
|
||||
|
||||
interface BreadcrumbSegmentProps {
|
||||
icon?: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
dropdownItems?: DropdownOption[]
|
||||
editing?: BreadcrumbEditing
|
||||
}
|
||||
|
||||
const BreadcrumbSegment = memo(function BreadcrumbSegment({
|
||||
function BreadcrumbSegment({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
dropdownItems,
|
||||
editing,
|
||||
}: BreadcrumbSegmentProps) {
|
||||
}: {
|
||||
icon?: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
dropdownItems?: DropdownOption[]
|
||||
editing?: BreadcrumbEditing
|
||||
}) {
|
||||
if (editing?.isEditing) {
|
||||
return (
|
||||
<span className='inline-flex items-center px-2 py-1'>
|
||||
@@ -207,4 +203,4 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, type ReactNode, useCallback, useRef, useState } from 'react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -16,12 +16,6 @@ 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 {
|
||||
@@ -85,7 +79,56 @@ 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 && <SearchSection search={search} />}
|
||||
{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>
|
||||
)}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{extras}
|
||||
{filterTags?.map((tag) => (
|
||||
@@ -103,7 +146,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
<PopoverPrimitive.Root>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
<Button variant='subtle' className='px-2 py-1 text-caption'>
|
||||
{FILTER_ICON}
|
||||
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Filter
|
||||
</Button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
@@ -127,94 +170,14 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
|
||||
)
|
||||
})
|
||||
|
||||
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 }) {
|
||||
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'>
|
||||
{SORT_ICON}
|
||||
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
Sort
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -255,4 +218,4 @@ const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ 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
|
||||
@@ -71,13 +69,11 @@ 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 const Resource = memo(function Resource({
|
||||
export function Resource({
|
||||
icon,
|
||||
title,
|
||||
breadcrumbs,
|
||||
@@ -139,7 +135,7 @@ export const Resource = memo(function Resource({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export interface ResourceTableProps {
|
||||
columns: ResourceColumn[]
|
||||
@@ -233,13 +229,6 @@ 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
|
||||
@@ -270,7 +259,7 @@ export const ResourceTable = memo(function ResourceTable({
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectable.isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
onCheckedChange={(checked) => selectable.onSelectAll(checked as boolean)}
|
||||
disabled={selectable.disabled}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
@@ -317,20 +306,68 @@ export const ResourceTable = memo(function ResourceTable({
|
||||
<table className='w-full table-fixed text-small'>
|
||||
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
|
||||
<tbody>
|
||||
{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} />}
|
||||
{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>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasMore && (
|
||||
@@ -353,7 +390,7 @@ export const ResourceTable = memo(function ResourceTable({
|
||||
)
|
||||
})
|
||||
|
||||
const Pagination = memo(function Pagination({
|
||||
function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
@@ -410,17 +447,10 @@ const Pagination = memo(function Pagination({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface CellContentProps {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
content?: ReactNode
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) {
|
||||
if (content) return <>{content}</>
|
||||
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
|
||||
if (cell.content) return <>{cell.content}</>
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -428,132 +458,19 @@ const CellContent = memo(function CellContent({ icon, label, content, primary }:
|
||||
primary ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{icon}</span>}
|
||||
<span className='truncate'>{label}</span>
|
||||
{cell.icon && <span className='flex-shrink-0 text-[var(--text-icon)]'>{cell.icon}</span>}
|
||||
<span className='truncate'>{cell.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
|
||||
}
|
||||
|
||||
const DataRow = memo(function DataRow({
|
||||
row,
|
||||
function ResourceColGroup({
|
||||
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]' />}
|
||||
@@ -569,19 +486,17 @@ const ResourceColGroup = memo(function ResourceColGroup({
|
||||
))}
|
||||
</colgroup>
|
||||
)
|
||||
})
|
||||
|
||||
interface DataTableSkeletonProps {
|
||||
columns: ResourceColumn[]
|
||||
rowCount: number
|
||||
hasCheckbox?: boolean
|
||||
}
|
||||
|
||||
const DataTableSkeleton = memo(function DataTableSkeleton({
|
||||
function DataTableSkeleton({
|
||||
columns,
|
||||
rowCount,
|
||||
hasCheckbox,
|
||||
}: DataTableSkeletonProps) {
|
||||
}: {
|
||||
columns: ResourceColumn[]
|
||||
rowCount: number
|
||||
hasCheckbox?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className='overflow-hidden'>
|
||||
@@ -634,4 +549,4 @@ const DataTableSkeleton = memo(function DataTableSkeleton({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -183,8 +183,6 @@ 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('')
|
||||
@@ -232,14 +230,14 @@ function TextEditor({
|
||||
const currentContent = contentRef.current
|
||||
if (currentContent === savedContentRef.current) return
|
||||
|
||||
await updateContentRef.current.mutateAsync({
|
||||
await updateContent.mutateAsync({
|
||||
workspaceId,
|
||||
fileId: file.id,
|
||||
content: currentContent,
|
||||
})
|
||||
setSavedContent(currentContent)
|
||||
savedContentRef.current = currentContent
|
||||
}, [workspaceId, file.id])
|
||||
}, [workspaceId, file.id, updateContent])
|
||||
|
||||
const { saveStatus, saveImmediately, isDirty } = useAutosave({
|
||||
content,
|
||||
@@ -404,7 +402,7 @@ function TextEditor({
|
||||
)
|
||||
}
|
||||
|
||||
const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
|
||||
return (
|
||||
@@ -419,9 +417,9 @@ const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFil
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
|
||||
return (
|
||||
@@ -434,7 +432,7 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const pptxSlideCache = new Map<string, string[]>()
|
||||
|
||||
@@ -703,11 +701,7 @@ function PptxPreview({
|
||||
)
|
||||
}
|
||||
|
||||
const UnsupportedPreview = memo(function UnsupportedPreview({
|
||||
file,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
}) {
|
||||
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const ext = getFileExtension(file.name)
|
||||
|
||||
return (
|
||||
@@ -720,4 +714,4 @@ const UnsupportedPreview = memo(function UnsupportedPreview({
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,12 +42,7 @@ interface PreviewPanelProps {
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export const PreviewPanel = memo(function PreviewPanel({
|
||||
content,
|
||||
mimeType,
|
||||
filename,
|
||||
isStreaming,
|
||||
}: PreviewPanelProps) {
|
||||
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
|
||||
const previewType = resolvePreviewType(mimeType, filename)
|
||||
|
||||
if (previewType === 'markdown')
|
||||
@@ -57,7 +52,7 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
if (previewType === 'svg') return <SvgPreview content={content} />
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
|
||||
@@ -202,7 +197,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
)
|
||||
})
|
||||
|
||||
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
|
||||
function HtmlPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='h-full overflow-hidden'>
|
||||
<iframe
|
||||
@@ -213,9 +208,9 @@ const HtmlPreview = memo(function HtmlPreview({ content }: { content: string })
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
|
||||
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>`,
|
||||
@@ -232,9 +227,9 @@ const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
|
||||
function CsvPreview({ content }: { content: string }) {
|
||||
const { headers, rows } = useMemo(() => parseCsv(content), [content])
|
||||
|
||||
if (headers.length === 0) {
|
||||
@@ -276,7 +271,7 @@ const CsvPreview = memo(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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,7 +18,7 @@ interface FilesListContextMenuProps {
|
||||
disableUpload?: boolean
|
||||
}
|
||||
|
||||
export const FilesListContextMenu = memo(function FilesListContextMenu({
|
||||
export function FilesListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
@@ -65,4 +64,4 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -41,7 +41,6 @@ import type {
|
||||
HeaderAction,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
InlineRenameInput,
|
||||
@@ -160,29 +159,11 @@ export function Files() {
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
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 [searchTerm, setSearchTerm] = useState('')
|
||||
const [creatingFile, setCreatingFile] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
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 [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
|
||||
@@ -202,105 +183,59 @@ export function Files() {
|
||||
() => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null),
|
||||
[fileIdFromRoute, files]
|
||||
)
|
||||
const selectedFileRef = useRef(selectedFile)
|
||||
selectedFileRef.current = selectedFile
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!debouncedSearchTerm) return files
|
||||
const q = debouncedSearchTerm.toLowerCase()
|
||||
if (!searchTerm) return files
|
||||
const q = searchTerm.toLowerCase()
|
||||
return files.filter((f) => f.name.toLowerCase().includes(q))
|
||||
}, [files, debouncedSearchTerm])
|
||||
}, [files, searchTerm])
|
||||
|
||||
const rowCacheRef = useRef(
|
||||
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
|
||||
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 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
|
||||
@@ -353,13 +288,8 @@ export function Files() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteTargetFileRef = useRef(deleteTargetFile)
|
||||
deleteTargetFileRef.current = deleteTargetFile
|
||||
const fileIdFromRouteRef = useRef(fileIdFromRoute)
|
||||
fileIdFromRouteRef.current = fileIdFromRoute
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const target = deleteTargetFileRef.current
|
||||
const target = deleteTargetFile
|
||||
if (!target) return
|
||||
|
||||
try {
|
||||
@@ -369,7 +299,7 @@ export function Files() {
|
||||
})
|
||||
setShowDeleteConfirm(false)
|
||||
setDeleteTargetFile(null)
|
||||
if (fileIdFromRouteRef.current === target.id) {
|
||||
if (fileIdFromRoute === target.id) {
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
@@ -377,44 +307,36 @@ export function Files() {
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete file:', err)
|
||||
}
|
||||
}, [workspaceId, router])
|
||||
|
||||
const isDirtyRef = useRef(isDirty)
|
||||
isDirtyRef.current = isDirty
|
||||
const saveStatusRef = useRef(saveStatus)
|
||||
saveStatusRef.current = saveStatus
|
||||
}, [deleteTargetFile, workspaceId, fileIdFromRoute, router])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return
|
||||
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
|
||||
await saveRef.current()
|
||||
}, [])
|
||||
}, [isDirty, saveStatus])
|
||||
|
||||
const handleBackAttempt = useCallback(() => {
|
||||
if (isDirtyRef.current) {
|
||||
if (isDirty) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
setPreviewMode('editor')
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}
|
||||
}, [router, workspaceId])
|
||||
}, [isDirty, router, workspaceId])
|
||||
|
||||
const handleStartHeaderRename = useCallback(() => {
|
||||
const file = selectedFileRef.current
|
||||
if (file) headerRename.startRename(file.id, file.name)
|
||||
}, [headerRename.startRename])
|
||||
if (selectedFile) headerRename.startRename(selectedFile.id, selectedFile.name)
|
||||
}, [selectedFile, headerRename.startRename])
|
||||
|
||||
const handleDownloadSelected = useCallback(() => {
|
||||
const file = selectedFileRef.current
|
||||
if (file) handleDownload(file)
|
||||
}, [handleDownload])
|
||||
if (selectedFile) handleDownload(selectedFile)
|
||||
}, [selectedFile, handleDownload])
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
const file = selectedFileRef.current
|
||||
if (file) {
|
||||
setDeleteTargetFile(file)
|
||||
if (selectedFile) {
|
||||
setDeleteTargetFile(selectedFile)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
}, [])
|
||||
}, [selectedFile])
|
||||
|
||||
const fileDetailBreadcrumbs = useMemo(
|
||||
() =>
|
||||
@@ -457,6 +379,9 @@ export function Files() {
|
||||
handleBackAttempt,
|
||||
headerRename.editingId,
|
||||
headerRename.editValue,
|
||||
headerRename.setEditValue,
|
||||
headerRename.submitRename,
|
||||
headerRename.cancelRename,
|
||||
handleStartHeaderRename,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
@@ -471,15 +396,12 @@ export function Files() {
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const creatingFileRef = useRef(creatingFile)
|
||||
creatingFileRef.current = creatingFile
|
||||
|
||||
const handleCreateFile = useCallback(async () => {
|
||||
if (creatingFileRef.current) return
|
||||
if (creatingFile) return
|
||||
setCreatingFile(true)
|
||||
|
||||
try {
|
||||
const existingNames = new Set(filesRef.current.map((f) => f.name))
|
||||
const existingNames = new Set(files.map((f) => f.name))
|
||||
let name = 'untitled.md'
|
||||
let counter = 1
|
||||
while (existingNames.has(name)) {
|
||||
@@ -501,49 +423,42 @@ export function Files() {
|
||||
} finally {
|
||||
setCreatingFile(false)
|
||||
}
|
||||
}, [workspaceId, router])
|
||||
}, [creatingFile, files, workspaceId, router])
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const file = filesRef.current.find((f) => f.id === rowId)
|
||||
const file = files.find((f) => f.id === rowId)
|
||||
if (file) {
|
||||
setContextMenuFile(file)
|
||||
openContextMenu(e)
|
||||
}
|
||||
},
|
||||
[openContextMenu]
|
||||
[files, openContextMenu]
|
||||
)
|
||||
|
||||
const contextMenuFileRef = useRef(contextMenuFile)
|
||||
contextMenuFileRef.current = contextMenuFile
|
||||
|
||||
const handleContextMenuOpen = useCallback(() => {
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
router.push(`/workspace/${workspaceId}/files/${file.id}`)
|
||||
if (!contextMenuFile) return
|
||||
router.push(`/workspace/${workspaceId}/files/${contextMenuFile.id}`)
|
||||
closeContextMenu()
|
||||
}, [closeContextMenu, router, workspaceId])
|
||||
}, [contextMenuFile, closeContextMenu, router, workspaceId])
|
||||
|
||||
const handleContextMenuDownload = useCallback(() => {
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
handleDownload(file)
|
||||
if (!contextMenuFile) return
|
||||
handleDownload(contextMenuFile)
|
||||
closeContextMenu()
|
||||
}, [handleDownload, closeContextMenu])
|
||||
}, [contextMenuFile, handleDownload, closeContextMenu])
|
||||
|
||||
const handleContextMenuRename = useCallback(() => {
|
||||
const file = contextMenuFileRef.current
|
||||
if (file) listRename.startRename(file.id, file.name)
|
||||
if (contextMenuFile) listRename.startRename(contextMenuFile.id, contextMenuFile.name)
|
||||
closeContextMenu()
|
||||
}, [listRename.startRename, closeContextMenu])
|
||||
}, [contextMenuFile, listRename.startRename, closeContextMenu])
|
||||
|
||||
const handleContextMenuDelete = useCallback(() => {
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
setDeleteTargetFile(file)
|
||||
if (!contextMenuFile) return
|
||||
setDeleteTargetFile(contextMenuFile)
|
||||
setShowDeleteConfirm(true)
|
||||
closeContextMenu()
|
||||
}, [closeContextMenu])
|
||||
}, [contextMenuFile, closeContextMenu])
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -564,46 +479,41 @@ export function Files() {
|
||||
closeListContextMenu()
|
||||
}, [closeListContextMenu])
|
||||
|
||||
const prevFileIdRef = useRef(fileIdFromRoute)
|
||||
if (fileIdFromRoute !== prevFileIdRef.current) {
|
||||
prevFileIdRef.current = fileIdFromRoute
|
||||
useEffect(() => {
|
||||
const isJustCreated =
|
||||
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
|
||||
if (justCreatedFileIdRef.current && !isJustCreated) {
|
||||
justCreatedFileIdRef.current = null
|
||||
}
|
||||
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)
|
||||
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')
|
||||
}
|
||||
}
|
||||
}, [fileIdFromRoute])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFile) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!fileIdFromRouteRef.current) return
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (!isDirtyRef.current) return
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedFile, handleSave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [handleSave])
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [isDirty])
|
||||
|
||||
const handleCyclePreviewMode = useCallback(() => {
|
||||
setPreviewMode((prev) => {
|
||||
@@ -682,92 +592,27 @@ 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={loadingBreadcrumbs} />
|
||||
<ResourceHeader
|
||||
icon={FilesIcon}
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: 'Files',
|
||||
onClick: () => router.push(`/workspace/${workspaceId}/files`),
|
||||
},
|
||||
{ label: '...' },
|
||||
]}
|
||||
/>
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<Skeleton className='h-[16px] w-[200px]' />
|
||||
</div>
|
||||
@@ -788,7 +633,7 @@ export function Files() {
|
||||
key={selectedFile.id}
|
||||
file={selectedFile}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
canEdit={userPermissions.canEdit === true}
|
||||
previewMode={previewMode}
|
||||
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
|
||||
onDirtyChange={setIsDirty}
|
||||
@@ -827,18 +672,43 @@ export function Files() {
|
||||
)
|
||||
}
|
||||
|
||||
const uploadButtonLabel =
|
||||
uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
icon={FilesIcon}
|
||||
title='Files'
|
||||
create={createConfig}
|
||||
search={searchConfig}
|
||||
create={{
|
||||
label: 'New file',
|
||||
onClick: handleCreateFile,
|
||||
disabled: uploading || creatingFile || userPermissions.canEdit !== true,
|
||||
}}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search files...',
|
||||
}}
|
||||
defaultSort='created'
|
||||
headerActions={headerActionsConfig}
|
||||
headerActions={[
|
||||
{
|
||||
label: uploadButtonLabel,
|
||||
icon: Upload,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
},
|
||||
]}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClick={(id) => {
|
||||
if (listRename.editingId !== id && !headerRename.editingId) {
|
||||
router.push(`/workspace/${workspaceId}/files/${id}`)
|
||||
}
|
||||
}}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
isLoading={isLoading}
|
||||
onContextMenu={handleContentContextMenu}
|
||||
@@ -850,20 +720,58 @@ export function Files() {
|
||||
onClose={closeListContextMenu}
|
||||
onCreateFile={handleCreateFile}
|
||||
onUploadFile={handleListUploadFile}
|
||||
disableCreate={uploading || creatingFile || !canEdit}
|
||||
disableUpload={uploading || !canEdit}
|
||||
disableCreate={uploading || creatingFile || userPermissions.canEdit !== true}
|
||||
disableUpload={uploading || userPermissions.canEdit !== true}
|
||||
/>
|
||||
|
||||
<FileRowContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
onClose={closeContextMenu}
|
||||
onOpen={handleContextMenuOpen}
|
||||
onDownload={handleContextMenuDownload}
|
||||
onRename={handleContextMenuRename}
|
||||
onDelete={handleContextMenuDelete}
|
||||
canEdit={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>
|
||||
|
||||
<DeleteConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
@@ -886,75 +794,6 @@ 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
|
||||
@@ -963,7 +802,7 @@ interface DeleteConfirmModalProps {
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
const DeleteConfirmModal = memo(function DeleteConfirmModal({
|
||||
function DeleteConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
@@ -994,4 +833,4 @@ const DeleteConfirmModal = memo(function DeleteConfirmModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ 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,
|
||||
|
||||
@@ -54,7 +54,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
deduplicate: true,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1213,30 +1213,31 @@ export function useChat(
|
||||
}
|
||||
flush()
|
||||
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 (
|
||||
parsed.type === 'tool_call' &&
|
||||
@@ -1333,17 +1334,18 @@ export function useChat(
|
||||
flush()
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
break
|
||||
}
|
||||
@@ -1452,32 +1454,33 @@ export function useChat(
|
||||
}
|
||||
}
|
||||
|
||||
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?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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?.()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
break
|
||||
@@ -1574,12 +1577,13 @@ export function useChat(
|
||||
}
|
||||
flush()
|
||||
|
||||
if (toolCallName && shouldOpenGenericResource(toolCallName)) {
|
||||
const entryIdx = genericEntryMap.get(id)
|
||||
if (entryIdx !== undefined) {
|
||||
updateGenericEntry(entryIdx, { status: 'error', streamingArgs: undefined })
|
||||
}
|
||||
}
|
||||
// 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 })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ export type MothershipToolName =
|
||||
| 'create_job'
|
||||
| 'complete_job'
|
||||
| 'update_job_history'
|
||||
| 'job_respond'
|
||||
| 'download_to_workspace_file'
|
||||
| 'materialize_file'
|
||||
| 'context_write'
|
||||
@@ -394,7 +393,6 @@ 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' },
|
||||
|
||||
@@ -1138,12 +1138,9 @@ export function Document({
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{effectiveDocumentName}
|
||||
</span>
|
||||
?{' '}
|
||||
<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>{' '}
|
||||
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
|
||||
chunk
|
||||
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
|
||||
{documentData?.connectorId ? (
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This document is synced from a connector. Deleting it will permanently exclude it
|
||||
|
||||
@@ -1106,10 +1106,8 @@ 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>?
|
||||
<span className='text-[var(--text-error)]'>
|
||||
The knowledge base and all {pagination.total} document
|
||||
{pagination.total === 1 ? '' : 's'} within it will be removed.
|
||||
</span>{' '}
|
||||
The knowledge base and all {pagination.total} document
|
||||
{pagination.total === 1 ? '' : 's'} within it will be removed.{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
</span>
|
||||
@@ -1149,9 +1147,7 @@ export function KnowledgeBase({
|
||||
it from future syncs. To temporarily hide it from search, disable it instead.
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently delete the document.
|
||||
</span>
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
@@ -1181,10 +1177,7 @@ 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 will permanently delete the selected document
|
||||
{selectedDocuments.size === 1 ? '' : 's'}.
|
||||
</span>
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -416,11 +416,9 @@ 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?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove this tag from {selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.
|
||||
</span>{' '}
|
||||
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' : ''}.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -73,26 +73,12 @@ export function ConnectorsSection({
|
||||
isLoading,
|
||||
canEdit,
|
||||
}: ConnectorsSectionProps) {
|
||||
const { mutate: triggerSync } = useTriggerSync()
|
||||
const { mutate: updateConnector } = useUpdateConnector()
|
||||
const { mutate: triggerSync, isPending: isSyncing } = useTriggerSync()
|
||||
const { mutate: updateConnector, isPending: isUpdating } = 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())
|
||||
@@ -117,7 +103,6 @@ export function ConnectorsSection({
|
||||
if (isSyncOnCooldown(connectorId)) return
|
||||
|
||||
syncTriggeredAt.current[connectorId] = Date.now()
|
||||
addToSet(setSyncingIds, connectorId)
|
||||
|
||||
triggerSync(
|
||||
{ knowledgeBaseId, connectorId },
|
||||
@@ -136,35 +121,10 @@ export function ConnectorsSection({
|
||||
delete syncTriggeredAt.current[connectorId]
|
||||
forceUpdate((n) => n + 1)
|
||||
},
|
||||
onSettled: () => removeFromSet(setSyncingIds, connectorId),
|
||||
}
|
||||
)
|
||||
},
|
||||
[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]
|
||||
[knowledgeBaseId, triggerSync, isSyncOnCooldown]
|
||||
)
|
||||
|
||||
if (connectors.length === 0 && !canEdit && !isLoading) return null
|
||||
@@ -203,11 +163,28 @@ export function ConnectorsSection({
|
||||
workspaceId={workspaceId}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
canEdit={canEdit}
|
||||
isSyncPending={syncingIds.has(connector.id)}
|
||||
isUpdating={updatingIds.has(connector.id)}
|
||||
isSyncing={isSyncing}
|
||||
isUpdating={isUpdating}
|
||||
syncCooldown={isSyncOnCooldown(connector.id)}
|
||||
onSync={() => handleSync(connector.id)}
|
||||
onTogglePause={() => handleTogglePause(connector)}
|
||||
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)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
onEdit={() => setEditingConnector(connector)}
|
||||
onDelete={() => setDeleteTarget(connector.id)}
|
||||
/>
|
||||
@@ -229,13 +206,8 @@ 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?{' '}
|
||||
<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>
|
||||
Are you sure you want to remove this connected source? Documents already synced will
|
||||
remain in the knowledge base.
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
@@ -278,7 +250,7 @@ interface ConnectorCardProps {
|
||||
workspaceId: string
|
||||
knowledgeBaseId: string
|
||||
canEdit: boolean
|
||||
isSyncPending: boolean
|
||||
isSyncing: boolean
|
||||
isUpdating: boolean
|
||||
syncCooldown: boolean
|
||||
onSync: () => void
|
||||
@@ -292,7 +264,7 @@ function ConnectorCard({
|
||||
workspaceId,
|
||||
knowledgeBaseId,
|
||||
canEdit,
|
||||
isSyncPending,
|
||||
isSyncing,
|
||||
isUpdating,
|
||||
syncCooldown,
|
||||
onSync,
|
||||
@@ -334,13 +306,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='flex items-center gap-1.5 font-medium text-[var(--text-primary)] text-small'>
|
||||
<span className='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>
|
||||
@@ -384,7 +356,7 @@ function ConnectorCard({
|
||||
variant='ghost'
|
||||
className='h-7 w-7 p-0'
|
||||
onClick={onSync}
|
||||
disabled={connector.status === 'syncing' || isSyncPending || syncCooldown}
|
||||
disabled={connector.status === 'syncing' || isSyncing || syncCooldown}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react'
|
||||
@@ -78,10 +78,7 @@ interface SubmitStatus {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const CreateBaseModal = memo(function CreateBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateBaseModalProps) {
|
||||
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -546,4 +543,4 @@ export const CreateBaseModal = memo(function CreateBaseModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
|
||||
interface DeleteKnowledgeBaseModalProps {
|
||||
@@ -30,7 +29,7 @@ interface DeleteKnowledgeBaseModalProps {
|
||||
* Delete confirmation modal for knowledge base items.
|
||||
* Displays a warning message and confirmation buttons.
|
||||
*/
|
||||
export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
|
||||
export function DeleteKnowledgeBaseModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -47,17 +46,10 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All associated documents, chunks, and embeddings will be removed.
|
||||
</span>
|
||||
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>
|
||||
</>
|
||||
'Are you sure you want to delete this knowledge base? All associated documents, chunks, and embeddings will be removed.'
|
||||
)}{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
@@ -75,4 +67,4 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { 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 const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
|
||||
export function EditKnowledgeBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
@@ -172,4 +172,4 @@ export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -31,7 +30,7 @@ interface KnowledgeBaseContextMenuProps {
|
||||
* Context menu component for knowledge base cards.
|
||||
* Displays open in new tab, view tags, edit, and delete options.
|
||||
*/
|
||||
export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
export function KnowledgeBaseContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
@@ -115,4 +114,4 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -21,7 +20,7 @@ interface KnowledgeListContextMenuProps {
|
||||
* Context menu component for the knowledge base list page.
|
||||
* Displays "Add knowledge base" option when right-clicking on empty space.
|
||||
*/
|
||||
export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
|
||||
export function KnowledgeListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
@@ -59,4 +58,4 @@ export const KnowledgeListContextMenu = memo(function KnowledgeListContextMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, 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 {
|
||||
CreateAction,
|
||||
ResourceCell,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import {
|
||||
@@ -25,10 +18,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')
|
||||
|
||||
@@ -40,48 +33,11 @@ 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()
|
||||
@@ -98,22 +54,8 @@ export function Knowledge() {
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
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 [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
|
||||
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
|
||||
@@ -127,6 +69,7 @@ export function Knowledge() {
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
position: listContextMenuPosition,
|
||||
menuRef: listMenuRef,
|
||||
handleContextMenu: handleListContextMenu,
|
||||
closeMenu: closeListContextMenu,
|
||||
} = useContextMenu()
|
||||
@@ -134,19 +77,11 @@ 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
|
||||
@@ -161,7 +96,7 @@ export function Knowledge() {
|
||||
[handleListContextMenu]
|
||||
)
|
||||
|
||||
const handleOpenCreateModal = useCallback(() => {
|
||||
const handleAddKnowledgeBase = useCallback(() => {
|
||||
setIsCreateModalOpen(true)
|
||||
}, [])
|
||||
|
||||
@@ -197,7 +132,7 @@ export function Knowledge() {
|
||||
id: kb.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: DATABASE_ICON,
|
||||
icon: <Database className='h-[14px] w-[14px]' />,
|
||||
label: kb.name,
|
||||
},
|
||||
documents: {
|
||||
@@ -206,7 +141,6 @@ 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),
|
||||
@@ -214,7 +148,6 @@ 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(),
|
||||
},
|
||||
@@ -225,98 +158,51 @@ export function Knowledge() {
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
if (isRowContextMenuOpenRef.current) return
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId)
|
||||
if (isRowContextMenuOpen) return
|
||||
const kb = knowledgeBases.find((k) => k.id === rowId)
|
||||
if (!kb) return
|
||||
const urlParams = new URLSearchParams({ kbName: kb.name })
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
|
||||
},
|
||||
[router, workspaceId]
|
||||
[isRowContextMenuOpen, knowledgeBases, router, workspaceId]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as
|
||||
| KnowledgeBaseWithDocCount
|
||||
| undefined
|
||||
const kb = knowledgeBases.find((k) => k.id === rowId) as KnowledgeBaseWithDocCount | undefined
|
||||
setActiveKnowledgeBase(kb ?? null)
|
||||
handleRowCtxMenu(e)
|
||||
},
|
||||
[handleRowCtxMenu]
|
||||
[knowledgeBases, handleRowCtxMenu]
|
||||
)
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
const kb = activeKnowledgeBaseRef.current
|
||||
if (!kb) return
|
||||
if (!activeKnowledgeBase) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await handleDeleteKnowledgeBase(kb.id)
|
||||
await handleDeleteKnowledgeBase(activeKnowledgeBase.id)
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [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]
|
||||
)
|
||||
}, [activeKnowledgeBase, handleDeleteKnowledgeBase])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
icon={Database}
|
||||
title='Knowledge Base'
|
||||
create={createAction}
|
||||
search={searchConfig}
|
||||
create={{
|
||||
label: 'New base',
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
disabled: userPermissions.canEdit !== true,
|
||||
}}
|
||||
search={{
|
||||
value: searchQuery,
|
||||
onChange: setSearchQuery,
|
||||
placeholder: 'Search knowledge bases...',
|
||||
}}
|
||||
defaultSort='created'
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
@@ -330,8 +216,8 @@ export function Knowledge() {
|
||||
isOpen={isListContextMenuOpen}
|
||||
position={listContextMenuPosition}
|
||||
onClose={closeListContextMenu}
|
||||
onAddKnowledgeBase={handleOpenCreateModal}
|
||||
disableAdd={!canEdit}
|
||||
onAddKnowledgeBase={handleAddKnowledgeBase}
|
||||
disableAdd={userPermissions.canEdit !== true}
|
||||
/>
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
@@ -339,17 +225,23 @@ export function Knowledge() {
|
||||
isOpen={isRowContextMenuOpen}
|
||||
position={rowContextMenuPosition}
|
||||
onClose={closeRowContextMenu}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onViewTags={handleViewTags}
|
||||
onCopyId={handleCopyId}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
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)}
|
||||
showOpenInNewTab
|
||||
showViewTags
|
||||
showEdit
|
||||
showDelete
|
||||
disableEdit={!canEdit}
|
||||
disableDelete={!canEdit}
|
||||
disableEdit={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -367,7 +259,10 @@ export function Knowledge() {
|
||||
{activeKnowledgeBase && (
|
||||
<DeleteKnowledgeBaseModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
knowledgeBaseName={activeKnowledgeBase.name}
|
||||
|
||||
@@ -1268,9 +1268,7 @@ export const NotificationSettings = memo(function NotificationSettings({
|
||||
<ModalHeader>Delete Notification</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)] text-caption'>
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove the notification and stop all deliveries.
|
||||
</span>{' '}
|
||||
This will permanently remove the notification and stop all deliveries.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -371,10 +371,8 @@ export function ApiKeys() {
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
Deleting{' '}
|
||||
<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='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span> will
|
||||
immediately revoke access for any integrations using it.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -404,10 +404,7 @@ export function BYOK() {
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
|
||||
</span>{' '}
|
||||
API key?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This workspace will revert to using platform hosted keys.
|
||||
</span>
|
||||
API key? This workspace will revert to using platform hosted keys.
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -366,9 +366,7 @@ export function Copilot() {
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{deleteKey?.name || 'Unnamed Key'}
|
||||
</span>{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
will immediately revoke access for any integrations using it.
|
||||
</span>{' '}
|
||||
will immediately revoke access for any integrations using it.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -164,10 +164,8 @@ 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`}?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove all data in {isSingleRow ? 'this row' : 'these rows'}.
|
||||
</span>{' '}
|
||||
{isSingleRow ? 'this row' : `these ${deleteCount} rows`}? This will permanently remove
|
||||
all data in {isSingleRow ? 'this row' : 'these rows'}.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -1809,9 +1809,6 @@ 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>
|
||||
@@ -1848,10 +1845,8 @@ 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>?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove all data in this column.
|
||||
</span>{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>? This
|
||||
will remove all data in this column.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -320,10 +320,8 @@ 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>?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All {activeTable?.rowCount} rows will be removed.
|
||||
</span>{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{activeTable?.name}</span>?
|
||||
All {activeTable?.rowCount} rows will be removed.{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
</span>
|
||||
|
||||
@@ -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-active)]'
|
||||
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)]'
|
||||
title='Add chat inputs to Start block'
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -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-active)]'
|
||||
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)]'
|
||||
title='Add required A2A input fields to Start block'
|
||||
onClick={handleAddA2AInputs}
|
||||
>
|
||||
|
||||
@@ -280,7 +280,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
|
||||
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn('pointer-events-none', className)}
|
||||
|
||||
@@ -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:bg-[var(--surface-active)] dark:bg-[var(--surface-5)]'
|
||||
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)]'
|
||||
>
|
||||
<div className='truncate pr-6 text-sm' title={file.name}>
|
||||
<span className='text-[var(--text-primary)]'>{truncateMiddle(file.name)}</span>
|
||||
|
||||
@@ -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:bg-[var(--surface-active)]'
|
||||
'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)]'
|
||||
)}
|
||||
>
|
||||
<span className='flex flex-1 items-center gap-2 truncate text-[var(--text-muted)]'>
|
||||
|
||||
@@ -1061,7 +1061,6 @@ try {
|
||||
setShowSchemaParams(false)
|
||||
}
|
||||
}}
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
@@ -1179,11 +1178,8 @@ try {
|
||||
<ModalHeader>Delete Custom Tool</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[var(--text-secondary)]'>
|
||||
<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>
|
||||
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>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -874,10 +874,7 @@ export const Panel = memo(function Panel() {
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{currentWorkflow?.name ?? 'this workflow'}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All associated blocks, executions, and configuration will be removed.
|
||||
</span>{' '}
|
||||
? All associated blocks, executions, and configuration will be removed.{' '}
|
||||
<span className='text-[var(--text-tertiary)]'>
|
||||
You can restore it from Recently Deleted in Settings.
|
||||
</span>
|
||||
|
||||
@@ -1822,7 +1822,7 @@ export function useWorkflowExecution() {
|
||||
try {
|
||||
const pointer = await loadExecutionPointer(reconnectWorkflowId)
|
||||
if (cleanupRan) return
|
||||
if (pointer?.executionId) {
|
||||
if (pointer && pointer.executionId) {
|
||||
executionId = pointer.executionId
|
||||
fromEventId = pointer.lastEventId
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type MouseEvent as ReactMouseEvent, useState } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { Folder, MoreHorizontal, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -12,7 +12,6 @@ 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'
|
||||
@@ -34,7 +33,6 @@ 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>
|
||||
@@ -58,9 +56,9 @@ interface CollapsedWorkflowFlyoutItemProps {
|
||||
onEditValueChange?: (value: string) => void
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
onEditBlur?: () => void
|
||||
onOpenInNewTab?: () => void
|
||||
onRename?: () => void
|
||||
canRename?: boolean
|
||||
onContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
|
||||
onMorePointerDown?: () => void
|
||||
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
|
||||
}
|
||||
|
||||
const EDIT_ROW_CLASS =
|
||||
@@ -70,12 +68,10 @@ function FlyoutMoreButton({
|
||||
ariaLabel,
|
||||
onPointerDown,
|
||||
onClick,
|
||||
isVisible,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
onPointerDown?: () => void
|
||||
onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
isVisible?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
@@ -83,10 +79,7 @@ function FlyoutMoreButton({
|
||||
aria-label={ariaLabel}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
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'
|
||||
)}
|
||||
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'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
@@ -161,7 +154,7 @@ export function CollapsedSidebarMenu({
|
||||
<button
|
||||
type='button'
|
||||
aria-label={ariaLabel}
|
||||
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover-hover:bg-[var(--surface-hover)]'
|
||||
className='mx-0.5 flex h-[30px] items-center rounded-[8px] px-2 hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
@@ -187,7 +180,6 @@ export function CollapsedSidebarMenu({
|
||||
export function CollapsedTaskFlyoutItem({
|
||||
task,
|
||||
isCurrentRoute,
|
||||
isMenuOpen = false,
|
||||
isEditing = false,
|
||||
editValue,
|
||||
inputRef,
|
||||
@@ -229,13 +221,12 @@ export function CollapsedTaskFlyoutItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div className='group relative mx-0.5'>
|
||||
<Link
|
||||
href={task.href}
|
||||
className={cn(
|
||||
'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)]'
|
||||
'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)]'
|
||||
)}
|
||||
onContextMenu={
|
||||
task.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, task.id) : undefined
|
||||
@@ -245,9 +236,7 @@ export function CollapsedTaskFlyoutItem({
|
||||
title={task.name}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
statusIndicatorClassName={
|
||||
!(isCurrentRoute || isMenuOpen) ? 'group-hover:hidden' : undefined
|
||||
}
|
||||
statusIndicatorClassName={!isCurrentRoute ? 'group-hover:hidden' : undefined}
|
||||
/>
|
||||
</Link>
|
||||
{showActions && (
|
||||
@@ -259,7 +248,6 @@ export function CollapsedTaskFlyoutItem({
|
||||
e.stopPropagation()
|
||||
onMoreClick?.(e, task.id)
|
||||
}}
|
||||
isVisible={isMenuOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -277,102 +265,62 @@ export function CollapsedWorkflowFlyoutItem({
|
||||
onEditValueChange,
|
||||
onEditKeyDown,
|
||||
onEditBlur,
|
||||
onOpenInNewTab,
|
||||
onRename,
|
||||
canRename = true,
|
||||
onContextMenu,
|
||||
onMorePointerDown,
|
||||
onMoreClick,
|
||||
}: CollapsedWorkflowFlyoutItemProps) {
|
||||
const hasActions = !!onOpenInNewTab || !!onRename
|
||||
const [actionsOpen, setActionsOpen] = useState(false)
|
||||
const showActions = !!onMoreClick
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div className='group relative mx-0.5'>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'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)]'
|
||||
'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)]'
|
||||
)}
|
||||
onContextMenu={
|
||||
hasActions
|
||||
? (e) => {
|
||||
e.preventDefault()
|
||||
setActionsOpen(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, workflow) : undefined}
|
||||
>
|
||||
<WorkflowColorSwatch color={workflow.color} />
|
||||
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
|
||||
</Link>
|
||||
{hasActions && (
|
||||
<DropdownMenuSub
|
||||
open={actionsOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActionsOpen(false)
|
||||
{showActions && (
|
||||
<FlyoutMoreButton
|
||||
ariaLabel='Workflow options'
|
||||
onPointerDown={onMorePointerDown}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMoreClick?.(e, workflow)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
@@ -390,9 +338,9 @@ export function CollapsedFolderItems({
|
||||
onEditValueChange,
|
||||
onEditKeyDown,
|
||||
onEditBlur,
|
||||
onWorkflowOpenInNewTab,
|
||||
onWorkflowRename,
|
||||
canRenameWorkflow,
|
||||
onWorkflowContextMenu,
|
||||
onWorkflowMorePointerDown,
|
||||
onWorkflowMoreClick,
|
||||
}: {
|
||||
nodes: FolderTreeNode[]
|
||||
workflowsByFolder: Record<string, WorkflowMetadata[]>
|
||||
@@ -405,9 +353,9 @@ export function CollapsedFolderItems({
|
||||
onEditValueChange?: (value: string) => void
|
||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
onEditBlur?: () => void
|
||||
onWorkflowOpenInNewTab?: (workflow: WorkflowMetadata) => void
|
||||
onWorkflowRename?: (workflow: WorkflowMetadata) => void
|
||||
canRenameWorkflow?: boolean
|
||||
onWorkflowContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
|
||||
onWorkflowMorePointerDown?: () => void
|
||||
onWorkflowMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -426,7 +374,7 @@ export function CollapsedFolderItems({
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={folder.id}>
|
||||
<DropdownMenuSubTrigger className='focus:bg-[var(--surface-hover)] data-[state=open]:bg-[var(--surface-hover)]'>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Folder className='h-[14px] w-[14px]' />
|
||||
<span className='truncate'>{folder.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
@@ -443,9 +391,9 @@ export function CollapsedFolderItems({
|
||||
onEditValueChange={onEditValueChange}
|
||||
onEditKeyDown={onEditKeyDown}
|
||||
onEditBlur={onEditBlur}
|
||||
onWorkflowOpenInNewTab={onWorkflowOpenInNewTab}
|
||||
onWorkflowRename={onWorkflowRename}
|
||||
canRenameWorkflow={canRenameWorkflow}
|
||||
onWorkflowContextMenu={onWorkflowContextMenu}
|
||||
onWorkflowMorePointerDown={onWorkflowMorePointerDown}
|
||||
onWorkflowMoreClick={onWorkflowMoreClick}
|
||||
/>
|
||||
{folderWorkflows.map((workflow) => (
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
@@ -460,11 +408,9 @@ export function CollapsedFolderItems({
|
||||
onEditValueChange={onEditValueChange}
|
||||
onEditKeyDown={onEditKeyDown}
|
||||
onEditBlur={onEditBlur}
|
||||
onOpenInNewTab={
|
||||
onWorkflowOpenInNewTab ? () => onWorkflowOpenInNewTab(workflow) : undefined
|
||||
}
|
||||
onRename={onWorkflowRename ? () => onWorkflowRename(workflow) : undefined}
|
||||
canRename={canRenameWorkflow}
|
||||
onContextMenu={onWorkflowContextMenu}
|
||||
onMorePointerDown={onWorkflowMorePointerDown}
|
||||
onMoreClick={onWorkflowMoreClick}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
|
||||
@@ -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-hover)]'
|
||||
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<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,8 +259,7 @@ 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]',
|
||||
!active && 'hover-hover:bg-[var(--surface-hover)]',
|
||||
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover:bg-[var(--surface-active)]',
|
||||
active && 'bg-[var(--surface-active)]'
|
||||
)
|
||||
const content = (
|
||||
|
||||
@@ -75,10 +75,7 @@ export function DeleteModal({
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All associated blocks, executions, and configuration will be removed.
|
||||
</span>
|
||||
? All associated blocks, executions, and configuration will be removed.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -86,21 +83,12 @@ export function DeleteModal({
|
||||
return (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<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>
|
||||
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? 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>
|
||||
</>
|
||||
)
|
||||
return 'Are you sure you want to delete this workflow? All associated blocks, executions, and configuration will be removed.'
|
||||
}
|
||||
|
||||
if (itemType === 'folder') {
|
||||
@@ -111,11 +99,8 @@ export function DeleteModal({
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove all workflows, logs, and knowledge bases within these
|
||||
folders.
|
||||
</span>
|
||||
? This will permanently remove all workflows, logs, and knowledge bases within these
|
||||
folders.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -123,21 +108,12 @@ export function DeleteModal({
|
||||
return (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<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>
|
||||
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? 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>
|
||||
</>
|
||||
)
|
||||
return 'Are you sure you want to delete this folder? This will permanently remove all associated workflows, logs, and knowledge bases.'
|
||||
}
|
||||
|
||||
if (itemType === 'task') {
|
||||
@@ -148,10 +124,7 @@ export function DeleteModal({
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{displayNames.length} tasks
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove all conversation history.
|
||||
</span>
|
||||
? This will permanently remove all conversation history.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -159,21 +132,12 @@ export function DeleteModal({
|
||||
return (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<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>
|
||||
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? 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>
|
||||
</>
|
||||
)
|
||||
return 'Are you sure you want to delete this task? This will permanently remove all conversation history.'
|
||||
}
|
||||
|
||||
if (itemType === 'mixed') {
|
||||
@@ -184,23 +148,12 @@ export function DeleteModal({
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will permanently remove all selected workflows and folders, including their
|
||||
contents.
|
||||
</span>
|
||||
? 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>
|
||||
</>
|
||||
)
|
||||
return 'Are you sure you want to delete the selected items? This will permanently remove all selected workflows and folders, including their contents.'
|
||||
}
|
||||
|
||||
// workspace type
|
||||
@@ -208,22 +161,12 @@ export function DeleteModal({
|
||||
return (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<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>
|
||||
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? 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 'Are you sure you want to delete this workspace? This will permanently remove all associated workflows, folders, logs, and knowledge bases.'
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -448,11 +448,8 @@ 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',
|
||||
!isSelected &&
|
||||
!isContextMenuOpen &&
|
||||
!isAnyDragActive &&
|
||||
'hover-hover:bg-[var(--surface-hover)]',
|
||||
(isSelected || isContextMenuOpen) && 'bg-[var(--surface-active)]',
|
||||
!isAnyDragActive && 'hover-hover:bg-[var(--surface-active)]',
|
||||
isSelected ? 'bg-[var(--surface-active)]' : '',
|
||||
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
@@ -514,9 +511,8 @@ 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',
|
||||
!isAnyDragActive && 'group-hover:opacity-100',
|
||||
isContextMenuOpen && 'opacity-100'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
|
||||
@@ -386,11 +386,8 @@ 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 || isContextMenuOpen) && 'bg-[var(--surface-active)]',
|
||||
!active &&
|
||||
!isContextMenuOpen &&
|
||||
!isAnyDragActive &&
|
||||
'hover-hover:bg-[var(--surface-hover)]',
|
||||
active && 'bg-[var(--surface-active)]',
|
||||
!active && !isAnyDragActive && 'hover-hover:bg-[var(--surface-active)]',
|
||||
isSelected && selectedWorkflows.size > 1 && !active && 'bg-[var(--surface-active)]',
|
||||
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
|
||||
)}
|
||||
@@ -448,9 +445,8 @@ 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',
|
||||
!isAnyDragActive && 'group-hover:opacity-100',
|
||||
isContextMenuOpen && 'opacity-100'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
Plus,
|
||||
UserPlus,
|
||||
} from '@/components/emcn'
|
||||
import { getDisplayPlanName, isFree } from '@/lib/billing/plan-helpers'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { getDisplayPlanName } from '@/lib/billing/plan-helpers'
|
||||
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'
|
||||
@@ -28,7 +27,6 @@ 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')
|
||||
|
||||
@@ -133,17 +131,9 @@ export function WorkspaceHeader({
|
||||
}, [])
|
||||
|
||||
const { isInvitationsDisabled } = usePermissionConfig()
|
||||
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)
|
||||
const { data: subscriptionResponse } = useSubscriptionData()
|
||||
const rawPlanName = getDisplayPlanName(subscriptionResponse?.data?.plan)
|
||||
const planDisplayName = rawPlanName.includes('for Teams') ? rawPlanName : `${rawPlanName} Plan`
|
||||
|
||||
// Listen for open-invite-modal event from context menu
|
||||
useEffect(() => {
|
||||
@@ -405,30 +395,11 @@ export function WorkspaceHeader({
|
||||
>
|
||||
{workspaceInitial}
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
|
||||
{activeWorkspace?.name || 'Loading...'}
|
||||
</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>
|
||||
)}
|
||||
<span className='text-[var(--text-tertiary)] text-xs'>{planDisplayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -492,9 +463,7 @@ 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',
|
||||
workspace.id !== workspaceId &&
|
||||
'hover-hover:bg-[var(--surface-hover)]',
|
||||
'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)]',
|
||||
workspace.id === workspaceId && 'bg-[var(--surface-active)]'
|
||||
)}
|
||||
onClick={() => onWorkspaceSwitch(workspace)}
|
||||
@@ -513,7 +482,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 group-hover:opacity-100'
|
||||
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'
|
||||
>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</button>
|
||||
@@ -527,7 +496,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-hover)] 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-active)] disabled:pointer-events-none disabled:opacity-50'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
@@ -545,7 +514,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-hover)]'
|
||||
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)]'
|
||||
onClick={() => {
|
||||
setIsInviteModalOpen(true)
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
|
||||
@@ -113,7 +113,6 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
isSelected,
|
||||
isActive,
|
||||
isUnread,
|
||||
isMenuOpen,
|
||||
showCollapsedTooltips,
|
||||
onMultiSelectClick,
|
||||
onContextMenu,
|
||||
@@ -125,7 +124,6 @@ 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
|
||||
@@ -138,10 +136,8 @@ 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',
|
||||
!(isCurrentRoute || isSelected || isMenuOpen) &&
|
||||
'hover-hover:bg-[var(--surface-hover)]',
|
||||
(isCurrentRoute || isSelected || isMenuOpen) && 'bg-[var(--surface-active)]'
|
||||
'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)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (task.id === 'new') return
|
||||
@@ -181,10 +177,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
e.stopPropagation()
|
||||
onMoreClick(e, task.id)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 group-hover:opacity-100',
|
||||
isMenuOpen && 'opacity-100'
|
||||
)}
|
||||
className='flex h-[18px] w-[18px] items-center justify-center rounded-sm opacity-0 hover-hover:bg-[var(--surface-7)] group-hover:opacity-100'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
@@ -221,8 +214,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'
|
||||
const hoverClasses = !active ? 'hover-hover:bg-[var(--surface-hover)]' : ''
|
||||
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 activeClasses = active ? 'bg-[var(--surface-active)]' : ''
|
||||
|
||||
const content = (
|
||||
@@ -237,7 +230,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
href={item.href}
|
||||
data-item-id={item.id}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={`${baseClasses} ${hoverClasses} ${activeClasses}`}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onClick={
|
||||
item.onClick
|
||||
? (e) => {
|
||||
@@ -256,7 +249,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
type='button'
|
||||
data-item-id={item.id}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={`${baseClasses} ${hoverClasses} ${activeClasses}`}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{content}
|
||||
@@ -317,11 +310,6 @@ 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)
|
||||
|
||||
@@ -497,11 +485,6 @@ 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()
|
||||
@@ -519,7 +502,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const handleTaskContextMenu = useCallback(
|
||||
(e: React.MouseEvent, taskId: string) => {
|
||||
captureTaskSelection(taskId)
|
||||
setMenuOpenTaskId(taskId)
|
||||
tasksHover.setLocked(true)
|
||||
preventTaskDismiss()
|
||||
handleTaskContextMenuBase(e)
|
||||
@@ -541,7 +523,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
}
|
||||
tasksHover.setLocked(true)
|
||||
captureTaskSelection(taskId)
|
||||
setMenuOpenTaskId(taskId)
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
handleTaskContextMenuBase({
|
||||
preventDefault: () => {},
|
||||
@@ -559,6 +540,77 @@ 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,
|
||||
})
|
||||
@@ -657,14 +709,14 @@ export const Sidebar = memo(function Sidebar() {
|
||||
icon: Settings,
|
||||
href: getSettingsHref(),
|
||||
onClick: () => {
|
||||
if (!isCollapsedRef.current) {
|
||||
if (!isCollapsed) {
|
||||
setSidebarWidth(SIDEBAR_WIDTH.MIN)
|
||||
}
|
||||
navigateToSettings()
|
||||
},
|
||||
},
|
||||
],
|
||||
[navigateToSettings, getSettingsHref, setSidebarWidth]
|
||||
[workspaceId, navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth]
|
||||
)
|
||||
|
||||
const handleStartTour = useCallback(() => {
|
||||
@@ -758,12 +810,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
const navigateToPage = useCallback(
|
||||
(path: string) => {
|
||||
if (!isCollapsedRef.current) {
|
||||
if (!isCollapsed) {
|
||||
setSidebarWidth(SIDEBAR_WIDTH.MIN)
|
||||
}
|
||||
router.push(path)
|
||||
},
|
||||
[setSidebarWidth, router]
|
||||
[isCollapsed, setSidebarWidth, router]
|
||||
)
|
||||
|
||||
const handleConfirmDeleteTasks = useCallback(() => {
|
||||
@@ -802,6 +854,10 @@ export const Sidebar = memo(function Sidebar() {
|
||||
itemType: 'workflow',
|
||||
onSave: async (workflowIdToRename, name) => {
|
||||
await updateWorkflow(workflowIdToRename, { name })
|
||||
collapsedWorkflowContextMenuRef.current = {
|
||||
workflowId: workflowIdToRename,
|
||||
workflowName: name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -810,8 +866,8 @@ export const Sidebar = memo(function Sidebar() {
|
||||
}, [isTaskContextMenuOpen, taskFlyoutRename.editingId, tasksHover.setLocked])
|
||||
|
||||
useEffect(() => {
|
||||
workflowsHover.setLocked(!!workflowFlyoutRename.editingId)
|
||||
}, [workflowFlyoutRename.editingId, workflowsHover.setLocked])
|
||||
workflowsHover.setLocked(isCollapsedWorkflowContextMenuOpen || !!workflowFlyoutRename.editingId)
|
||||
}, [isCollapsedWorkflowContextMenuOpen, workflowFlyoutRename.editingId, workflowsHover.setLocked])
|
||||
|
||||
const handleTaskOpenInNewTab = useCallback(() => {
|
||||
const { taskIds: ids } = contextMenuSelectionRef.current
|
||||
@@ -841,20 +897,22 @@ export const Sidebar = memo(function Sidebar() {
|
||||
taskFlyoutRename.startRename({ id: taskId, name: task.name })
|
||||
}, [taskFlyoutRename, tasks, tasksHover])
|
||||
|
||||
const handleCollapsedWorkflowOpenInNewTab = useCallback(
|
||||
(workflow: { id: string }) => {
|
||||
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
const handleCollapsedWorkflowOpenInNewTab = useCallback(() => {
|
||||
const workflow = collapsedWorkflowContextMenuRef.current
|
||||
if (!workflow) return
|
||||
window.open(
|
||||
`/workspace/${workspaceId}/w/${workflow.workflowId}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}, [workspaceId])
|
||||
|
||||
const handleCollapsedWorkflowRename = useCallback(
|
||||
(workflow: { id: string; name: string }) => {
|
||||
workflowsHover.setLocked(true)
|
||||
workflowFlyoutRename.startRename({ id: workflow.id, name: workflow.name })
|
||||
},
|
||||
[workflowFlyoutRename, workflowsHover]
|
||||
)
|
||||
const handleStartCollapsedWorkflowRename = useCallback(() => {
|
||||
const workflow = collapsedWorkflowContextMenuRef.current
|
||||
if (!workflow) return
|
||||
workflowsHover.setLocked(true)
|
||||
workflowFlyoutRename.startRename({ id: workflow.workflowId, name: workflow.workflowName })
|
||||
}, [workflowFlyoutRename, workflowsHover])
|
||||
|
||||
const [hasOverflowTop, setHasOverflowTop] = useState(false)
|
||||
const [hasOverflowBottom, setHasOverflowBottom] = useState(false)
|
||||
@@ -1006,88 +1064,6 @@ 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
|
||||
@@ -1174,7 +1150,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-hover:bg-[var(--surface-hover)]'
|
||||
className='sidebar-collapse-hide sidebar-collapse-remove flex h-[30px] items-center rounded-[8px] px-1.5 hover:bg-[var(--surface-active)]'
|
||||
tabIndex={isCollapsed ? -1 : 0}
|
||||
>
|
||||
{brand.logoUrl ? (
|
||||
@@ -1196,7 +1172,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-hover:bg-[var(--surface-hover)]'
|
||||
className='sidebar-collapse-show group absolute left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
|
||||
aria-label='Expand sidebar'
|
||||
tabIndex={isCollapsed ? 0 : -1}
|
||||
>
|
||||
@@ -1228,7 +1204,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-hover)]',
|
||||
'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)]',
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
|
||||
)}
|
||||
aria-label='Collapse sidebar'
|
||||
@@ -1280,7 +1256,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}
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
item={item}
|
||||
active={item.href ? !!pathname?.startsWith(item.href) : false}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
@@ -1297,7 +1273,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<div className='flex flex-col gap-0.5 px-2'>
|
||||
{workspaceNavItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.id}
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
item={item}
|
||||
active={item.href ? !!pathname?.startsWith(item.href) : false}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
@@ -1325,8 +1301,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-hover)]'
|
||||
onClick={handleNewTask}
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
|
||||
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
@@ -1340,11 +1316,16 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={tasksCollapsedIcon}
|
||||
icon={
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
hover={tasksHover}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-1.5'
|
||||
primaryAction={tasksPrimaryAction}
|
||||
primaryAction={{
|
||||
label: 'New task',
|
||||
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
|
||||
}}
|
||||
>
|
||||
{tasksLoading ? (
|
||||
<DropdownMenuItem disabled>
|
||||
@@ -1357,14 +1338,13 @@ 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={handleTaskRenameBlur}
|
||||
onEditBlur={() => void taskFlyoutRename.saveRename()}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
@@ -1395,7 +1375,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
value={taskFlyoutRename.value}
|
||||
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
|
||||
onKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onBlur={handleTaskRenameBlur}
|
||||
onBlur={() => void taskFlyoutRename.saveRename()}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
/>
|
||||
</div>
|
||||
@@ -1410,7 +1390,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
@@ -1422,8 +1401,8 @@ export const Sidebar = memo(function Sidebar() {
|
||||
{tasks.length > visibleTaskCount && (
|
||||
<button
|
||||
type='button'
|
||||
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)]'
|
||||
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)]'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
|
||||
<span className='font-base'>See more</span>
|
||||
@@ -1450,7 +1429,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-hover)]'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{isImporting || isCreatingFolder ? (
|
||||
@@ -1490,7 +1469,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-hover)]'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-active)]'
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow || !canEdit}
|
||||
>
|
||||
@@ -1506,11 +1485,23 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={workflowsCollapsedIcon}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
hover={workflowsHover}
|
||||
ariaLabel='Workflows'
|
||||
className='mt-1.5'
|
||||
primaryAction={workflowsPrimaryAction}
|
||||
primaryAction={{
|
||||
label: 'New workflow',
|
||||
onSelect: handleCreateWorkflow,
|
||||
}}
|
||||
>
|
||||
{workflowsLoading && regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
@@ -1532,10 +1523,10 @@ export const Sidebar = memo(function Sidebar() {
|
||||
isRenamingWorkflow={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
|
||||
onWorkflowRename={handleCollapsedWorkflowRename}
|
||||
canRenameWorkflow={canEdit}
|
||||
onEditBlur={() => void workflowFlyoutRename.saveRename()}
|
||||
onWorkflowContextMenu={handleCollapsedWorkflowContextMenu}
|
||||
onWorkflowMorePointerDown={handleCollapsedWorkflowMorePointerDown}
|
||||
onWorkflowMoreClick={handleCollapsedWorkflowMoreClick}
|
||||
/>
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
@@ -1549,10 +1540,10 @@ export const Sidebar = memo(function Sidebar() {
|
||||
isRenaming={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
|
||||
onRename={() => handleCollapsedWorkflowRename(workflow)}
|
||||
canRename={canEdit}
|
||||
onEditBlur={() => void workflowFlyoutRename.saveRename()}
|
||||
onContextMenu={handleCollapsedWorkflowContextMenu}
|
||||
onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
|
||||
onMoreClick={handleCollapsedWorkflowMoreClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -1594,7 +1585,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-hover:bg-[var(--surface-hover)]'
|
||||
className='group mx-0.5 flex h-[30px] items-center gap-2 rounded-[8px] px-2 text-[14px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<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)]'>
|
||||
@@ -1610,11 +1601,15 @@ export const Sidebar = memo(function Sidebar() {
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent align='start' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem onSelect={handleOpenDocs}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
Docs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleOpenHelpFromMenu}>
|
||||
<DropdownMenuItem onSelect={() => setIsHelpModalOpen(true)}>
|
||||
<HelpCircle className='h-[14px] w-[14px]' />
|
||||
Report an issue
|
||||
</DropdownMenuItem>
|
||||
@@ -1627,7 +1622,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
{footerItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.id}
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
item={item}
|
||||
active={false}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
@@ -1671,10 +1666,26 @@ 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={handleCloseTaskDeleteModal}
|
||||
onClose={() => setIsTaskDeleteModalOpen(false)}
|
||||
onConfirm={handleConfirmDeleteTasks}
|
||||
isDeleting={deleteTaskMutation.isPending || deleteTasksMutation.isPending}
|
||||
itemType='task'
|
||||
@@ -1721,7 +1732,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
ref={workspaceFileInputRef}
|
||||
type='file'
|
||||
accept='.zip'
|
||||
style={hiddenStyle}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleWorkspaceFileChange}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -56,7 +56,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder,
|
||||
deduplicate: true,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -176,7 +176,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
color: workflowColor,
|
||||
workspaceId: newWorkspace.id,
|
||||
folderId: targetFolderId,
|
||||
deduplicate: true,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -303,7 +303,6 @@ async function runWorkflowExecution({
|
||||
export type ScheduleExecutionPayload = {
|
||||
scheduleId: string
|
||||
workflowId: string
|
||||
workspaceId?: string
|
||||
executionId?: string
|
||||
requestId?: string
|
||||
correlation?: AsyncExecutionCorrelation
|
||||
|
||||
@@ -36,7 +36,6 @@ export function buildWorkflowCorrelation(
|
||||
export type WorkflowExecutionPayload = {
|
||||
workflowId: string
|
||||
userId: string
|
||||
workspaceId?: string
|
||||
input?: any
|
||||
triggerType?: CoreTriggerType
|
||||
executionId?: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { db, workflowExecutionLogs } from '@sim/db'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
account,
|
||||
workspaceNotificationDelivery,
|
||||
@@ -17,14 +17,11 @@ 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'
|
||||
@@ -35,7 +32,6 @@ 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
|
||||
@@ -490,170 +486,12 @@ async function updateDeliveryStatus(
|
||||
export interface NotificationDeliveryParams {
|
||||
deliveryId: string
|
||||
subscriptionId: string
|
||||
workspaceId: string
|
||||
notificationType: 'webhook' | 'email' | 'slack'
|
||||
log: WorkflowExecutionLog
|
||||
alertConfig?: AlertConfig
|
||||
}
|
||||
|
||||
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> {
|
||||
export async function executeNotificationDelivery(params: NotificationDeliveryParams) {
|
||||
const { deliveryId, subscriptionId, notificationType, log, alertConfig } = params
|
||||
|
||||
try {
|
||||
@@ -666,7 +504,7 @@ export async function executeNotificationDelivery(
|
||||
if (!subscription || !subscription.active) {
|
||||
logger.warn(`Subscription ${subscriptionId} not found or inactive`)
|
||||
await updateDeliveryStatus(deliveryId, 'failed', 'Subscription not found or inactive')
|
||||
return { status: 'failed' }
|
||||
return
|
||||
}
|
||||
|
||||
const claimed = await db
|
||||
@@ -691,7 +529,7 @@ export async function executeNotificationDelivery(
|
||||
|
||||
if (claimed.length === 0) {
|
||||
logger.info(`Delivery ${deliveryId} not claimable`)
|
||||
return { status: 'skipped' }
|
||||
return
|
||||
}
|
||||
|
||||
const attempts = claimed[0].attempts
|
||||
@@ -701,7 +539,7 @@ export async function executeNotificationDelivery(
|
||||
if (!payload) {
|
||||
await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was archived or deleted')
|
||||
logger.info(`Skipping delivery ${deliveryId} - workflow was archived or deleted`)
|
||||
return { status: 'failed' }
|
||||
return
|
||||
}
|
||||
|
||||
let result: { success: boolean; status?: number; error?: string }
|
||||
@@ -723,35 +561,39 @@ export async function executeNotificationDelivery(
|
||||
if (result.success) {
|
||||
await updateDeliveryStatus(deliveryId, 'success', undefined, result.status)
|
||||
logger.info(`${notificationType} notification delivered successfully`, { deliveryId })
|
||||
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)
|
||||
} 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)
|
||||
|
||||
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}`,
|
||||
{
|
||||
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`, {
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
case 'linear_list_notifications':
|
||||
return {
|
||||
...baseParams,
|
||||
first: params.first ? Number(params.first) : undefined,
|
||||
after: params.after?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
// 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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
// 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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
// 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?.trim() || undefined,
|
||||
after: params.after,
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -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 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'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)]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -520,7 +520,7 @@ const Combobox = memo(
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
'w-full pr-10 font-medium transition-colors',
|
||||
'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)]',
|
||||
(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(--surface-active)]',
|
||||
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
|
||||
'hover-hover:bg-[var(--border-1)]',
|
||||
(isHighlighted || isSelected) && 'bg-[var(--border-1)]',
|
||||
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(--surface-active)]',
|
||||
!multiSelectValues?.length && 'bg-[var(--surface-active)]'
|
||||
'hover-hover:bg-[var(--border-1)]',
|
||||
!multiSelectValues?.length && 'bg-[var(--border-1)]'
|
||||
)}
|
||||
>
|
||||
<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(--surface-active)]',
|
||||
(isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
|
||||
'hover-hover:bg-[var(--border-1)]',
|
||||
(isHighlighted || isSelected) && 'bg-[var(--border-1)]',
|
||||
option.disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'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)]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -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 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 focus-visible:border-[var(--text-muted)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -116,8 +116,8 @@ const STYLES = {
|
||||
/** Interactive state styles: default, secondary (brand), inverted (dark bg in light mode) */
|
||||
states: {
|
||||
default: {
|
||||
active: 'bg-[var(--surface-active)]',
|
||||
hover: 'hover-hover:bg-[var(--surface-active)]',
|
||||
active: 'bg-[var(--border-1)]',
|
||||
hover: 'hover-hover:bg-[var(--border-1)]',
|
||||
},
|
||||
secondary: {
|
||||
active: 'bg-[var(--brand-secondary)] text-white [&_svg]:text-white',
|
||||
|
||||
@@ -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 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 focus-visible:border-[var(--text-muted)] resize-none overflow-auto disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -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 disabled:cursor-not-allowed disabled:opacity-50 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 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',
|
||||
{
|
||||
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)]'
|
||||
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)]'
|
||||
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)]'
|
||||
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)]'
|
||||
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-active)] hover-hover:text-[var(--text-primary)]'
|
||||
: '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)]'
|
||||
)}
|
||||
>
|
||||
{period}
|
||||
|
||||
@@ -1287,9 +1287,7 @@ 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>?
|
||||
<span className='text-[var(--text-error)]'>
|
||||
All members will be removed from this group.
|
||||
</span>{' '}
|
||||
All members will be removed from this group.{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -164,7 +164,6 @@ interface CreateWorkflowVariables {
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
id?: string
|
||||
deduplicate?: boolean
|
||||
}
|
||||
|
||||
interface CreateWorkflowResult {
|
||||
@@ -301,8 +300,7 @@ export function useCreateWorkflow() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
|
||||
const { workspaceId, name, description, color, folderId, sortOrder, id, deduplicate } =
|
||||
variables
|
||||
const { workspaceId, name, description, color, folderId, sortOrder, id } = variables
|
||||
|
||||
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
|
||||
|
||||
@@ -317,7 +315,6 @@ export function useCreateWorkflow() {
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
sortOrder,
|
||||
deduplicate,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -14,20 +14,6 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isPro,
|
||||
isTeam,
|
||||
} from '@/lib/billing/plan-helpers'
|
||||
import { parseEnterpriseSubscriptionMetadata } from '@/lib/billing/types'
|
||||
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
export const ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'past_due'] as const
|
||||
@@ -80,15 +80,27 @@ 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 = parseEnterpriseSubscriptionMetadata(subscription.metadata)
|
||||
if (metadata) {
|
||||
return metadata.seats
|
||||
const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | null
|
||||
if (isEnterpriseMetadata(metadata)) {
|
||||
return Number.parseInt(metadata.seats, 10)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -2,47 +2,18 @@
|
||||
* Billing System Types
|
||||
* Centralized type definitions for the billing system
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
export const enterpriseSubscriptionMetadataSchema = z.object({
|
||||
plan: z
|
||||
.string()
|
||||
.transform((v) => v.toLowerCase())
|
||||
.pipe(z.literal('enterprise')),
|
||||
export interface EnterpriseSubscriptionMetadata {
|
||||
plan: 'enterprise'
|
||||
// The referenceId must be provided in Stripe metadata to link to the organization
|
||||
// This gets stored in the subscription.referenceId column
|
||||
referenceId: z.string().min(1),
|
||||
referenceId: string
|
||||
// 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: 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
|
||||
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
|
||||
}
|
||||
|
||||
export interface UsageData {
|
||||
|
||||
@@ -6,10 +6,26 @@ 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 { parseEnterpriseSubscriptionMetadata } from '../types'
|
||||
import type { EnterpriseSubscriptionMetadata } 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
|
||||
|
||||
@@ -47,16 +63,37 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
|
||||
throw new Error('Unable to resolve referenceId for subscription')
|
||||
}
|
||||
|
||||
const enterpriseMetadata = parseEnterpriseSubscriptionMetadata(metadata)
|
||||
if (!enterpriseMetadata) {
|
||||
if (!isEnterpriseMetadata(metadata)) {
|
||||
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 }
|
||||
|
||||
const { seats, monthlyPrice } = 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')
|
||||
}
|
||||
|
||||
// Get the first subscription item which contains the period information
|
||||
const referenceItem = stripeSubscription.items?.data?.[0]
|
||||
@@ -80,7 +117,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: metadata as Record<string, unknown>,
|
||||
metadata: metadataJson,
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* @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')
|
||||
})
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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> {}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export { BullMQJobQueue } from './bullmq'
|
||||
export { DatabaseJobQueue } from './database'
|
||||
export { RedisJobQueue } from './redis'
|
||||
export { TriggerDevJobQueue } from './trigger-dev'
|
||||
|
||||
176
apps/sim/lib/core/async-jobs/backends/redis.test.ts
Normal file
176
apps/sim/lib/core/async-jobs/backends/redis.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @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)
|
||||
})
|
||||
})
|
||||
146
apps/sim/lib/core/async-jobs/backends/redis.ts
Normal file
146
apps/sim/lib/core/async-jobs/backends/redis.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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,15 +11,16 @@ let cachedInlineBackend: JobQueueBackend | null = null
|
||||
|
||||
/**
|
||||
* Determines which async backend to use based on environment configuration.
|
||||
* Follows the fallback chain: trigger.dev → bullmq → database
|
||||
* Follows the fallback chain: trigger.dev → redis → database
|
||||
*/
|
||||
export function getAsyncBackendType(): AsyncBackendType {
|
||||
if (isTriggerDevEnabled) {
|
||||
return 'trigger-dev'
|
||||
}
|
||||
|
||||
if (isBullMQEnabled()) {
|
||||
return 'bullmq'
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
return 'redis'
|
||||
}
|
||||
|
||||
return 'database'
|
||||
@@ -42,9 +43,13 @@ export async function getJobQueue(): Promise<JobQueueBackend> {
|
||||
cachedBackend = new TriggerDevJobQueue()
|
||||
break
|
||||
}
|
||||
case 'bullmq': {
|
||||
const { BullMQJobQueue } = await import('@/lib/core/async-jobs/backends/bullmq')
|
||||
cachedBackend = new BullMQJobQueue()
|
||||
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)
|
||||
break
|
||||
}
|
||||
case 'database': {
|
||||
@@ -57,10 +62,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -72,19 +73,20 @@ export function getCurrentBackendType(): AsyncBackendType | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a job queue backend that bypasses Trigger.dev (BullMQ -> Database).
|
||||
* Used for execution paths that must avoid Trigger.dev cold starts.
|
||||
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
|
||||
* Used for non-polling webhooks that should always execute inline.
|
||||
*/
|
||||
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
|
||||
if (cachedInlineBackend) {
|
||||
return cachedInlineBackend
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
let type: string
|
||||
if (isBullMQEnabled()) {
|
||||
const { BullMQJobQueue } = await import('@/lib/core/async-jobs/backends/bullmq')
|
||||
cachedInlineBackend = new BullMQJobQueue()
|
||||
type = 'bullmq'
|
||||
if (redis) {
|
||||
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
|
||||
cachedInlineBackend = new RedisJobQueue(redis)
|
||||
type = 'redis'
|
||||
} else {
|
||||
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
|
||||
cachedInlineBackend = new DatabaseJobQueue()
|
||||
@@ -96,15 +98,11 @@ export async function getInlineJobQueue(): Promise<JobQueueBackend> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if jobs should be executed inline in-process.
|
||||
* Database fallback is the only mode that still relies on inline execution.
|
||||
* Checks if jobs should be executed inline (fire-and-forget).
|
||||
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
|
||||
*/
|
||||
export function shouldExecuteInline(): boolean {
|
||||
return getAsyncBackendType() === 'database'
|
||||
}
|
||||
|
||||
export function shouldUseBullMQ(): boolean {
|
||||
return isBullMQEnabled()
|
||||
return getAsyncBackendType() !== 'trigger-dev'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,6 @@ export {
|
||||
getJobQueue,
|
||||
resetJobQueueCache,
|
||||
shouldExecuteInline,
|
||||
shouldUseBullMQ,
|
||||
} from './config'
|
||||
export type {
|
||||
AsyncBackendType,
|
||||
|
||||
@@ -62,10 +62,6 @@ export interface JobMetadata {
|
||||
export interface EnqueueOptions {
|
||||
maxAttempts?: number
|
||||
metadata?: JobMetadata
|
||||
jobId?: string
|
||||
priority?: number
|
||||
name?: string
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,4 +95,4 @@ export interface JobQueueBackend {
|
||||
markJobFailed(jobId: string, error: string): Promise<void>
|
||||
}
|
||||
|
||||
export type AsyncBackendType = 'trigger-dev' | 'bullmq' | 'database'
|
||||
export type AsyncBackendType = 'trigger-dev' | 'redis' | 'database'
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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: {} } : {}),
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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'
|
||||
@@ -1,196 +0,0 @@
|
||||
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 ?? {},
|
||||
}
|
||||
}
|
||||
@@ -183,11 +183,6 @@ 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)
|
||||
@@ -199,10 +194,6 @@ 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
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* @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()
|
||||
})
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
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}`)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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'
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @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')
|
||||
})
|
||||
})
|
||||
@@ -1,505 +0,0 @@
|
||||
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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* @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
Reference in New Issue
Block a user