mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 15:38:00 -05:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a061e38a | ||
|
|
ab71fcfc49 | ||
|
|
864622c1dc | ||
|
|
8668622d66 | ||
|
|
53dd277cfe | ||
|
|
0e8e8c7a47 | ||
|
|
47da5eb6e8 | ||
|
|
37dcde2afc | ||
|
|
e31627c7c2 | ||
|
|
57c98d86ba | ||
|
|
0f7dfe084a | ||
|
|
afc1632830 | ||
|
|
56eee2c2d2 | ||
|
|
fc558a8eef | ||
|
|
c68cadfb84 | ||
|
|
95d93a2532 | ||
|
|
59b2023124 | ||
|
|
a672f17136 | ||
|
|
1de59668e4 | ||
|
|
26243b99e8 | ||
|
|
fce1423d05 | ||
|
|
3656d3d7ad | ||
|
|
581929bc01 | ||
|
|
11d8188415 | ||
|
|
36c98d18e9 |
@@ -91,4 +91,31 @@ Copilot is your in-editor assistant that helps you build, understand, and improv
|
||||
>
|
||||
<div className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
</Cards>
|
||||
|
||||
## Billing and Cost Calculation
|
||||
|
||||
### How Costs Are Calculated
|
||||
|
||||
Copilot usage is billed per token from the underlying LLM:
|
||||
|
||||
- **Input tokens**: billed at the provider's base rate (**at-cost**)
|
||||
- **Output tokens**: billed at **1.5×** the provider's base output rate
|
||||
|
||||
```javascript
|
||||
copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000
|
||||
```
|
||||
|
||||
| Component | Rate Applied |
|
||||
|----------|----------------------|
|
||||
| Input | inputPrice |
|
||||
| Output | outputPrice × 1.5 |
|
||||
|
||||
<Callout type="warning">
|
||||
Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. See <a href="/execution/advanced#cost-calculation">Logging and Cost Calculation</a> for background and examples.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ const UpdateCostSchema = z.object({
|
||||
input: z.number().min(0, 'Input tokens must be a non-negative number'),
|
||||
output: z.number().min(0, 'Output tokens must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
multiplier: z.number().min(0),
|
||||
inputMultiplier: z.number().min(0),
|
||||
outputMultiplier: z.number().min(0),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -75,14 +76,15 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, input, output, model, multiplier } = validation.data
|
||||
const { userId, input, output, model, inputMultiplier, outputMultiplier } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
input,
|
||||
output,
|
||||
model,
|
||||
multiplier,
|
||||
inputMultiplier,
|
||||
outputMultiplier,
|
||||
})
|
||||
|
||||
const finalPromptTokens = input
|
||||
@@ -95,7 +97,8 @@ export async function POST(req: NextRequest) {
|
||||
finalPromptTokens,
|
||||
finalCompletionTokens,
|
||||
false,
|
||||
multiplier
|
||||
inputMultiplier,
|
||||
outputMultiplier
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Cost calculation result`, {
|
||||
@@ -104,7 +107,8 @@ export async function POST(req: NextRequest) {
|
||||
promptTokens: finalPromptTokens,
|
||||
completionTokens: finalCompletionTokens,
|
||||
totalTokens: totalTokens,
|
||||
multiplier,
|
||||
inputMultiplier,
|
||||
outputMultiplier,
|
||||
costResult,
|
||||
})
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -289,6 +290,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -341,6 +343,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -430,6 +433,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'ask',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -378,6 +378,7 @@ export async function POST(req: NextRequest) {
|
||||
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
...(agentContexts.length > 0 && { context: agentContexts }),
|
||||
...(actualChatId ? { chatId: actualChatId } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,23 +12,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const BodySchema = z
|
||||
.object({
|
||||
// Do NOT send id; messageId is the unique correlator
|
||||
userId: z.string().optional(),
|
||||
chatId: z.string().uuid().optional(),
|
||||
messageId: z.string().optional(),
|
||||
depth: z.number().int().nullable().optional(),
|
||||
maxEnabled: z.boolean().nullable().optional(),
|
||||
createdAt: z.union([z.string().datetime(), z.date()]).optional(),
|
||||
diffCreated: z.boolean().nullable().optional(),
|
||||
diffAccepted: z.boolean().nullable().optional(),
|
||||
duration: z.number().int().nullable().optional(),
|
||||
inputTokens: z.number().int().nullable().optional(),
|
||||
outputTokens: z.number().int().nullable().optional(),
|
||||
aborted: z.boolean().nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
const BodySchema = z.object({
|
||||
messageId: z.string(),
|
||||
diffCreated: z.boolean(),
|
||||
diffAccepted: z.boolean(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
@@ -43,15 +31,15 @@ export async function POST(req: NextRequest) {
|
||||
if (!parsed.success) {
|
||||
return createBadRequestResponse('Invalid request body for copilot stats')
|
||||
}
|
||||
const body = parsed.data as any
|
||||
|
||||
// Build outgoing payload for Sim Agent; do not include id
|
||||
const { messageId, diffCreated, diffAccepted } = parsed.data as any
|
||||
|
||||
// Build outgoing payload for Sim Agent with only required fields
|
||||
const payload: Record<string, any> = {
|
||||
...body,
|
||||
userId: body.userId || userId,
|
||||
createdAt: body.createdAt || new Date().toISOString(),
|
||||
messageId,
|
||||
diffCreated,
|
||||
diffAccepted,
|
||||
}
|
||||
payload.id = undefined
|
||||
|
||||
const agentRes = await fetch(`${SIM_AGENT_API_URL}/api/stats`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -585,6 +585,7 @@ export async function POST(req: NextRequest) {
|
||||
const useE2B =
|
||||
e2bEnabled &&
|
||||
!useLocalVM &&
|
||||
!isCustomTool &&
|
||||
(lang === CodeLanguage.JavaScript || lang === CodeLanguage.Python)
|
||||
|
||||
if (useE2B) {
|
||||
|
||||
@@ -339,7 +339,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
role,
|
||||
workspaceInvitationsWithNames,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
@@ -352,7 +352,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/${orgInvitation.id}`,
|
||||
email
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
@@ -18,7 +19,7 @@ import { decryptSecret } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { subscription, userStats, workflow, workflowSchedule } from '@/db/schema'
|
||||
import { userStats, workflow, workflowSchedule } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
@@ -108,19 +109,15 @@ export async function GET() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check rate limits for scheduled execution
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, workflowRecord.userId))
|
||||
.limit(1)
|
||||
// Check rate limits for scheduled execution (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(workflowRecord.userId)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
const subscriptionPlan = (userSubscription?.plan || 'free') as SubscriptionPlan
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
workflowRecord.userId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
'schedule',
|
||||
false // schedules are always sync
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, subscription } from '@/db/schema'
|
||||
import { apiKey as apiKeyTable } from '@/db/schema'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
|
||||
const logger = createLogger('RateLimitAPI')
|
||||
@@ -33,31 +34,22 @@ export async function GET(request: NextRequest) {
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, authenticatedUserId))
|
||||
.limit(1)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as
|
||||
| 'free'
|
||||
| 'pro'
|
||||
| 'team'
|
||||
| 'enterprise'
|
||||
// Get user subscription (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const isApiAuth = !session?.user?.id
|
||||
const triggerType = isApiAuth ? 'api' : 'manual'
|
||||
|
||||
const syncStatus = await rateLimiter.getRateLimitStatus(
|
||||
const syncStatus = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false
|
||||
)
|
||||
const asyncStatus = await rateLimiter.getRateLimitStatus(
|
||||
const asyncStatus = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
true
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
} from '@/lib/webhooks/utils'
|
||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||
import { db } from '@/db'
|
||||
import { subscription, webhook, workflow } from '@/db/schema'
|
||||
import { webhook, workflow } from '@/db/schema'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
import type { SubscriptionPlan } from '@/services/queue/types'
|
||||
|
||||
@@ -247,21 +248,19 @@ export async function POST(
|
||||
}
|
||||
|
||||
// --- PHASE 3: Rate limiting for webhook execution ---
|
||||
let isEnterprise = false
|
||||
try {
|
||||
// Get user subscription for rate limiting
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, foundWorkflow.userId))
|
||||
.limit(1)
|
||||
// Get user subscription for rate limiting (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(foundWorkflow.userId)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
const subscriptionPlan = (userSubscription?.plan || 'free') as SubscriptionPlan
|
||||
isEnterprise = subscriptionPlan === 'enterprise'
|
||||
|
||||
// Check async rate limits (webhooks are processed asynchronously)
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
foundWorkflow.userId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
'webhook',
|
||||
true // isAsync = true for webhook execution
|
||||
)
|
||||
@@ -333,7 +332,7 @@ export async function POST(
|
||||
// Continue processing - better to risk usage limit bypass than fail webhook
|
||||
}
|
||||
|
||||
// --- PHASE 5: Queue webhook execution (trigger.dev or direct based on env) ---
|
||||
// --- PHASE 5: Queue webhook execution (trigger.dev or direct based on plan/env) ---
|
||||
try {
|
||||
const payload = {
|
||||
webhookId: foundWebhook.id,
|
||||
@@ -346,7 +345,9 @@ export async function POST(
|
||||
blockId: foundWebhook.blockId,
|
||||
}
|
||||
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
// Enterprise users always execute directly, others check TRIGGER_DEV_ENABLED env
|
||||
// Note: isEnterprise was already determined during rate limiting phase
|
||||
const useTrigger = !isEnterprise && isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
const handle = await tasks.trigger('webhook-execution', payload)
|
||||
@@ -358,8 +359,9 @@ export async function POST(
|
||||
void executeWebhookJob(payload).catch((error) => {
|
||||
logger.error(`[${requestId}] Direct webhook execution failed`, error)
|
||||
})
|
||||
const reason = isEnterprise ? 'Enterprise plan' : 'Trigger.dev disabled'
|
||||
logger.info(
|
||||
`[${requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
|
||||
`[${requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (${reason})`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
@@ -97,7 +98,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
isDeployed: false,
|
||||
collaborators: [],
|
||||
runCount: 0,
|
||||
variables: source.variables || {},
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
const remapped: Record<string, Variable> = {}
|
||||
for (const [, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
const newVarId = crypto.randomUUID()
|
||||
remapped[newVarId] = {
|
||||
...variable,
|
||||
id: newVarId,
|
||||
workflowId: newWorkflowId,
|
||||
}
|
||||
}
|
||||
return remapped
|
||||
})(),
|
||||
isPublished: false,
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
@@ -46,6 +46,11 @@ describe('Workflow Execution API Route', () => {
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
checkRateLimitWithSubscription: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
})),
|
||||
RateLimitError: class RateLimitError extends Error {
|
||||
constructor(
|
||||
@@ -66,6 +71,13 @@ describe('Workflow Execution API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn().mockResolvedValue({
|
||||
plan: 'free',
|
||||
referenceId: 'user-id',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
subscription: {
|
||||
plan: 'plan',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { subscription, userStats } from '@/db/schema'
|
||||
import { userStats } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import {
|
||||
@@ -374,19 +375,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
try {
|
||||
// Check rate limits BEFORE entering queue for GET requests
|
||||
if (triggerType === 'api') {
|
||||
// Get user subscription
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, validation.workflow.userId))
|
||||
.limit(1)
|
||||
// Get user subscription (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(validation.workflow.userId)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
const subscriptionPlan = (userSubscription?.plan || 'free') as SubscriptionPlan
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
validation.workflow.userId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false // isAsync = false for sync calls
|
||||
)
|
||||
@@ -505,20 +502,17 @@ export async function POST(
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.referenceId, authenticatedUserId))
|
||||
.limit(1)
|
||||
// Get user subscription (checks both personal and org subscriptions)
|
||||
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
|
||||
|
||||
const subscriptionPlan = (subscriptionRecord?.plan || 'free') as SubscriptionPlan
|
||||
const subscriptionPlan = (userSubscription?.plan || 'free') as SubscriptionPlan
|
||||
|
||||
if (isAsync) {
|
||||
try {
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
'api',
|
||||
true // isAsync = true
|
||||
)
|
||||
@@ -580,9 +574,9 @@ export async function POST(
|
||||
|
||||
try {
|
||||
const rateLimiter = new RateLimiter()
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimit(
|
||||
const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription(
|
||||
authenticatedUserId,
|
||||
subscriptionPlan,
|
||||
userSubscription,
|
||||
triggerType,
|
||||
false // isAsync = false for sync calls
|
||||
)
|
||||
|
||||
@@ -64,7 +64,12 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
BILLING_ENABLED: false,
|
||||
},
|
||||
isTruthy: (value: any) =>
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
}))
|
||||
|
||||
mockTransaction = vi.fn()
|
||||
@@ -378,6 +383,16 @@ describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn(),
|
||||
}))
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
BILLING_ENABLED: false,
|
||||
},
|
||||
isTruthy: (value: any) =>
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
}))
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
workspaceInvitation: { id: 'id' },
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { render } from '@react-email/render'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/email/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
@@ -48,6 +52,14 @@ export async function GET(
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitationId}?error=invalid-token`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -234,3 +246,87 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.id, invitationId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
|
||||
return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ws = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const newToken = randomUUID()
|
||||
const newExpiresAt = new Date()
|
||||
newExpiresAt.setDate(newExpiresAt.getDate() + 7)
|
||||
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() })
|
||||
.where(eq(workspaceInvitation.id, invitationId))
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}`
|
||||
|
||||
const emailHtml = await render(
|
||||
WorkspaceInvitationEmail({
|
||||
workspaceName: ws.name,
|
||||
inviterName: session.user.name || session.user.email || 'A user',
|
||||
invitationLink,
|
||||
})
|
||||
)
|
||||
|
||||
const result = await sendEmail({
|
||||
to: invitation.email,
|
||||
subject: `You've been invited to join "${ws.name}" on Sim`,
|
||||
html: emailHtml,
|
||||
from: getFromEmailAddress(),
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send invitation email. Please try again.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error resending workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function getErrorMessage(reason: string): string {
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return 'This invitation was sent to a different email address. Please log in with the correct account or contact the person who invited you.'
|
||||
return 'This invitation was sent to a different email address. Please log in with the correct account.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
|
||||
@@ -1101,21 +1101,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
* Get workflows in the exact order they appear in the sidebar
|
||||
*/
|
||||
const getSidebarOrderedWorkflows = () => {
|
||||
// Get and sort regular workflows by last modified (newest first)
|
||||
// Get and sort regular workflows by creation date (newest first) for stable ordering
|
||||
const regularWorkflows = Object.values(workflows)
|
||||
.filter((workflow) => workflow.workspaceId === workspaceId)
|
||||
.filter((workflow) => workflow.marketplaceData?.status !== 'temp')
|
||||
.sort((a, b) => {
|
||||
const dateA =
|
||||
a.lastModified instanceof Date
|
||||
? a.lastModified.getTime()
|
||||
: new Date(a.lastModified).getTime()
|
||||
const dateB =
|
||||
b.lastModified instanceof Date
|
||||
? b.lastModified.getTime()
|
||||
: new Date(b.lastModified).getTime()
|
||||
return dateB - dateA
|
||||
})
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
|
||||
// Group workflows by folder
|
||||
const workflowsByFolder = regularWorkflows.reduce(
|
||||
|
||||
@@ -393,11 +393,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const workspaceFiltered = items.filter(
|
||||
(w: any) => w.workspaceId === workspaceId || !w.workspaceId
|
||||
)
|
||||
// Sort by last modified/updated (newest first), matching sidebar behavior
|
||||
// Sort by creation date (newest first) for stable ordering, matching sidebar behavior
|
||||
const sorted = [...workspaceFiltered].sort((a: any, b: any) => {
|
||||
const ta = new Date(a.lastModified || a.updatedAt || a.createdAt || 0).getTime()
|
||||
const tb = new Date(b.lastModified || b.updatedAt || b.createdAt || 0).getTime()
|
||||
return tb - ta
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return dateB - dateA // Newest first for stable ordering
|
||||
})
|
||||
setWorkflows(
|
||||
sorted.map((w: any) => ({
|
||||
|
||||
@@ -81,15 +81,15 @@ export function WandPromptBar({
|
||||
<div
|
||||
ref={promptBarRef}
|
||||
className={cn(
|
||||
'-top-20 absolute right-0 left-0',
|
||||
'rounded-xl border bg-background shadow-lg',
|
||||
'-translate-y-3 absolute right-0 bottom-full left-0 gap-2',
|
||||
'rounded-lg border bg-background shadow-lg',
|
||||
'z-9999999 transition-all duration-150',
|
||||
isExiting ? 'opacity-0' : 'opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2 p-2'>
|
||||
<div className={cn('status-indicator ml-1', isStreaming && 'streaming')} />
|
||||
<div className={cn('status-indicator ml-2 self-center', isStreaming && 'streaming')} />
|
||||
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
@@ -98,7 +98,7 @@ export function WandPromptBar({
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'rounded-xl border-0 text-foreground text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
isStreaming && 'text-primary',
|
||||
isStreaming && 'text-foreground/70',
|
||||
(isLoading || isStreaming) && 'loading-placeholder'
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -111,11 +111,6 @@ export function WandPromptBar({
|
||||
disabled={isLoading || isStreaming}
|
||||
autoFocus={!isStreaming}
|
||||
/>
|
||||
{isStreaming && (
|
||||
<div className='pointer-events-none absolute inset-0 h-full w-full overflow-hidden'>
|
||||
<div className='shimmer-effect' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -141,14 +136,6 @@ export function WandPromptBar({
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes smoke-pulse {
|
||||
0%,
|
||||
@@ -164,8 +151,8 @@ export function WandPromptBar({
|
||||
|
||||
.status-indicator {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
@@ -183,36 +170,20 @@ export function WandPromptBar({
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
hsl(var(--primary) / 0.7) 0%,
|
||||
hsl(var(--primary) / 0.2) 60%,
|
||||
hsl(var(--primary) / 0.9) 0%,
|
||||
hsl(var(--primary) / 0.4) 60%,
|
||||
transparent 80%
|
||||
);
|
||||
animation: smoke-pulse 1.8s ease-in-out infinite;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.shimmer-effect {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.4) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
.dark .status-indicator.streaming::before {
|
||||
background: #6b7280;
|
||||
opacity: 0.9;
|
||||
animation: smoke-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .shimmer-effect {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(50, 50, 50, 0) 0%,
|
||||
rgba(80, 80, 80, 0.4) 50%,
|
||||
rgba(50, 50, 50, 0) 100%
|
||||
);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -404,10 +404,8 @@ IMPORTANT FORMATTING RULES:
|
||||
<div
|
||||
className={cn(
|
||||
'group relative min-h-[100px] rounded-md border border-input bg-background font-mono text-sm transition-colors',
|
||||
isConnecting && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
!isValidJson && 'border-destructive bg-destructive/10'
|
||||
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
title={!isValidJson ? 'Invalid JSON' : undefined}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
@@ -419,7 +417,7 @@ IMPORTANT FORMATTING RULES:
|
||||
onClick={isPromptVisible ? hidePromptInline : showPromptInline}
|
||||
disabled={isAiLoading || isAiStreaming}
|
||||
aria-label='Generate code with AI'
|
||||
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
|
||||
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
|
||||
>
|
||||
<Wand2 className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
@@ -426,7 +426,7 @@ export function LongInput({
|
||||
}
|
||||
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
|
||||
aria-label='Generate content with AI'
|
||||
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
|
||||
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
|
||||
>
|
||||
<Wand2 className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
@@ -436,7 +436,7 @@ export function ShortInput({
|
||||
}
|
||||
disabled={wandHook.isLoading || wandHook.isStreaming || disabled}
|
||||
aria-label='Generate content with AI'
|
||||
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
|
||||
className='h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow'
|
||||
>
|
||||
<Wand2 className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'prismjs/components/prism-json'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CodeEditorProps {
|
||||
@@ -213,19 +214,16 @@ export function CodeEditor({
|
||||
)}
|
||||
>
|
||||
{showWandButton && onWandClick && (
|
||||
<button
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onWandClick}
|
||||
disabled={wandButtonDisabled}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-transparent bg-muted/80 p-0 text-foreground shadow-sm transition-all duration-200',
|
||||
'hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow',
|
||||
'opacity-0 transition-opacity group-hover:opacity-100',
|
||||
wandButtonDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
aria-label='Generate with AI'
|
||||
className='absolute top-2 right-3 z-10 h-8 w-8 rounded-full border border-transparent bg-muted/80 text-muted-foreground opacity-0 shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow group-hover:opacity-100'
|
||||
>
|
||||
<Wand2 className='h-4 w-4' />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!showWandButton && code.split('\n').length > 5 && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Code, FileJson, Trash2, X } from 'lucide-react'
|
||||
import { AlertTriangle, Code, FileJson, Trash2, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -934,11 +934,18 @@ try {
|
||||
<Label htmlFor='json-schema' className='font-medium'>
|
||||
JSON Schema
|
||||
</Label>
|
||||
{schemaError &&
|
||||
!schemaGeneration.isStreaming && ( // Hide schema error while streaming
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className='h-4 w-4 cursor-pointer text-destructive' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p>Invalid JSON</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{schemaError &&
|
||||
!schemaGeneration.isStreaming && ( // Hide schema error while streaming
|
||||
<div className='ml-4 break-words text-red-600 text-sm'>{schemaError}</div>
|
||||
)}
|
||||
</div>
|
||||
<CodeEditor
|
||||
value={jsonSchema}
|
||||
@@ -975,7 +982,6 @@ try {
|
||||
}`}
|
||||
minHeight='360px'
|
||||
className={cn(
|
||||
schemaError && !schemaGeneration.isStreaming ? 'border-red-500' : '',
|
||||
(schemaGeneration.isLoading || schemaGeneration.isStreaming) &&
|
||||
'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
|
||||
@@ -125,6 +125,12 @@ export function useSubBlockValue<T = any>(
|
||||
return
|
||||
}
|
||||
|
||||
const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!currentActiveWorkflowId) {
|
||||
logger.warn('No active workflow ID when setting value', { blockId, subBlockId })
|
||||
return
|
||||
}
|
||||
|
||||
// Use deep comparison to avoid unnecessary updates for complex objects
|
||||
if (!isEqual(valueRef.current, newValue)) {
|
||||
valueRef.current = newValue
|
||||
@@ -147,10 +153,10 @@ export function useSubBlockValue<T = any>(
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId || '']: {
|
||||
...state.workflowValues[activeWorkflowId || ''],
|
||||
[currentActiveWorkflowId]: {
|
||||
...state.workflowValues[currentActiveWorkflowId],
|
||||
[blockId]: {
|
||||
...state.workflowValues[activeWorkflowId || '']?.[blockId],
|
||||
...state.workflowValues[currentActiveWorkflowId]?.[blockId],
|
||||
[subBlockId]: newValue,
|
||||
},
|
||||
},
|
||||
@@ -194,7 +200,6 @@ export function useSubBlockValue<T = any>(
|
||||
isStreaming,
|
||||
emitValue,
|
||||
isShowingDiff,
|
||||
activeWorkflowId,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -867,52 +867,41 @@ const WorkflowContent = React.memo(() => {
|
||||
[project, isPointInLoopNodeWrapper, getNodes]
|
||||
)
|
||||
|
||||
// Track when workflow is fully ready for rendering
|
||||
// Initialize workflow when it exists in registry and isn't active
|
||||
useEffect(() => {
|
||||
const currentId = params.workflowId as string
|
||||
if (!currentId || !workflows[currentId]) return
|
||||
|
||||
// Reset workflow ready state when workflow changes
|
||||
if (activeWorkflowId !== currentId) {
|
||||
setIsWorkflowReady(false)
|
||||
return
|
||||
// Clear diff and set as active
|
||||
const { clearDiff } = useWorkflowDiffStore.getState()
|
||||
clearDiff()
|
||||
setActiveWorkflow(currentId)
|
||||
}
|
||||
}, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow])
|
||||
|
||||
// Check if we have the necessary data to render the workflow
|
||||
const hasActiveWorkflow = activeWorkflowId === currentId
|
||||
const hasWorkflowInRegistry = Boolean(workflows[currentId])
|
||||
const isNotLoading = !isLoading
|
||||
// Track when workflow is ready for rendering
|
||||
useEffect(() => {
|
||||
const currentId = params.workflowId as string
|
||||
|
||||
// Workflow is ready when:
|
||||
// 1. We have an active workflow that matches the URL
|
||||
// 2. The workflow exists in the registry
|
||||
// 3. Workflows are not currently loading
|
||||
if (hasActiveWorkflow && hasWorkflowInRegistry && isNotLoading) {
|
||||
// Add a small delay to ensure blocks state has settled
|
||||
const timeoutId = setTimeout(() => {
|
||||
setIsWorkflowReady(true)
|
||||
}, 100)
|
||||
const shouldBeReady =
|
||||
activeWorkflowId === currentId && Boolean(workflows[currentId]) && !isLoading
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
setIsWorkflowReady(false)
|
||||
setIsWorkflowReady(shouldBeReady)
|
||||
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
|
||||
|
||||
// Init workflow
|
||||
// Handle navigation and validation
|
||||
useEffect(() => {
|
||||
const validateAndNavigate = async () => {
|
||||
const workflowIds = Object.keys(workflows)
|
||||
const currentId = params.workflowId as string
|
||||
|
||||
// Check if workflows have been initially loaded at least once
|
||||
// This prevents premature navigation decisions on page refresh
|
||||
if (!hasWorkflowsInitiallyLoaded()) {
|
||||
logger.info('Waiting for initial workflow load...')
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for both initialization and workflow loading to complete
|
||||
if (isLoading) {
|
||||
logger.info('Workflows still loading, waiting...')
|
||||
// Wait for initial load to complete before making navigation decisions
|
||||
if (!hasWorkflowsInitiallyLoaded() || isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -952,24 +941,10 @@ const WorkflowContent = React.memo(() => {
|
||||
router.replace(`/workspace/${currentWorkflow.workspaceId}/w/${currentId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current active workflow state
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
|
||||
if (activeWorkflowId !== currentId) {
|
||||
// Clear workflow diff store when switching workflows
|
||||
const { clearDiff } = useWorkflowDiffStore.getState()
|
||||
clearDiff()
|
||||
|
||||
setActiveWorkflow(currentId)
|
||||
} else {
|
||||
// Don't reset variables cache if we're not actually switching workflows
|
||||
setActiveWorkflow(currentId)
|
||||
}
|
||||
}
|
||||
|
||||
validateAndNavigate()
|
||||
}, [params.workflowId, workflows, isLoading, setActiveWorkflow, createWorkflow, router])
|
||||
}, [params.workflowId, workflows, isLoading, workspaceId, router])
|
||||
|
||||
// Transform blocks and loops into ReactFlow nodes
|
||||
const nodes = useMemo(() => {
|
||||
|
||||
@@ -79,7 +79,11 @@ export function UsageHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={isBlocked ? 100 : progress} className='h-2' />
|
||||
<Progress
|
||||
value={isBlocked ? 100 : progress}
|
||||
className='h-2'
|
||||
indicatorClassName='bg-black dark:bg-white'
|
||||
/>
|
||||
|
||||
{isBlocked && (
|
||||
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
|
||||
|
||||
@@ -100,9 +100,11 @@ export function TeamSeatsOverview({
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>Seats</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
|
||||
</span>
|
||||
{!checkEnterprisePlan(subscriptionData) ? (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground'>{usedSeats} used</span>
|
||||
|
||||
@@ -92,8 +92,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar with color: yellow for warning, red for full/blocked */}
|
||||
<Progress value={isBlocked ? 100 : progressPercentage} className='h-2' />
|
||||
{/* Progress Bar */}
|
||||
<Progress
|
||||
value={isBlocked ? 100 : progressPercentage}
|
||||
className='h-2'
|
||||
indicatorClassName='bg-black dark:bg-white'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import { Loader2, RotateCw, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -60,6 +60,7 @@ interface PermissionsTableProps {
|
||||
onPermissionChange: (userId: string, permissionType: PermissionType) => void
|
||||
onRemoveMember?: (userId: string, email: string) => void
|
||||
onRemoveInvitation?: (invitationId: string, email: string) => void
|
||||
onResendInvitation?: (invitationId: string, email: string) => void
|
||||
disabled?: boolean
|
||||
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
|
||||
isSaving?: boolean
|
||||
@@ -67,6 +68,9 @@ interface PermissionsTableProps {
|
||||
permissionsLoading: boolean
|
||||
pendingInvitations: UserPermissions[]
|
||||
isPendingInvitationsLoading: boolean
|
||||
resendingInvitationIds?: Record<string, boolean>
|
||||
resentInvitationIds?: Record<string, boolean>
|
||||
resendCooldowns?: Record<string, number>
|
||||
}
|
||||
|
||||
interface PendingInvitation {
|
||||
@@ -159,13 +163,18 @@ PermissionSelector.displayName = 'PermissionSelector'
|
||||
|
||||
const PermissionsTableSkeleton = React.memo(() => (
|
||||
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
|
||||
<div className='flex items-center justify-between gap-2 py-2'>
|
||||
{/* Email skeleton - matches the actual email span dimensions */}
|
||||
<Skeleton className='h-5 w-40' />
|
||||
|
||||
{/* Permission selector skeleton - matches PermissionSelector exact height */}
|
||||
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, idx) => (
|
||||
<div key={idx} className='flex items-center justify-between gap-2 py-2'>
|
||||
<Skeleton className='h-5 w-40' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-[30px] w-32 flex-shrink-0 rounded-[12px]' />
|
||||
<div className='flex w-10 items-center gap-1 sm:w-12'>
|
||||
<Skeleton className='h-4 w-4 rounded' />
|
||||
<Skeleton className='h-4 w-4 rounded' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -183,6 +192,10 @@ const PermissionsTable = ({
|
||||
permissionsLoading,
|
||||
pendingInvitations,
|
||||
isPendingInvitationsLoading,
|
||||
onResendInvitation,
|
||||
resendingInvitationIds,
|
||||
resentInvitationIds,
|
||||
resendCooldowns,
|
||||
}: PermissionsTableProps) => {
|
||||
const { data: session } = useSession()
|
||||
const userPerms = useUserPermissionsContext()
|
||||
@@ -309,8 +322,21 @@ const PermissionsTable = ({
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
|
||||
{isPendingInvitation && (
|
||||
<span className='inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
|
||||
Sent
|
||||
<span className='inline-flex items-center gap-1 rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
|
||||
{resendingInvitationIds &&
|
||||
user.invitationId &&
|
||||
resendingInvitationIds[user.invitationId] ? (
|
||||
<>
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
<span>Sending...</span>
|
||||
</>
|
||||
) : resentInvitationIds &&
|
||||
user.invitationId &&
|
||||
resentInvitationIds[user.invitationId] ? (
|
||||
<span>Resent</span>
|
||||
) : (
|
||||
<span>Sent</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
@@ -321,7 +347,7 @@ const PermissionsTable = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permission selector and remove button container */}
|
||||
{/* Permission selector and fixed-width action area to keep rows aligned */}
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
<PermissionSelector
|
||||
value={user.permissionType}
|
||||
@@ -335,8 +361,45 @@ const PermissionsTable = ({
|
||||
className='w-auto'
|
||||
/>
|
||||
|
||||
{/* X button with consistent spacing - always reserve space */}
|
||||
<div className='flex h-4 w-4 items-center justify-center'>
|
||||
{/* Fixed-width action area so selector stays inline across rows */}
|
||||
<div className='flex h-4 w-10 items-center justify-center gap-1 sm:w-12'>
|
||||
{isPendingInvitation &&
|
||||
currentUserIsAdmin &&
|
||||
user.invitationId &&
|
||||
onResendInvitation && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='inline-flex'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => onResendInvitation(user.invitationId!, user.email)}
|
||||
disabled={
|
||||
disabled ||
|
||||
isSaving ||
|
||||
resendingInvitationIds?.[user.invitationId!] ||
|
||||
(resendCooldowns && resendCooldowns[user.invitationId!] > 0)
|
||||
}
|
||||
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
|
||||
>
|
||||
{resendingInvitationIds?.[user.invitationId!] ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<RotateCw className='h-3.5 w-3.5' />
|
||||
)}
|
||||
<span className='sr-only'>Resend invite</span>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{resendCooldowns?.[user.invitationId!]
|
||||
? `Resend in ${resendCooldowns[user.invitationId!]}s`
|
||||
: 'Resend invite'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{((canShowRemoveButton && onRemoveMember) ||
|
||||
(isPendingInvitation &&
|
||||
currentUserIsAdmin &&
|
||||
@@ -408,6 +471,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
email: string
|
||||
} | null>(null)
|
||||
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
|
||||
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
|
||||
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
|
||||
const [resentInvitationIds, setResentInvitationIds] = useState<Record<string, boolean>>({})
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -748,6 +814,72 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
setInvitationToRemove(null)
|
||||
}, [])
|
||||
|
||||
const handleResendInvitation = useCallback(
|
||||
async (invitationId: string, email: string) => {
|
||||
if (!workspaceId || !userPerms.canAdmin) return
|
||||
|
||||
const secondsLeft = resendCooldowns[invitationId]
|
||||
if (secondsLeft && secondsLeft > 0) return
|
||||
|
||||
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to resend invitation')
|
||||
}
|
||||
|
||||
setSuccessMessage(`Invitation resent to ${email}`)
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
|
||||
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
|
||||
setTimeout(() => {
|
||||
setResentInvitationIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
return next
|
||||
})
|
||||
}, 4000)
|
||||
} catch (error) {
|
||||
logger.error('Error resending invitation:', error)
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Failed to resend invitation. Please try again.'
|
||||
setErrorMessage(errorMsg)
|
||||
} finally {
|
||||
setResendingInvitationIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
return next
|
||||
})
|
||||
// Start 60s cooldown
|
||||
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
|
||||
const interval = setInterval(() => {
|
||||
setResendCooldowns((prev) => {
|
||||
const current = prev[invitationId]
|
||||
if (current === undefined) return prev
|
||||
if (current <= 1) {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
clearInterval(interval)
|
||||
return next
|
||||
}
|
||||
return { ...prev, [invitationId]: current - 1 }
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
[workspaceId, userPerms.canAdmin, resendCooldowns]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) {
|
||||
@@ -989,6 +1121,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
onPermissionChange={handlePermissionChange}
|
||||
onRemoveMember={handleRemoveMemberClick}
|
||||
onRemoveInvitation={handleRemoveInvitationClick}
|
||||
onResendInvitation={handleResendInvitation}
|
||||
disabled={isSubmitting || isSaving || isRemovingMember || isRemovingInvitation}
|
||||
existingUserPermissionChanges={existingUserPermissionChanges}
|
||||
isSaving={isSaving}
|
||||
@@ -996,6 +1129,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
permissionsLoading={permissionsLoading}
|
||||
pendingInvitations={pendingInvitations}
|
||||
isPendingInvitationsLoading={isPendingInvitationsLoading}
|
||||
resendingInvitationIds={resendingInvitationIds}
|
||||
resentInvitationIds={resentInvitationIds}
|
||||
resendCooldowns={resendCooldowns}
|
||||
/>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -691,21 +691,13 @@ export function Sidebar() {
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by last modified date (newest first)
|
||||
const sortByLastModified = (a: WorkflowMetadata, b: WorkflowMetadata) => {
|
||||
const dateA =
|
||||
a.lastModified instanceof Date
|
||||
? a.lastModified.getTime()
|
||||
: new Date(a.lastModified).getTime()
|
||||
const dateB =
|
||||
b.lastModified instanceof Date
|
||||
? b.lastModified.getTime()
|
||||
: new Date(b.lastModified).getTime()
|
||||
return dateB - dateA
|
||||
// Sort by creation date (newest first) for stable ordering
|
||||
const sortByCreatedAt = (a: WorkflowMetadata, b: WorkflowMetadata) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
}
|
||||
|
||||
regular.sort(sortByLastModified)
|
||||
temp.sort(sortByLastModified)
|
||||
regular.sort(sortByCreatedAt)
|
||||
temp.sort(sortByCreatedAt)
|
||||
}
|
||||
|
||||
return { regularWorkflows: regular, tempWorkflows: temp }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const router = useRouter()
|
||||
const { workflows, isLoading, loadWorkflows } = useWorkflowRegistry()
|
||||
const { workflows, isLoading, loadWorkflows, setActiveWorkflow } = useWorkflowRegistry()
|
||||
const [hasInitialized, setHasInitialized] = useState(false)
|
||||
|
||||
const params = useParams()
|
||||
@@ -45,9 +45,14 @@ export default function WorkflowsPage() {
|
||||
|
||||
// If we have valid workspace workflows, redirect to the first one
|
||||
if (workspaceWorkflows.length > 0) {
|
||||
router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0]}`)
|
||||
// Ensure the workflow is set as active before redirecting
|
||||
// This prevents the empty canvas issue on first login
|
||||
const firstWorkflowId = workspaceWorkflows[0]
|
||||
setActiveWorkflow(firstWorkflowId).then(() => {
|
||||
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
|
||||
})
|
||||
}
|
||||
}, [hasInitialized, isLoading, workflows, workspaceId, router])
|
||||
}, [hasInitialized, isLoading, workflows, workspaceId, router, setActiveWorkflow])
|
||||
|
||||
// Always show loading state until redirect happens
|
||||
// There should always be a default workflow, so we never show "no workflows found"
|
||||
|
||||
@@ -4,21 +4,24 @@ import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className='h-full w-full flex-1 bg-primary transition-all'
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, ProgressProps>(
|
||||
({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn('h-full w-full flex-1 bg-primary transition-all', indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
)
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
|
||||
@@ -327,105 +327,97 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
}
|
||||
})
|
||||
|
||||
// Shared function to rehydrate workflow stores
|
||||
const rehydrateWorkflowStores = async (
|
||||
workflowId: string,
|
||||
workflowState: any,
|
||||
source: 'copilot' | 'workflow-state'
|
||||
) => {
|
||||
// Import stores dynamically
|
||||
const [
|
||||
{ useOperationQueueStore },
|
||||
{ useWorkflowRegistry },
|
||||
{ useWorkflowStore },
|
||||
{ useSubBlockStore },
|
||||
] = await Promise.all([
|
||||
import('@/stores/operation-queue/store'),
|
||||
import('@/stores/workflows/registry/store'),
|
||||
import('@/stores/workflows/workflow/store'),
|
||||
import('@/stores/workflows/subblock/store'),
|
||||
])
|
||||
|
||||
// Only proceed if this is the active workflow
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
if (activeWorkflowId !== workflowId) {
|
||||
logger.info(`Skipping rehydration - workflow ${workflowId} is not active`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for pending operations
|
||||
const hasPending = useOperationQueueStore
|
||||
.getState()
|
||||
.operations.some((op: any) => op.workflowId === workflowId && op.status !== 'confirmed')
|
||||
if (hasPending) {
|
||||
logger.info(`Skipping ${source} rehydration due to pending operations in queue`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract subblock values from blocks
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
})
|
||||
})
|
||||
|
||||
// Replace local workflow store with authoritative server state
|
||||
useWorkflowStore.setState({
|
||||
blocks: workflowState.blocks || {},
|
||||
edges: workflowState.edges || [],
|
||||
loops: workflowState.loops || {},
|
||||
parallels: workflowState.parallels || {},
|
||||
lastSaved: workflowState.lastSaved || Date.now(),
|
||||
isDeployed: workflowState.isDeployed ?? false,
|
||||
deployedAt: workflowState.deployedAt,
|
||||
deploymentStatuses: workflowState.deploymentStatuses || {},
|
||||
hasActiveWebhook: workflowState.hasActiveWebhook ?? false,
|
||||
})
|
||||
|
||||
// Replace subblock store values for this workflow
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowId]: subblockValues,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info(`Successfully rehydrated stores from ${source}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// Copilot workflow edit events (database has been updated, rehydrate stores)
|
||||
socketInstance.on('copilot-workflow-edit', async (data) => {
|
||||
logger.info(
|
||||
`Copilot edited workflow ${data.workflowId} - rehydrating stores from database`
|
||||
)
|
||||
|
||||
if (data.workflowId === urlWorkflowId) {
|
||||
try {
|
||||
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
|
||||
const hasPending = useOperationQueueStore
|
||||
.getState()
|
||||
.operations.some(
|
||||
(op: any) => op.workflowId === data.workflowId && op.status !== 'confirmed'
|
||||
)
|
||||
if (hasPending) {
|
||||
logger.info('Skipping copilot rehydration due to pending operations in queue')
|
||||
return
|
||||
try {
|
||||
// Fetch fresh workflow state directly from API
|
||||
const response = await fetch(`/api/workflows/${data.workflowId}`)
|
||||
if (response.ok) {
|
||||
const responseData = await response.json()
|
||||
const workflowData = responseData.data
|
||||
|
||||
if (workflowData?.state) {
|
||||
await rehydrateWorkflowStores(data.workflowId, workflowData.state, 'copilot')
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
// Fetch fresh workflow state directly from API
|
||||
const response = await fetch(`/api/workflows/${data.workflowId}`)
|
||||
if (response.ok) {
|
||||
const responseData = await response.json()
|
||||
const workflowData = responseData.data
|
||||
|
||||
if (workflowData?.state) {
|
||||
logger.info('Rehydrating stores with fresh workflow state from database')
|
||||
|
||||
// Import stores dynamically to avoid import issues
|
||||
Promise.all([
|
||||
import('@/stores/workflows/workflow/store'),
|
||||
import('@/stores/workflows/subblock/store'),
|
||||
])
|
||||
.then(([{ useWorkflowStore }, { useSubBlockStore }]) => {
|
||||
const workflowState = workflowData.state
|
||||
|
||||
// Extract subblock values from blocks
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(
|
||||
([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Merge workflow store with server state (do not drop optimistic local state)
|
||||
const existing = useWorkflowStore.getState()
|
||||
const mergedBlocks = {
|
||||
...(existing.blocks || {}),
|
||||
...(workflowState.blocks || {}),
|
||||
}
|
||||
const edgeById = new Map<string, any>()
|
||||
;(existing.edges || []).forEach((e: any) => edgeById.set(e.id, e))
|
||||
;(workflowState.edges || []).forEach((e: any) => edgeById.set(e.id, e))
|
||||
const mergedEdges = Array.from(edgeById.values())
|
||||
useWorkflowStore.setState({
|
||||
blocks: mergedBlocks,
|
||||
edges: mergedEdges,
|
||||
loops: workflowState.loops || existing.loops || {},
|
||||
parallels: workflowState.parallels || existing.parallels || {},
|
||||
lastSaved: workflowState.lastSaved || existing.lastSaved || Date.now(),
|
||||
isDeployed: workflowState.isDeployed ?? existing.isDeployed ?? false,
|
||||
deployedAt: workflowState.deployedAt || existing.deployedAt,
|
||||
deploymentStatuses:
|
||||
workflowState.deploymentStatuses || existing.deploymentStatuses || {},
|
||||
hasActiveWebhook:
|
||||
workflowState.hasActiveWebhook ?? existing.hasActiveWebhook ?? false,
|
||||
})
|
||||
|
||||
// Merge subblock store values per workflow
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[data.workflowId]: {
|
||||
...(state.workflowValues?.[data.workflowId] || {}),
|
||||
...subblockValues,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Note: Auto-layout is already handled by the copilot backend before saving
|
||||
// No need to trigger additional auto-layout here to avoid ID conflicts
|
||||
|
||||
logger.info('Successfully rehydrated stores from database after copilot edit')
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to import stores for copilot rehydration:', error)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to fetch fresh workflow state:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to rehydrate stores after copilot edit:', error)
|
||||
} else {
|
||||
logger.error('Failed to fetch fresh workflow state:', response.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to rehydrate stores after copilot edit:', error)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -479,86 +471,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
||||
logger.debug('Operation confirmed:', data)
|
||||
})
|
||||
|
||||
socketInstance.on('workflow-state', (workflowData) => {
|
||||
socketInstance.on('workflow-state', async (workflowData) => {
|
||||
logger.info('Received workflow state from server')
|
||||
|
||||
// Update local stores with the fresh workflow state (same logic as YAML editor)
|
||||
if (workflowData?.state && workflowData.id === urlWorkflowId) {
|
||||
try {
|
||||
const { useOperationQueueStore } = require('@/stores/operation-queue/store')
|
||||
const hasPending = useOperationQueueStore
|
||||
.getState()
|
||||
.operations.some(
|
||||
(op: any) => op.workflowId === workflowData.id && op.status !== 'confirmed'
|
||||
)
|
||||
if (hasPending) {
|
||||
logger.info(
|
||||
'Skipping workflow-state rehydration due to pending operations in queue'
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
logger.info('Updating local stores with fresh workflow state from server')
|
||||
|
||||
try {
|
||||
Promise.all([
|
||||
import('@/stores/workflows/workflow/store'),
|
||||
import('@/stores/workflows/subblock/store'),
|
||||
import('@/stores/workflows/registry/store'),
|
||||
])
|
||||
.then(([{ useWorkflowStore }, { useSubBlockStore }]) => {
|
||||
const workflowState = workflowData.state
|
||||
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
})
|
||||
})
|
||||
|
||||
const existing = useWorkflowStore.getState()
|
||||
const mergedBlocks = {
|
||||
...(existing.blocks || {}),
|
||||
...(workflowState.blocks || {}),
|
||||
}
|
||||
const edgeById = new Map<string, any>()
|
||||
;(existing.edges || []).forEach((e: any) => edgeById.set(e.id, e))
|
||||
;(workflowState.edges || []).forEach((e: any) => edgeById.set(e.id, e))
|
||||
const mergedEdges = Array.from(edgeById.values())
|
||||
useWorkflowStore.setState({
|
||||
blocks: mergedBlocks,
|
||||
edges: mergedEdges,
|
||||
loops: workflowState.loops || existing.loops || {},
|
||||
parallels: workflowState.parallels || existing.parallels || {},
|
||||
lastSaved: workflowState.lastSaved || existing.lastSaved || Date.now(),
|
||||
isDeployed: workflowState.isDeployed ?? existing.isDeployed ?? false,
|
||||
deployedAt: workflowState.deployedAt || existing.deployedAt,
|
||||
deploymentStatuses:
|
||||
workflowState.deploymentStatuses || existing.deploymentStatuses || {},
|
||||
hasActiveWebhook:
|
||||
workflowState.hasActiveWebhook ?? existing.hasActiveWebhook ?? false,
|
||||
})
|
||||
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowData.id]: {
|
||||
...(state.workflowValues?.[workflowData.id] || {}),
|
||||
...subblockValues,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info('Merged fresh workflow state with local state')
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to import stores for workflow state update:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update local stores with workflow state:', error)
|
||||
}
|
||||
if (workflowData?.state) {
|
||||
await rehydrateWorkflowStores(workflowData.id, workflowData.state, 'workflow-state')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
2
apps/sim/db/migrations/0084_even_lockheed.sql
Normal file
2
apps/sim/db/migrations/0084_even_lockheed.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user_rate_limits" RENAME COLUMN "user_id" TO "reference_id";--> statement-breakpoint
|
||||
ALTER TABLE "user_rate_limits" DROP CONSTRAINT "user_rate_limits_user_id_user_id_fk";
|
||||
6024
apps/sim/db/migrations/meta/0084_snapshot.json
Normal file
6024
apps/sim/db/migrations/meta/0084_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -582,6 +582,13 @@
|
||||
"when": 1756768177306,
|
||||
"tag": "0083_ambiguous_dreadnoughts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 84,
|
||||
"version": "7",
|
||||
"when": 1757046301281,
|
||||
"tag": "0084_even_lockheed",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -531,9 +531,7 @@ export const subscription = pgTable(
|
||||
)
|
||||
|
||||
export const userRateLimits = pgTable('user_rate_limits', {
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
referenceId: text('reference_id').primaryKey(), // Can be userId or organizationId for pooling
|
||||
syncApiRequests: integer('sync_api_requests').notNull().default(0), // Sync API requests counter
|
||||
asyncApiRequests: integer('async_api_requests').notNull().default(0), // Async API requests counter
|
||||
windowStart: timestamp('window_start').notNull().defaultNow(),
|
||||
|
||||
@@ -1610,7 +1610,7 @@ describe('InputResolver', () => {
|
||||
}
|
||||
|
||||
expect(() => connectionResolver.resolveInputs(testBlock, contextWithConnections)).toThrow(
|
||||
/Available connected blocks:.*Agent Block.*agent-1.*start/
|
||||
/Available connected blocks:.*Agent Block.*Start/
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -463,10 +463,18 @@ export class InputResolver {
|
||||
const blockMatches = value.match(/<([^>]+)>/g)
|
||||
if (!blockMatches) return value
|
||||
|
||||
// If we're in an API block body, check each match to see if it looks like XML rather than a reference
|
||||
// Filter out patterns that are clearly not variable references (e.g., comparison operators)
|
||||
const validBlockMatches = blockMatches.filter((match) => this.isValidVariableReference(match))
|
||||
|
||||
// If no valid matches found after filtering, return original value
|
||||
if (validBlockMatches.length === 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
// If we're in an API block body, check each valid match to see if it looks like XML rather than a reference
|
||||
if (
|
||||
currentBlock.metadata?.id === 'api' &&
|
||||
blockMatches.some((match) => {
|
||||
validBlockMatches.some((match) => {
|
||||
const innerContent = match.slice(1, -1)
|
||||
// Patterns that suggest this is XML, not a block reference:
|
||||
return (
|
||||
@@ -490,7 +498,7 @@ export class InputResolver {
|
||||
value.includes('}') &&
|
||||
value.includes('`')
|
||||
|
||||
for (const match of blockMatches) {
|
||||
for (const match of validBlockMatches) {
|
||||
// Skip variables - they've already been processed
|
||||
if (match.startsWith('<variable.')) {
|
||||
continue
|
||||
@@ -814,6 +822,63 @@ export class InputResolver {
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a match with < and > is actually a variable reference.
|
||||
* Valid variable references must:
|
||||
* - Have no space after the opening <
|
||||
* - Contain a dot (.)
|
||||
* - Have no spaces until the closing >
|
||||
* - Not be comparison operators or HTML tags
|
||||
*
|
||||
* @param match - The matched string including < and >
|
||||
* @returns Whether this is a valid variable reference
|
||||
*/
|
||||
private isValidVariableReference(match: string): boolean {
|
||||
const innerContent = match.slice(1, -1)
|
||||
|
||||
if (!innerContent.includes('.')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dotIndex = innerContent.indexOf('.')
|
||||
const beforeDot = innerContent.substring(0, dotIndex)
|
||||
const afterDot = innerContent.substring(dotIndex + 1)
|
||||
|
||||
if (afterDot.includes(' ')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
beforeDot.match(/^\s*[<>=!]+\s*$/) ||
|
||||
beforeDot.match(/\s[<>=!]+\s/) ||
|
||||
beforeDot.match(/^[<>=!]+\s/)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (innerContent.startsWith(' ')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (innerContent.match(/^[a-zA-Z][a-zA-Z0-9]*$/) && !innerContent.includes('.')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (innerContent.match(/^[<>=!]+\s/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (beforeDot.match(/[+*/=<>!]/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (afterDot.match(/[+\-*/=<>!]/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a string contains a properly formatted environment variable reference.
|
||||
* Valid references are either:
|
||||
@@ -1145,6 +1210,24 @@ export class InputResolver {
|
||||
return [...new Set(names)] // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user-friendly block names for error messages.
|
||||
* Only returns the actual block names that users see in the UI.
|
||||
*/
|
||||
private getAccessibleBlockNamesForError(currentBlockId: string): string[] {
|
||||
const accessibleBlockIds = this.getAccessibleBlocks(currentBlockId)
|
||||
const names: string[] = []
|
||||
|
||||
for (const blockId of accessibleBlockIds) {
|
||||
const block = this.blockById.get(blockId)
|
||||
if (block?.metadata?.name) {
|
||||
names.push(block.metadata.name)
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(names)] // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block reference could potentially be valid without throwing errors.
|
||||
* Used to filter out non-block patterns like <test> from block reference resolution.
|
||||
@@ -1197,7 +1280,7 @@ export class InputResolver {
|
||||
}
|
||||
|
||||
if (!sourceBlock) {
|
||||
const accessibleNames = this.getAccessibleBlockNames(currentBlockId)
|
||||
const accessibleNames = this.getAccessibleBlockNamesForError(currentBlockId)
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: `Block "${blockRef}" was not found. Available connected blocks: ${accessibleNames.join(', ')}`,
|
||||
@@ -1207,7 +1290,7 @@ export class InputResolver {
|
||||
// Check if block is accessible (connected)
|
||||
const accessibleBlocks = this.getAccessibleBlocks(currentBlockId)
|
||||
if (!accessibleBlocks.has(sourceBlock.id)) {
|
||||
const accessibleNames = this.getAccessibleBlockNames(currentBlockId)
|
||||
const accessibleNames = this.getAccessibleBlockNamesForError(currentBlockId)
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: `Block "${blockRef}" is not connected to this block. Available connected blocks: ${accessibleNames.join(', ')}`,
|
||||
|
||||
@@ -95,25 +95,6 @@ export class BuildWorkflowClientTool extends BaseClientTool {
|
||||
// Populate diff preview immediately (without marking complete yet)
|
||||
try {
|
||||
const diffStore = useWorkflowDiffStore.getState()
|
||||
// Send early stats upsert with the triggering user message id if available
|
||||
try {
|
||||
const { useCopilotStore } = await import('@/stores/copilot/store')
|
||||
const { currentChat, currentUserMessageId, agentDepth, agentPrefetch } =
|
||||
useCopilotStore.getState() as any
|
||||
if (currentChat?.id && currentUserMessageId) {
|
||||
fetch('/api/copilot/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: currentChat.id,
|
||||
messageId: currentUserMessageId,
|
||||
depth: agentDepth,
|
||||
maxEnabled: agentDepth >= 2 && !agentPrefetch,
|
||||
diffCreated: true,
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch {}
|
||||
await diffStore.setProposedChanges(result.yamlContent)
|
||||
logger.info('diff proposed changes set')
|
||||
} catch (e) {
|
||||
|
||||
@@ -151,25 +151,6 @@ export class EditWorkflowClientTool extends BaseClientTool {
|
||||
try {
|
||||
if (!this.hasAppliedDiff) {
|
||||
const diffStore = useWorkflowDiffStore.getState()
|
||||
// Send early stats upsert with the triggering user message id if available
|
||||
try {
|
||||
const { useCopilotStore } = await import('@/stores/copilot/store')
|
||||
const { currentChat, currentUserMessageId, agentDepth, agentPrefetch } =
|
||||
useCopilotStore.getState() as any
|
||||
if (currentChat?.id && currentUserMessageId) {
|
||||
fetch('/api/copilot/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: currentChat.id,
|
||||
messageId: currentUserMessageId,
|
||||
depth: agentDepth,
|
||||
maxEnabled: agentDepth >= 2 && !agentPrefetch,
|
||||
diffCreated: true,
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch {}
|
||||
await diffStore.setProposedChanges(result.yamlContent)
|
||||
logger.info('diff proposed changes set for edit_workflow')
|
||||
this.hasAppliedDiff = true
|
||||
|
||||
@@ -83,6 +83,21 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
// Handle whitelabel redirects for terms and privacy pages
|
||||
if (url.pathname === '/terms') {
|
||||
const termsUrl = process.env.NEXT_PUBLIC_TERMS_URL
|
||||
if (termsUrl?.startsWith('http')) {
|
||||
return NextResponse.redirect(termsUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/privacy') {
|
||||
const privacyUrl = process.env.NEXT_PUBLIC_PRIVACY_URL
|
||||
if (privacyUrl?.startsWith('http')) {
|
||||
return NextResponse.redirect(privacyUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy redirect: /w -> /workspace (will be handled by workspace layout)
|
||||
if (url.pathname === '/w' || url.pathname.startsWith('/w/')) {
|
||||
// Extract workflow ID if present
|
||||
@@ -195,6 +210,8 @@ export async function middleware(request: NextRequest) {
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/', // Root path for self-hosted redirect logic
|
||||
'/terms', // Whitelabel terms redirect
|
||||
'/privacy', // Whitelabel privacy redirect
|
||||
'/w', // Legacy /w redirect
|
||||
'/w/:path*', // Legacy /w/* redirects
|
||||
'/workspace/:path*', // New workspace routes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { withSentryConfig } from '@sentry/nextjs'
|
||||
import type { NextConfig } from 'next'
|
||||
import { env, getEnv, isTruthy } from './lib/env'
|
||||
import { env, isTruthy } from './lib/env'
|
||||
import { isDev, isHosted, isProd } from './lib/environment'
|
||||
import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/csp'
|
||||
|
||||
@@ -186,24 +186,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
async redirects() {
|
||||
const redirects = []
|
||||
// Add whitelabel redirects for terms and privacy pages if external URLs are configured
|
||||
const termsUrl = getEnv('NEXT_PUBLIC_TERMS_URL')
|
||||
if (termsUrl?.startsWith('http')) {
|
||||
redirects.push({
|
||||
source: '/terms',
|
||||
destination: termsUrl,
|
||||
permanent: false,
|
||||
})
|
||||
}
|
||||
|
||||
const privacyUrl = getEnv('NEXT_PUBLIC_PRIVACY_URL')
|
||||
if (privacyUrl?.startsWith('http')) {
|
||||
redirects.push({
|
||||
source: '/privacy',
|
||||
destination: privacyUrl,
|
||||
permanent: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Only enable domain redirects for the hosted version
|
||||
if (isHosted) {
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function executeProviderRequest(
|
||||
const { prompt: promptTokens = 0, completion: completionTokens = 0 } = response.tokens
|
||||
const useCachedInput = !!request.context && request.context.length > 0
|
||||
|
||||
if (shouldBillModelUsage(response.model, request.apiKey)) {
|
||||
if (shouldBillModelUsage(response.model)) {
|
||||
response.cost = calculateCost(response.model, promptTokens, completionTokens, useCachedInput)
|
||||
} else {
|
||||
response.cost = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCostMultiplier, isHosted } from '@/lib/environment'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { anthropicProvider } from '@/providers/anthropic'
|
||||
import { azureOpenAIProvider } from '@/providers/azure-openai'
|
||||
@@ -444,7 +444,8 @@ export function calculateCost(
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
useCachedInput = false,
|
||||
customMultiplier?: number
|
||||
inputMultiplier?: number,
|
||||
outputMultiplier?: number
|
||||
) {
|
||||
// First check if it's an embedding model
|
||||
let pricing = getEmbeddingModelPricing(model)
|
||||
@@ -479,13 +480,9 @@ export function calculateCost(
|
||||
: pricing.input / 1_000_000)
|
||||
|
||||
const outputCost = completionTokens * (pricing.output / 1_000_000)
|
||||
const totalCost = inputCost + outputCost
|
||||
|
||||
const costMultiplier = customMultiplier ?? getCostMultiplier()
|
||||
|
||||
const finalInputCost = inputCost * costMultiplier
|
||||
const finalOutputCost = outputCost * costMultiplier
|
||||
const finalTotalCost = totalCost * costMultiplier
|
||||
const finalInputCost = inputCost * (inputMultiplier ?? 1)
|
||||
const finalOutputCost = outputCost * (outputMultiplier ?? 1)
|
||||
const finalTotalCost = finalInputCost + finalOutputCost
|
||||
|
||||
return {
|
||||
input: Number.parseFloat(finalInputCost.toFixed(8)), // Use 8 decimal places for small costs
|
||||
@@ -551,20 +548,11 @@ export function getHostedModels(): string[] {
|
||||
* Determine if model usage should be billed to the user
|
||||
*
|
||||
* @param model The model name
|
||||
* @param userProvidedApiKey Whether the user provided their own API key
|
||||
* @returns true if the usage should be billed to the user
|
||||
*/
|
||||
export function shouldBillModelUsage(model: string, userProvidedApiKey?: string): boolean {
|
||||
export function shouldBillModelUsage(model: string): boolean {
|
||||
const hostedModels = getHostedModels()
|
||||
if (!hostedModels.includes(model)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (userProvidedApiKey && userProvidedApiKey.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return hostedModels.includes(model)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,11 @@ vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions) => ({ and: conditions })),
|
||||
}))
|
||||
|
||||
// Mock getHighestPrioritySubscription
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
import { db } from '@/db'
|
||||
|
||||
describe('RateLimiter', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userRateLimits } from '@/db/schema'
|
||||
@@ -12,14 +13,43 @@ import {
|
||||
|
||||
const logger = createLogger('RateLimiter')
|
||||
|
||||
interface SubscriptionInfo {
|
||||
plan: string
|
||||
referenceId: string
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
/**
|
||||
* Check if user can execute a workflow
|
||||
* Determine the rate limit key based on subscription
|
||||
* For team/enterprise plans via organization, use the organization ID
|
||||
* For direct user subscriptions (including direct team), use the user ID
|
||||
*/
|
||||
private getRateLimitKey(userId: string, subscription: SubscriptionInfo | null): string {
|
||||
if (!subscription) {
|
||||
return userId
|
||||
}
|
||||
|
||||
const plan = subscription.plan as SubscriptionPlan
|
||||
|
||||
// Check if this is an organization subscription (referenceId !== userId)
|
||||
// If referenceId === userId, it's a direct user subscription
|
||||
if ((plan === 'team' || plan === 'enterprise') && subscription.referenceId !== userId) {
|
||||
// This is an organization subscription
|
||||
// All organization members share the same rate limit pool
|
||||
return subscription.referenceId
|
||||
}
|
||||
|
||||
// For direct user subscriptions (free/pro/team/enterprise where referenceId === userId)
|
||||
return userId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can execute a workflow with organization-aware rate limiting
|
||||
* Manual executions bypass rate limiting entirely
|
||||
*/
|
||||
async checkRateLimit(
|
||||
async checkRateLimitWithSubscription(
|
||||
userId: string,
|
||||
subscriptionPlan: SubscriptionPlan = 'free',
|
||||
subscription: SubscriptionInfo | null,
|
||||
triggerType: TriggerType = 'manual',
|
||||
isAsync = false
|
||||
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
|
||||
@@ -32,6 +62,9 @@ export class RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionPlan = (subscription?.plan || 'free') as SubscriptionPlan
|
||||
const rateLimitKey = this.getRateLimitKey(userId, subscription)
|
||||
|
||||
const limit = RATE_LIMITS[subscriptionPlan]
|
||||
const execLimit = isAsync
|
||||
? limit.asyncApiExecutionsPerMinute
|
||||
@@ -40,11 +73,11 @@ export class RateLimiter {
|
||||
const now = new Date()
|
||||
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
|
||||
|
||||
// Get or create rate limit record
|
||||
// Get or create rate limit record using the rate limit key
|
||||
const [rateLimitRecord] = await db
|
||||
.select()
|
||||
.from(userRateLimits)
|
||||
.where(eq(userRateLimits.userId, userId))
|
||||
.where(eq(userRateLimits.referenceId, rateLimitKey))
|
||||
.limit(1)
|
||||
|
||||
if (!rateLimitRecord || new Date(rateLimitRecord.windowStart) < windowStart) {
|
||||
@@ -52,7 +85,7 @@ export class RateLimiter {
|
||||
const result = await db
|
||||
.insert(userRateLimits)
|
||||
.values({
|
||||
userId,
|
||||
referenceId: rateLimitKey,
|
||||
syncApiRequests: isAsync ? 0 : 1,
|
||||
asyncApiRequests: isAsync ? 1 : 0,
|
||||
windowStart: now,
|
||||
@@ -60,7 +93,7 @@ export class RateLimiter {
|
||||
isRateLimited: false,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userRateLimits.userId,
|
||||
target: userRateLimits.referenceId,
|
||||
set: {
|
||||
// Only reset if window is still expired (avoid race condition)
|
||||
syncApiRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${isAsync ? 0 : 1} ELSE ${userRateLimits.syncApiRequests} + ${isAsync ? 0 : 1} END`,
|
||||
@@ -94,7 +127,20 @@ export class RateLimiter {
|
||||
isRateLimited: true,
|
||||
rateLimitResetAt: resetAt,
|
||||
})
|
||||
.where(eq(userRateLimits.userId, userId))
|
||||
.where(eq(userRateLimits.referenceId, rateLimitKey))
|
||||
|
||||
logger.info(
|
||||
`Rate limit exceeded - request ${actualCount} > limit ${execLimit} for ${
|
||||
rateLimitKey === userId ? `user ${userId}` : `organization ${rateLimitKey}`
|
||||
}`,
|
||||
{
|
||||
execLimit,
|
||||
isAsync,
|
||||
actualCount,
|
||||
rateLimitKey,
|
||||
plan: subscriptionPlan,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
@@ -119,7 +165,7 @@ export class RateLimiter {
|
||||
: { syncApiRequests: sql`${userRateLimits.syncApiRequests} + 1` }),
|
||||
lastRequestAt: now,
|
||||
})
|
||||
.where(eq(userRateLimits.userId, userId))
|
||||
.where(eq(userRateLimits.referenceId, rateLimitKey))
|
||||
.returning({
|
||||
asyncApiRequests: userRateLimits.asyncApiRequests,
|
||||
syncApiRequests: userRateLimits.syncApiRequests,
|
||||
@@ -137,11 +183,15 @@ export class RateLimiter {
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`Rate limit exceeded - request ${actualNewRequests} > limit ${execLimit} for user ${userId}`,
|
||||
`Rate limit exceeded - request ${actualNewRequests} > limit ${execLimit} for ${
|
||||
rateLimitKey === userId ? `user ${userId}` : `organization ${rateLimitKey}`
|
||||
}`,
|
||||
{
|
||||
execLimit,
|
||||
isAsync,
|
||||
actualNewRequests,
|
||||
rateLimitKey,
|
||||
plan: subscriptionPlan,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -152,7 +202,7 @@ export class RateLimiter {
|
||||
isRateLimited: true,
|
||||
rateLimitResetAt: resetAt,
|
||||
})
|
||||
.where(eq(userRateLimits.userId, userId))
|
||||
.where(eq(userRateLimits.referenceId, rateLimitKey))
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
@@ -178,14 +228,29 @@ export class RateLimiter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status for user
|
||||
* Only applies to API executions
|
||||
* Legacy method - for backward compatibility
|
||||
* @deprecated Use checkRateLimitWithSubscription instead
|
||||
*/
|
||||
async getRateLimitStatus(
|
||||
async checkRateLimit(
|
||||
userId: string,
|
||||
subscriptionPlan: SubscriptionPlan = 'free',
|
||||
triggerType: TriggerType = 'manual',
|
||||
isAsync = false
|
||||
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
|
||||
// For backward compatibility, fetch the subscription
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
return this.checkRateLimitWithSubscription(userId, subscription, triggerType, isAsync)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status with organization awareness
|
||||
* Only applies to API executions
|
||||
*/
|
||||
async getRateLimitStatusWithSubscription(
|
||||
userId: string,
|
||||
subscription: SubscriptionInfo | null,
|
||||
triggerType: TriggerType = 'manual',
|
||||
isAsync = false
|
||||
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
|
||||
try {
|
||||
if (triggerType === 'manual') {
|
||||
@@ -197,6 +262,9 @@ export class RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionPlan = (subscription?.plan || 'free') as SubscriptionPlan
|
||||
const rateLimitKey = this.getRateLimitKey(userId, subscription)
|
||||
|
||||
const limit = RATE_LIMITS[subscriptionPlan]
|
||||
const execLimit = isAsync
|
||||
? limit.asyncApiExecutionsPerMinute
|
||||
@@ -207,7 +275,7 @@ export class RateLimiter {
|
||||
const [rateLimitRecord] = await db
|
||||
.select()
|
||||
.from(userRateLimits)
|
||||
.where(eq(userRateLimits.userId, userId))
|
||||
.where(eq(userRateLimits.referenceId, rateLimitKey))
|
||||
.limit(1)
|
||||
|
||||
if (!rateLimitRecord || new Date(rateLimitRecord.windowStart) < windowStart) {
|
||||
@@ -229,8 +297,9 @@ export class RateLimiter {
|
||||
} catch (error) {
|
||||
logger.error('Error getting rate limit status:', error)
|
||||
const execLimit = isAsync
|
||||
? RATE_LIMITS[subscriptionPlan].asyncApiExecutionsPerMinute
|
||||
: RATE_LIMITS[subscriptionPlan].syncApiExecutionsPerMinute
|
||||
? RATE_LIMITS[(subscription?.plan || 'free') as SubscriptionPlan]
|
||||
.asyncApiExecutionsPerMinute
|
||||
: RATE_LIMITS[(subscription?.plan || 'free') as SubscriptionPlan].syncApiExecutionsPerMinute
|
||||
return {
|
||||
used: 0,
|
||||
limit: execLimit,
|
||||
@@ -241,13 +310,27 @@ export class RateLimiter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for user (admin action)
|
||||
* Legacy method - for backward compatibility
|
||||
* @deprecated Use getRateLimitStatusWithSubscription instead
|
||||
*/
|
||||
async resetRateLimit(userId: string): Promise<void> {
|
||||
try {
|
||||
await db.delete(userRateLimits).where(eq(userRateLimits.userId, userId))
|
||||
async getRateLimitStatus(
|
||||
userId: string,
|
||||
subscriptionPlan: SubscriptionPlan = 'free',
|
||||
triggerType: TriggerType = 'manual',
|
||||
isAsync = false
|
||||
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
|
||||
// For backward compatibility, fetch the subscription
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
return this.getRateLimitStatusWithSubscription(userId, subscription, triggerType, isAsync)
|
||||
}
|
||||
|
||||
logger.info(`Reset rate limit for user ${userId}`)
|
||||
/**
|
||||
* Reset rate limit for a user or organization
|
||||
*/
|
||||
async resetRateLimit(rateLimitKey: string): Promise<void> {
|
||||
try {
|
||||
await db.delete(userRateLimits).where(eq(userRateLimits.referenceId, rateLimitKey))
|
||||
logger.info(`Reset rate limit for ${rateLimitKey}`)
|
||||
} catch (error) {
|
||||
logger.error('Error resetting rate limit:', error)
|
||||
throw error
|
||||
|
||||
@@ -1685,27 +1685,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}).catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Optimistic stats: mark aborted for the in-flight user message
|
||||
try {
|
||||
const { currentChat: cc, currentUserMessageId, messageMetaById } = get() as any
|
||||
if (cc?.id && currentUserMessageId) {
|
||||
const meta = messageMetaById?.[currentUserMessageId] || null
|
||||
const agentDepth = meta?.depth
|
||||
const maxEnabled = meta?.maxEnabled
|
||||
fetch('/api/copilot/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: cc.id,
|
||||
messageId: currentUserMessageId,
|
||||
...(typeof agentDepth === 'number' ? { depth: agentDepth } : {}),
|
||||
...(typeof maxEnabled === 'boolean' ? { maxEnabled } : {}),
|
||||
aborted: true,
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
set({ isSendingMessage: false, isAborting: false, abortController: null })
|
||||
}
|
||||
@@ -2113,47 +2092,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
|
||||
// Post copilot_stats record (input/output tokens can be null for now)
|
||||
try {
|
||||
const { messageMetaById } = get() as any
|
||||
const meta =
|
||||
(messageMetaById && (messageMetaById as any)[triggerUserMessageId || '']) || null
|
||||
const agentDepth = meta?.depth ?? get().agentDepth
|
||||
const maxEnabled = meta?.maxEnabled ?? (agentDepth >= 2 && !get().agentPrefetch)
|
||||
const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store')
|
||||
const diffState = useWorkflowDiffStore.getState() as any
|
||||
const diffCreated = !!diffState?.isShowingDiff
|
||||
const diffAccepted = false // acceptance may arrive earlier or later via diff store
|
||||
const endMs = Date.now()
|
||||
const duration = Math.max(0, endMs - startTimeMs)
|
||||
const chatIdToUse = get().currentChat?.id || context.newChatId
|
||||
// Prefer provided trigger user message id; fallback to last user message
|
||||
let userMessageIdToUse = triggerUserMessageId
|
||||
if (!userMessageIdToUse) {
|
||||
const msgs = get().messages
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const m = msgs[i]
|
||||
if (m.role === 'user') {
|
||||
userMessageIdToUse = m.id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chatIdToUse) {
|
||||
fetch('/api/copilot/stats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: chatIdToUse,
|
||||
messageId: userMessageIdToUse || assistantMessageId,
|
||||
depth: agentDepth,
|
||||
maxEnabled,
|
||||
diffCreated,
|
||||
diffAccepted,
|
||||
duration: duration ?? null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
// Removed: stats sending now occurs only on accept/reject with minimal payload
|
||||
} catch {}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
@@ -97,6 +97,34 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
error: null,
|
||||
isEditing: null,
|
||||
|
||||
async loadForWorkflow(workflowId) {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `Failed to load variables: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const variables = (data?.data as Record<string, Variable>) || {}
|
||||
set((state) => {
|
||||
const withoutWorkflow = Object.fromEntries(
|
||||
Object.entries(state.variables).filter(
|
||||
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
|
||||
)
|
||||
)
|
||||
return {
|
||||
variables: { ...withoutWorkflow, ...variables },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error'
|
||||
set({ isLoading: false, error: message })
|
||||
}
|
||||
},
|
||||
|
||||
addVariable: (variable, providedId?: string) => {
|
||||
const id = providedId || crypto.randomUUID()
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface VariablesStore {
|
||||
error: string | null
|
||||
isEditing: string | null
|
||||
|
||||
/**
|
||||
* Loads variables for a specific workflow from the API and hydrates the store.
|
||||
*/
|
||||
loadForWorkflow: (workflowId: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Adds a new variable with automatic name uniqueness validation
|
||||
* If a variable with the same name exists, it will be suffixed with a number
|
||||
|
||||
@@ -303,7 +303,6 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: currentChat.id,
|
||||
messageId: triggerMessageId,
|
||||
diffCreated: true,
|
||||
diffAccepted: true,
|
||||
@@ -441,7 +440,6 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: currentChat.id,
|
||||
messageId: triggerMessageId,
|
||||
diffCreated: true,
|
||||
diffAccepted: false,
|
||||
|
||||
@@ -109,6 +109,7 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
|
||||
description: description || '',
|
||||
color: color || '#3972F6',
|
||||
lastModified: createdAt ? new Date(createdAt) : new Date(),
|
||||
createdAt: createdAt ? new Date(createdAt) : new Date(),
|
||||
marketplaceData: marketplaceData || null,
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
@@ -124,27 +125,6 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize subblock values
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
if (state?.blocks) {
|
||||
Object.entries(state.blocks).forEach(([blockId, block]) => {
|
||||
const blockState = block as BlockState
|
||||
subblockValues[blockId] = {}
|
||||
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = subblock.value
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Update subblock store
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[id]: subblockValues,
|
||||
},
|
||||
}))
|
||||
|
||||
if (variables && typeof variables === 'object') {
|
||||
useVariablesStore.setState((state) => {
|
||||
const withoutWorkflow = Object.fromEntries(
|
||||
@@ -505,23 +485,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
future: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Extract and update subblock values
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks).forEach(([blockId, block]) => {
|
||||
const blockState = block as any
|
||||
subblockValues[blockId] = {}
|
||||
Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => {
|
||||
subblockValues[blockId][subblockId] = (subblock as any).value
|
||||
})
|
||||
})
|
||||
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[id]: subblockValues,
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
// If no state in DB, use empty state - server should have created start block
|
||||
workflowState = {
|
||||
@@ -571,11 +534,12 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}))
|
||||
}
|
||||
|
||||
// Update all stores atomically to prevent race conditions
|
||||
// Set activeWorkflowId and workflow state together
|
||||
set({ activeWorkflowId: id, error: null })
|
||||
useWorkflowStore.setState(workflowState)
|
||||
useSubBlockStore.getState().initializeFromWorkflow(id, (workflowState as any).blocks || {})
|
||||
|
||||
set({ activeWorkflowId: id, error: null })
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('active-workflow-changed', {
|
||||
detail: { workflowId: id },
|
||||
@@ -631,6 +595,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
id: serverWorkflowId,
|
||||
name: createdWorkflow.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: createdWorkflow.description,
|
||||
color: createdWorkflow.color,
|
||||
marketplaceData: options.marketplaceId
|
||||
@@ -873,6 +838,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
id,
|
||||
name: metadata.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: metadata.description || 'Imported from marketplace',
|
||||
color: metadata.color || getNextWorkflowColor(),
|
||||
marketplaceData: { id: marketplaceId, status: 'temp' as const },
|
||||
@@ -1029,6 +995,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
id,
|
||||
name: `${sourceWorkflow.name} (Copy)`,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: sourceWorkflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
workspaceId, // Include the workspaceId in the new workflow
|
||||
@@ -1223,7 +1190,11 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}))
|
||||
}
|
||||
|
||||
// Workflow has already been persisted to the database via the duplication endpoint
|
||||
try {
|
||||
await useVariablesStore.getState().loadForWorkflow(id)
|
||||
} catch (error) {
|
||||
logger.warn(`Error hydrating variables for duplicated workflow ${id}:`, error)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId || 'none'}`
|
||||
@@ -1359,6 +1330,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
...workflow,
|
||||
...metadata,
|
||||
lastModified: new Date(),
|
||||
createdAt: workflow.createdAt, // Preserve creation date
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
@@ -1391,6 +1363,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
color: updatedWorkflow.color,
|
||||
folderId: updatedWorkflow.folderId,
|
||||
lastModified: new Date(updatedWorkflow.updatedAt),
|
||||
createdAt: updatedWorkflow.createdAt
|
||||
? new Date(updatedWorkflow.createdAt)
|
||||
: state.workflows[id].createdAt,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface WorkflowMetadata {
|
||||
id: string
|
||||
name: string
|
||||
lastModified: Date
|
||||
createdAt: Date
|
||||
description?: string
|
||||
color: string
|
||||
marketplaceData?: MarketplaceData | null
|
||||
|
||||
@@ -103,7 +103,7 @@ export const useSubBlockStore = create<SubBlockStore>()(
|
||||
const values: Record<string, Record<string, any>> = {}
|
||||
Object.entries(blocks).forEach(([blockId, block]) => {
|
||||
values[blockId] = {}
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
|
||||
values[blockId][subBlockId] = (subBlock as SubBlockConfig).value
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user