Compare commits

...

25 Commits

Author SHA1 Message Date
Waleed
60a061e38a v0.3.47: race condition fixes, store rehydration consolidation, other bugs 2025-09-04 22:36:42 -07:00
Waleed
ab71fcfc49 feat(invitations): add ability to resend invitations with cooldown, fixed UI in dark mode issues (#1256) 2025-09-04 22:15:27 -07:00
Vikhyath Mondreti
864622c1dc fix(ratelimits): enterprise and team checks should be pooled limit (#1255)
* fix(ratelimits): enterprise and team checks should be pooled limit"

* fix

* fix dynamic imports

* fix tests"
;
2025-09-04 21:44:56 -07:00
Waleed
8668622d66 feat(duplicate): duplicate variables when duplicating a workflow (#1254)
* feat(duplicate): duplicate variables when duplicating a workflow

* better typing
2025-09-04 21:20:30 -07:00
Waleed
53dd277cfe fix(cost): restored cost reporting for agent block in console entry (#1253) 2025-09-04 21:12:15 -07:00
Vikhyath Mondreti
0e8e8c7a47 fix(sidebar): order by created at (#1251) 2025-09-04 20:23:00 -07:00
Vikhyath Mondreti
47da5eb6e8 fix(rehydration): consolidate store rehydration code (#1249)
* fix(rehydration): consolidate store rehydration code

* fix stale closure
2025-09-04 20:00:51 -07:00
Vikhyath Mondreti
37dcde2afc feat(enterprise-plan-webhooks): skip webhook queue for enterprise plan users (#1250)
* feat(enterprise-plan-webhooks): skip webhook queue for enterprise plan users

* reuse subscription record instead of making extra db call
2025-09-04 20:00:24 -07:00
Vikhyath Mondreti
e31627c7c2 fix(sidebar): re-ordering based on last edit is confusing (#1248) 2025-09-04 18:30:59 -07:00
Vikhyath Mondreti
57c98d86ba fix(race-condition-workflow-switching): another race condition between registry and workflow stores (#1247)
* fix(race-condition-workflow-switching): another race condition between regitry and workflow stores"

* fix initial load race cond + cleanup

* fix initial load issue + simplify
2025-09-04 18:02:00 -07:00
Vikhyath Mondreti
0f7dfe084a fix(hydration): duplicate overlay after idle + subblocks race condition (#1246)
* fix(hydration): duplicate overlay after idle + subblocks race condition

* remove random timeout

* re-use correct helper

* remove redundant check

* add check

* remove third init func
2025-09-04 16:18:35 -07:00
Siddharth Ganesan
afc1632830 Merge pull request #1245 from simstudioai/fix/copilot-billing
improvement(copilot): billing multiplier adjustments
2025-09-04 12:05:17 -07:00
Siddharth Ganesan
56eee2c2d2 Waring 2025-09-04 11:37:06 -07:00
Siddharth Ganesan
fc558a8eef Lint + tests 2025-09-04 11:35:03 -07:00
Siddharth Ganesan
c68cadfb84 Docs 2025-09-04 11:27:54 -07:00
Siddharth Ganesan
95d93a2532 change 2025-09-04 11:23:36 -07:00
Siddharth Ganesan
59b2023124 Lint 2025-09-04 11:19:41 -07:00
Siddharth Ganesan
a672f17136 Add input/output multipliers 2025-09-04 11:19:00 -07:00
Waleed
1de59668e4 fix(whitelabel): move redirects (build-time) for whitelabeling to middlware (runtime) (#1236) 2025-09-03 16:36:47 -07:00
Waleed
26243b99e8 fix(code-subblock): added validation to not parse non-variables as variables in the code subblock (#1240)
* fix(code-subblock): added validation to not parse non-variables as variables in the code subblock

* fix wand prompt bar styling

* fix error message for available connected blocks to only show connected available blocks, not block ID's

* ui
2025-09-03 16:09:02 -07:00
Siddharth Ganesan
fce1423d05 v0.3.46: fix copilot stats updates
v0.3.46: fix copilot stats updates
2025-09-03 13:26:00 -07:00
Siddharth Ganesan
3656d3d7ad Updates (#1237) 2025-09-03 13:19:34 -07:00
Waleed
581929bc01 v0.3.45: fixes for organization invites, custom tool execution 2025-09-03 08:31:56 -07:00
Waleed
11d8188415 fix(rce): always use VM over RCE for custom tools (#1233) 2025-09-03 08:16:50 -07:00
Waleed
36c98d18e9 fix(team): fix organization invitation URL for teams (#1232) 2025-09-03 08:05:38 -07:00
55 changed files with 6900 additions and 650 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -585,6 +585,7 @@ export async function POST(req: NextRequest) {
const useE2B =
e2bEnabled &&
!useLocalVM &&
!isCustomTool &&
(lang === CodeLanguage.JavaScript || lang === CodeLanguage.Python)
if (useE2B) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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";

File diff suppressed because it is too large Load Diff

View File

@@ -582,6 +582,13 @@
"when": 1756768177306,
"tag": "0083_ambiguous_dreadnoughts",
"breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1757046301281,
"tag": "0084_even_lockheed",
"breakpoints": true
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ export interface WorkflowMetadata {
id: string
name: string
lastModified: Date
createdAt: Date
description?: string
color: string
marketplaceData?: MarketplaceData | null

View File

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