Compare commits

..

46 Commits

Author SHA1 Message Date
Waleed
2bb68335ee v0.5.79: longer MCP tools timeout, optimize loop/parallel regeneration, enrich.so integration 2026-01-31 21:57:56 -08:00
Waleed
8528fbe2d2 v0.5.78: billing fixes, mcp timeout increase, reactquery migrations, updated tool param visibilities, DSPy and Google Maps integrations 2026-01-31 13:48:22 -08:00
Waleed
31fdd2be13 v0.5.77: room manager redis migration, tool outputs, ui fixes 2026-01-30 14:57:17 -08:00
Waleed
028bc652c2 v0.5.76: posthog improvements, readme updates 2026-01-29 00:13:19 -08:00
Waleed
c6bf5cd58c v0.5.75: search modal overhaul, helm chart updates, run from block, terminal and visual debugging improvements 2026-01-28 22:54:13 -08:00
Vikhyath Mondreti
11dc18a80d v0.5.74: autolayout improvements, clerk integration, auth enforcements 2026-01-27 20:37:39 -08:00
Waleed
ab4e9dc72f v0.5.73: ci, helm updates, kb, ui fixes, note block enhancements 2026-01-26 22:04:35 -08:00
Vikhyath Mondreti
1c58c35bd8 v0.5.72: azure connection string, supabase improvement, multitrigger resolution, docs quick reference 2026-01-25 23:42:27 -08:00
Waleed
d63a5cb504 v0.5.71: ux, ci improvements, docs updates 2026-01-25 03:08:08 -08:00
Waleed
8bd5d41723 v0.5.70: router fix, anthropic agent response format adherence 2026-01-24 20:57:02 -08:00
Waleed
c12931bc50 v0.5.69: kb upgrades, blog, copilot improvements, auth consolidation (#2973)
* fix(subflows): tag dropdown + resolution logic (#2949)

* fix(subflows): tag dropdown + resolution logic

* fixes;

* revert parallel change

* chore(deps): bump posthog-js to 1.334.1 (#2948)

* fix(idempotency): add conflict target to atomicallyClaimDb query + remove redundant db namespace tracking (#2950)

* fix(idempotency): add conflict target to atomicallyClaimDb query

* delete needs to account for namespace

* simplify namespace filtering logic

* fix cleanup

* consistent target

* improvement(kb): add document filtering, select all, and React Query migration (#2951)

* improvement(kb): add document filtering, select all, and React Query migration

* test(kb): update tests for enabledFilter and removed userId params

* fix(kb): remove non-null assertion, add explicit guard

* improvement(logs): trace span, details (#2952)

* improvement(action-bar): ordering

* improvement(logs): details, trace span

* feat(blog): v0.5 release post (#2953)

* feat(blog): v0.5 post

* improvement(blog): simplify title and remove code block header

- Simplified blog title from Introducing Sim Studio v0.5 to Introducing Sim v0.5
- Removed language label header and copy button from code blocks for cleaner appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ack PR comments

* small styling improvements

* created system to create post-specific components

* updated componnet

* cache invalidation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(admin): add credits endpoint to issue credits to users (#2954)

* feat(admin): add credits endpoint to issue credits to users

* fix(admin): use existing credit functions and handle enterprise seats

* fix(admin): reject NaN and Infinity in amount validation

* styling

* fix(admin): validate userId and email are strings

* improvement(copilot): fast mode, subagent tool responses and allow preferences (#2955)

* Improvements

* Fix actions mapping

* Remove console logs

* fix(billing): handle missing userStats and prevent crashes (#2956)

* fix(billing): handle missing userStats and prevent crashes

* fix(billing): correct import path for getFilledPillColor

* fix(billing): add Number.isFinite check to lastPeriodCost

* fix(logs): refresh logic to refresh logs details (#2958)

* fix(security): add authentication and input validation to API routes (#2959)

* fix(security): add authentication and input validation to API routes

* moved utils

* remove extraneous commetns

* removed unused dep

* improvement(helm): add internal ingress support and same-host path consolidation (#2960)

* improvement(helm): add internal ingress support and same-host path consolidation

* improvement(helm): clean up ingress template comments

Simplify verbose inline Helm comments and section dividers to match the
minimal style used in services.yaml.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(helm): add missing copilot path consolidation for realtime host

When copilot.host equals realtime.host but differs from app.host,
copilot paths were not being routed. Added logic to consolidate
copilot paths into the realtime rule for this scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improvement(helm): follow ingress best practices

- Remove orphan comments that appeared when services were disabled
- Add documentation about path ordering requirements
- Paths rendered in order: realtime, copilot, app (specific before catch-all)
- Clean template output matching industry Helm chart standards

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* feat(blog): enterprise post (#2961)

* feat(blog): enterprise post

* added more images, styling

* more content

* updated v0-5 post

* remove unused transition

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(envvars): resolution standardized (#2957)

* fix(envvars): resolution standardized

* remove comments

* address bugbot

* fix highlighting for env vars

* remove comments

* address greptile

* address bugbot

* fix(copilot): mask credentials fix (#2963)

* Fix copilot masking

* Clean up

* Lint

* improvement(webhooks): remove dead code (#2965)

* fix(webhooks): subscription recreation path

* improvement(webhooks): remove dead code

* fix tests

* address bugbot comments

* fix restoration edge case

* fix more edge cases

* address bugbot comments

* fix gmail polling

* add warnings for UI indication for credential sets

* fix(preview): subblock values (#2969)

* fix(child-workflow): nested spans handoff (#2966)

* fix(child-workflow): nested spans handoff

* remove overly defensive programming

* update type check

* type more code

* remove more dead code

* address bugbot comments

* fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt

* fix(copilot): update copilot chat title (#2968)

* fix(hitl): fix condition blocks after hitl (#2967)

* fix(notes): ghost edges (#2970)

* fix(notes): ghost edges

* fix deployed state fallback

* fallback

* remove UI level checks

* annotation missing from autoconnect source check

* improvement(docs): loop and parallel var reference syntax (#2975)

* fix(blog): slash actions description (#2976)

* improvement(docs): loop and parallel var reference syntax

* fix(blog): slash actions description

* fix(auth): copilot routes (#2977)

* Fix copilot auth

* Fix

* Fix

* Fix

* fix(copilot): fix edit summary for loops/parallels (#2978)

* fix(integrations): hide from tool bar (#2544)

* fix(landing): ui (#2979)

* fix(edge-validation): race condition on collaborative add (#2980)

* fix(variables): boolean type support and input improvements (#2981)

* fix(variables): boolean type support and input improvements

* fix formatting

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-24 14:29:53 -08:00
Waleed
e9c4251c1c v0.5.68: router block reasoning, executor improvements, variable resolution consolidation, helm updates (#2946)
* improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)

* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast

* fix(executor): stop parallel execution when block errors (#2940)

* improvement(helm): add per-deployment extraVolumes support (#2942)

* fix(gmail): expose messageId field in read email block (#2943)

* fix(resolver): consolidate reference resolution  (#2941)

* fix(resolver): consolidate code to resolve references

* fix edge cases

* use already formatted error

* fix multi index

* fix backwards compat reachability

* handle backwards compatibility accurately

* use shared constant correctly

* feat(router): expose reasoning output in router v2 block (#2945)

* fix(copilot): always allow, credential masking (#2947)

* Fix always allow, credential validation

* Credential masking

* Autoload

* fix(executor): handle condition dead-end branches in loops (#2944)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2026-01-22 13:48:15 -08:00
Waleed
cc2be33d6b v0.5.67: loading, password reset, ui improvements, helm updates (#2928)
* fix(zustand): updated to useShallow from deprecated createWithEqualityFn (#2919)

* fix(logger): use direct env access for webpack inlining (#2920)

* fix(notifications): text overflow with line-clamp (#2921)

* chore(helm): add env vars for Vertex AI, orgs, and telemetry (#2922)

* fix(auth): improve reset password flow and consolidate brand detection (#2924)

* fix(auth): improve reset password flow and consolidate brand detection

* fix(auth): set errorHandled for EMAIL_NOT_VERIFIED to prevent duplicate error

* fix(auth): clear success message on login errors

* chore(auth): fix import order per lint

* fix(action-bar): duplicate subflows with children (#2923)

* fix(action-bar): duplicate subflows with children

* fix(action-bar): add validateTriggerPaste for subflow duplicate

* fix(resolver): agent response format, input formats, root level (#2925)

* fix(resolvers): agent response format, input formats, root level

* fix response block initial seeding

* fix tests

* fix(messages-input): fix cursor alignment and auto-resize with overlay (#2926)

* fix(messages-input): fix cursor alignment and auto-resize with overlay

* fixed remaining zustand warnings

* fix(stores): remove dead code causing log spam on startup (#2927)

* fix(stores): remove dead code causing log spam on startup

* fix(stores): replace custom tools zustand store with react query cache

* improvement(ui): use BrandedButton and BrandedLink components (#2930)

- Refactor auth forms to use BrandedButton component
- Add BrandedLink component for changelog page
- Reduce code duplication in login, signup, reset-password forms
- Update star count default value

* fix(custom-tools): remove unsafe title fallback in getCustomTool (#2929)

* fix(custom-tools): remove unsafe title fallback in getCustomTool

* fix(custom-tools): restore title fallback in getCustomTool lookup

Custom tools are referenced by title (custom_${title}), not database ID.
The title fallback is required for client-side tool resolution to work.

* fix(null-bodies): empty bodies handling (#2931)

* fix(null-statuses): empty bodies handling

* address bugbot comment

* fix(token-refresh): microsoft, notion, x, linear (#2933)

* fix(microsoft): proactive refresh needed

* fix(x): missing token refresh flag

* notion and linear missing flag too

* address bugbot comment

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback (#2932)

* fix(auth): handle EMAIL_NOT_VERIFIED in onError callback

* refactor(auth): extract redirectToVerify helper to reduce duplication

* fix(workflow-selector): use dedicated selector for workflow dropdown (#2934)

* feat(workflow-block): preview (#2935)

* improvement(copilot): tool configs to show nested props (#2936)

* fix(auth): add genericOAuth providers to trustedProviders (#2937)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-21 22:53:25 -08:00
Vikhyath Mondreti
45371e521e v0.5.66: external http requests fix, ring highlighting 2026-01-21 02:55:39 -08:00
Waleed
0ce0f98aa5 v0.5.65: gemini updates, textract integration, ui updates (#2909)
* fix(google): wrap primitive tool responses for Gemini API compatibility (#2900)

* fix(canonical): copilot path + update parent (#2901)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output (#2902)

* fix(rss): add top-level title, link, pubDate fields to RSS trigger output

* fix(imap): add top-level fields to IMAP trigger output

* improvement(browseruse): add profile id param (#2903)

* improvement(browseruse): add profile id param

* make request a stub since we have directExec

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels (#2880)

* improvement(executor): upgraded abort controller to handle aborts for loops and parallels

* comments

* improvement(files): update execution for passing base64 strings (#2906)

* progress

* improvement(execution): update execution for passing base64 strings

* fix types

* cleanup comments

* path security vuln

* reject promise correctly

* fix redirect case

* remove proxy routes

* fix tests

* use ipaddr

* feat(tools): added textract, added v2 for mistral, updated tag dropdown (#2904)

* feat(tools): added textract

* cleanup

* ack pr comments

* reorder

* removed upload for textract async version

* fix additional fields dropdown in editor, update parser to leave validation to be done on the server

* added mistral v2, files v2, and finalized textract

* updated the rest of the old file patterns, updated mistral outputs for v2

* updated tag dropdown to parse non-operation fields as well

* updated extension finder

* cleanup

* added description for inputs to workflow

* use helper for internal route check

* fix tag dropdown merge conflict change

* remove duplicate code

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>

* fix(ui): change add inputs button to match output selector (#2907)

* fix(canvas): removed invite to workspace from canvas popover (#2908)

* fix(canvas): removed invite to workspace

* removed unused props

* fix(copilot): legacy tool display names (#2911)

* fix(a2a): canonical merge  (#2912)

* fix canonical merge

* fix empty array case

* fix(change-detection): copilot diffs have extra field (#2913)

* improvement(logs): improved logs ui bugs, added subflow disable UI (#2910)

* improvement(logs): improved logs ui bugs, added subflow disable UI

* added duplicate to action bar for subflows

* feat(broadcast): email v0.5 (#2905)

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
2026-01-20 23:54:55 -08:00
Waleed
dff1c9d083 v0.5.64: unsubscribe, search improvements, metrics, additional SSO configuration 2026-01-20 00:34:11 -08:00
Vikhyath Mondreti
b09f683072 v0.5.63: ui and performance improvements, more google tools 2026-01-18 15:22:42 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
132 changed files with 763 additions and 13393 deletions

View File

@@ -180,11 +180,6 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
<td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr>
<tr>
<td>Lock/Unlock a block</td>
<td>Hover block → Click lock icon (Admin only)</td>
<td><ActionImage src="/static/quick-reference/lock-block.png" alt="Lock block" /></td>
</tr>
<tr>
<td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td>

View File

@@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/>
{/* MANUAL-CONTENT-START:intro */}
The [Pulse](https://www.runpulse.com) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
With Pulse, you can:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form'
import SSOForm from '@/app/(auth)/sso/sso-form'
export const dynamic = 'force-dynamic'

View File

@@ -8,7 +8,6 @@ import { verifyCronAuth } from '@/lib/auth/internal'
const logger = createLogger('CleanupStaleExecutions')
const STALE_THRESHOLD_MINUTES = 30
const MAX_INT32 = 2_147_483_647
export async function GET(request: NextRequest) {
try {
@@ -46,14 +45,13 @@ export async function GET(request: NextRequest) {
try {
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
await db
.update(workflowExecutionLogs)
.set({
status: 'failed',
endedAt: new Date(),
totalDurationMs,
totalDurationMs: staleDurationMs,
executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],

View File

@@ -284,7 +284,7 @@ async function handleToolsCall(
content: [
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
],
isError: executeResult.success === false,
isError: !executeResult.success,
}
return NextResponse.json(createResponse(id, result))

View File

@@ -20,7 +20,6 @@ import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -502,18 +501,6 @@ export async function PUT(
}
}
if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
} catch (syncError) {
logger.error('Failed to sync usage limits after joining org', {
userId: session.user.id,
organizationId,
error: syncError,
})
}
}
logger.info(`Organization invitation ${status}`, {
organizationId,
invitationId,

View File

@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/ee/access-control/utils/permission-check'
} from '@/executor/utils/permission-check'
const logger = createLogger('OrganizationInvitations')

View File

@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('SubscriptionTransferAPI')
@@ -89,14 +88,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
)
}
await db
.update(subscription)
.set({ referenceId: organizationId })

View File

@@ -203,10 +203,6 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
}
updateData.billingBlocked = body.billingBlocked
// Clear the reason when unblocking
if (body.billingBlocked === false) {
updateData.billingBlockedReason = null
}
updated.push('billingBlocked')
}

View File

@@ -1,4 +1,6 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
@@ -6,7 +8,6 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
@@ -74,31 +75,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const { startBlockId, sourceSnapshot, input } = validation.data
const executionId = uuidv4()
// Run preprocessing checks (billing, rate limits, usage limits)
const preprocessResult = await preprocessExecution({
workflowId,
userId,
triggerType: 'manual',
executionId,
requestId,
checkRateLimit: false, // Manual executions don't rate limit
checkDeployment: false, // Run-from-block doesn't require deployment
})
const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!preprocessResult.success) {
const { error } = preprocessResult
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
workflowId,
error: error?.message,
statusCode: error?.statusCode,
})
return NextResponse.json(
{ error: error?.message || 'Execution blocked' },
{ status: error?.statusCode || 500 }
)
}
const workflowRecord = preprocessResult.workflowRecord
if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
}
@@ -110,7 +92,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
workflowId,
startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length,
billingActorUserId: preprocessResult.actorUserId,
})
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)

View File

@@ -567,7 +567,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext
) => {
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
@@ -580,7 +579,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockId,
blockName,
blockType,
executionOrder,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
@@ -619,7 +617,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
error: callbackData.output.error,
durationMs: callbackData.executionTime || 0,
startedAt: callbackData.startedAt,
executionOrder: callbackData.executionOrder,
endedAt: callbackData.endedAt,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
@@ -647,7 +644,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
output: callbackData.output,
durationMs: callbackData.executionTime || 0,
startedAt: callbackData.startedAt,
executionOrder: callbackData.executionOrder,
endedAt: callbackData.endedAt,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,

View File

@@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => {
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
}))
vi.doMock('@/ee/access-control/utils/permission-check', () => ({
vi.doMock('@/executor/utils/permission-check', () => ({
validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
constructor() {

View File

@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import {
InvitationsNotAllowedError,
validateInvitationsAllowed,
} from '@/ee/access-control/utils/permission-check'
} from '@/executor/utils/permission-check'
export const dynamic = 'force-dynamic'
@@ -38,6 +38,7 @@ export async function GET(req: NextRequest) {
}
try {
// Get all workspaces where the user has permissions
const userWorkspaces = await db
.select({ id: workspace.id })
.from(workspace)
@@ -54,8 +55,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ invitations: [] })
}
// Get all workspaceIds where the user is a member
const workspaceIds = userWorkspaces.map((w) => w.id)
// Find all invitations for those workspaces
const invitations = await db
.select()
.from(workspaceInvitation)

View File

@@ -14,11 +14,11 @@ import {
ChatMessageContainer,
EmailAuth,
PasswordAuth,
SSOAuth,
VoiceInterface,
} from '@/app/chat/components'
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
import SSOAuth from '@/ee/sso/components/sso-auth'
const logger = createLogger('ChatClient')

View File

@@ -1,5 +1,6 @@
export { default as EmailAuth } from './auth/email/email-auth'
export { default as PasswordAuth } from './auth/password/password-auth'
export { default as SSOAuth } from './auth/sso/sso-auth'
export { ChatErrorState } from './error-state/error-state'
export { ChatHeader } from './header/header'
export { ChatInput } from './input/input'

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { Knowledge } from './knowledge'
interface KnowledgePageProps {
@@ -23,6 +23,7 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) {
redirect('/')
}
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`)

View File

@@ -104,12 +104,14 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
}
return (
<div className='flex flex-col gap-[4px] rounded-[6px] bg-[var(--surface-1)] px-[8px] py-[6px]'>
<div className='flex min-w-0 items-center justify-between gap-[8px]'>
<span className='min-w-0 flex-1 truncate font-medium text-[12px] text-[var(--text-secondary)]'>
{file.name}
</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
{file.name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatFileSize(file.size)}
</span>
</div>
@@ -140,18 +142,20 @@ export function FileCards({ files, isExecutionFile = false, workspaceId }: FileC
}
return (
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Files ({files.length})
</span>
{files.map((file, index) => (
<FileCard
key={file.id || `file-${index}`}
file={file}
isExecutionFile={isExecutionFile}
workspaceId={workspaceId}
/>
))}
<div className='flex flex-col gap-[8px]'>
{files.map((file, index) => (
<FileCard
key={file.id || `file-${index}`}
file={file}
isExecutionFile={isExecutionFile}
workspaceId={workspaceId}
/>
))}
</div>
</div>
)
}

View File

@@ -18,7 +18,6 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
ExecutionSnapshot,
@@ -454,7 +453,7 @@ export const LogDetails = memo(function LogDetails({
Duration
</span>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
{formatDuration(log.duration, { precision: 2 }) || '—'}
{log.duration || '—'}
</span>
</div>

View File

@@ -6,11 +6,11 @@ import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate,
formatDuration,
getDisplayStatus,
LOG_COLUMNS,
StatusBadge,
@@ -113,7 +113,7 @@ const LogRow = memo(
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration, { precision: 2 }) || '—'}
{formatDuration(log.duration) || '—'}
</Badge>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import React from 'react'
import { format } from 'date-fns'
import { Badge } from '@/components/emcn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
@@ -363,14 +362,47 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
}
}
/**
* Format duration for display in logs UI
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
* @param duration - Duration string (e.g., "500ms") or null
* @returns Formatted duration string or null
*/
export function formatDuration(duration: string | null): string | null {
if (!duration) return null
// Extract numeric value from duration string (e.g., "500ms" -> 500)
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
if (!Number.isFinite(ms)) return duration
if (ms < 1000) {
return `${ms}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
}
/**
* Format latency value for display in dashboard UI
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
* @param ms - Latency in milliseconds (number)
* @returns Formatted latency string
*/
export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
return formatDuration(ms, { precision: 2 }) ?? '—'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
}
export const formatDate = (dateString: string) => {

View File

@@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
interface TemplatesPageProps {
params: Promise<{

View File

@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
@@ -49,7 +49,6 @@ export const ActionBar = memo(
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
const { handleRunFromBlock } = useWorkflowExecution()
@@ -85,28 +84,16 @@ export const ActionBar = memo(
)
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
const {
isEnabled,
horizontalHandles,
parentId,
parentType,
isLocked,
isParentLocked,
isParentDisabled,
} = useWorkflowStore(
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback(
(state) => {
const block = state.blocks[blockId]
const parentId = block?.data?.parentId
const parentBlock = parentId ? state.blocks[parentId] : undefined
return {
isEnabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType: parentBlock?.type,
isLocked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
parentType: parentId ? state.blocks[parentId]?.type : undefined,
}
},
[blockId]
@@ -174,28 +161,26 @@ export const ActionBar = memo(
{!isNoteBlock && !isInsideSubflow && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (canRunFromBlock && !disabled) {
handleRunFromBlockClick()
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !canRunFromBlock}
>
<PlayOutline className={ICON_SIZE} />
</Button>
</span>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (canRunFromBlock && !disabled) {
handleRunFromBlockClick()
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !canRunFromBlock}
>
<PlayOutline className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{(() => {
if (disabled) return getTooltipMessage('Run')
if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Execution in progress'
if (!dependenciesSatisfied) return 'Disabled: Run Blocks Before'
return 'Run'
if (!dependenciesSatisfied) return 'Run upstream blocks first'
return 'Run from block'
})()}
</Tooltip.Content>
</Tooltip.Root>
@@ -208,54 +193,18 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
// Can't enable if parent is disabled (must enable parent first)
const cantEnable = !isEnabled && isParentDisabled
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
if (!disabled) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
}
disabled={disabled}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: !isEnabled && isParentDisabled
? 'Parent container is disabled'
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
{userPermissions.canAdmin && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
// Can't unlock a block if its parent container is locked
if (!disabled && !(isLocked && isParentLocked)) {
collaborativeBatchToggleLocked([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || (isLocked && isParentLocked)}
>
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked && isParentLocked
? 'Parent container is locked'
: isLocked
? 'Unlock Block'
: 'Lock Block'}
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
@@ -267,21 +216,17 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) {
if (!disabled) {
handleDuplicateBlock()
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked}
disabled={disabled}
>
<Copy className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Duplicate Block')}
</Tooltip.Content>
<Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
</Tooltip.Root>
)}
@@ -292,12 +237,12 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) {
if (!disabled) {
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked}
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className={ICON_SIZE} />
@@ -307,9 +252,7 @@ export const ActionBar = memo(
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
)}
@@ -321,23 +264,19 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
)
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Remove from Subflow')}
</Tooltip.Content>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
</Tooltip.Root>
)}
@@ -347,19 +286,17 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) {
if (!disabled) {
collaborativeBatchRemoveBlocks([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked}
disabled={disabled}
>
<Trash2 className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
</Tooltip.Content>
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
</Tooltip.Root>
</div>
)

View File

@@ -20,9 +20,6 @@ export interface BlockInfo {
horizontalHandles: boolean
parentId?: string
parentType?: string
locked?: boolean
isParentLocked?: boolean
isParentDisabled?: boolean
}
/**
@@ -49,17 +46,10 @@ export interface BlockMenuProps {
showRemoveFromSubflow?: boolean
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
canRunFromBlock?: boolean
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
disableEdit?: boolean
/** Whether the user has edit permission (ignoring locked state) */
userCanEdit?: boolean
isExecuting?: boolean
/** Whether the selected block is a trigger (has no incoming edges) */
isPositionalTrigger?: boolean
/** Callback to toggle locked state of selected blocks */
onToggleLocked?: () => void
/** Whether the user has admin permissions */
canAdmin?: boolean
}
/**
@@ -88,22 +78,13 @@ export function BlockMenu({
showRemoveFromSubflow = false,
canRunFromBlock = false,
disableEdit = false,
userCanEdit = true,
isExecuting = false,
isPositionalTrigger = false,
onToggleLocked,
canAdmin = false,
}: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const allLocked = selectedBlocks.every((b) => b.locked)
const allUnlocked = selectedBlocks.every((b) => !b.locked)
// Can't unlock blocks that have locked parents
const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked)
// Can't enable blocks that have disabled parents
const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled)
const hasSingletonBlock = selectedBlocks.some(
(b) =>
@@ -127,12 +108,6 @@ export function BlockMenu({
return 'Toggle Enabled'
}
const getToggleLockedLabel = () => {
if (allLocked) return 'Unlock'
if (allUnlocked) return 'Lock'
return 'Toggle Lock'
}
return (
<Popover
open={isOpen}
@@ -164,7 +139,7 @@ export function BlockMenu({
</PopoverItem>
<PopoverItem
className='group'
disabled={!userCanEdit || !hasClipboard}
disabled={disableEdit || !hasClipboard}
onClick={() => {
onPaste()
onClose()
@@ -189,15 +164,13 @@ export function BlockMenu({
{!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit || hasBlockWithDisabledParent}
disabled={disableEdit}
onClick={() => {
if (!disableEdit && !hasBlockWithDisabledParent) {
onToggleEnabled()
onClose()
}
onToggleEnabled()
onClose()
}}
>
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
{getToggleEnabledLabel()}
</PopoverItem>
)}
{!allNoteBlocks && !isSubflow && (
@@ -222,19 +195,6 @@ export function BlockMenu({
Remove from Subflow
</PopoverItem>
)}
{canAdmin && onToggleLocked && (
<PopoverItem
disabled={hasBlockWithLockedParent}
onClick={() => {
if (!hasBlockWithLockedParent) {
onToggleLocked()
onClose()
}
}}
>
{hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()}
</PopoverItem>
)}
{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}
@@ -273,7 +233,7 @@ export function BlockMenu({
}
}}
>
Run
Run from block
</PopoverItem>
{/* Hide "Run until" for triggers - they're always at the start */}
{!hasTriggerBlock && (

View File

@@ -34,8 +34,6 @@ export interface CanvasMenuProps {
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
/** Whether the workflow has locked blocks (disables auto-layout) */
hasLockedBlocks?: boolean
}
/**
@@ -62,7 +60,6 @@ export function CanvasMenu({
disableEdit = false,
canUndo = false,
canRedo = false,
hasLockedBlocks = false,
}: CanvasMenuProps) {
return (
<Popover
@@ -132,12 +129,11 @@ export function CanvasMenu({
</PopoverItem>
<PopoverItem
className='group'
disabled={disableEdit || hasLockedBlocks}
disabled={disableEdit}
onClick={() => {
onAutoLayout()
onClose()
}}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
>
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>

View File

@@ -3,7 +3,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
@@ -242,11 +241,15 @@ export function ThinkingBlock({
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
/** Formats duration in milliseconds to seconds (minimum 1s) */
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const hasContent = cleanContent.length > 0
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
// Round to nearest second (minimum 1s) to match original behavior
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${label} for ${formatDuration(roundedMs)}`
const durationText = `${label} for ${formatDuration(duration)}`
const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking'

View File

@@ -15,7 +15,6 @@ import {
hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
@@ -849,10 +848,13 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
(allParsed.options && Object.keys(allParsed.options).length > 0)
)
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const outerLabel = getSubagentCompletionLabel(toolCall.name)
// Round to nearest second (minimum 1s) to match original behavior
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
const durationText = `${outerLabel} for ${formatDuration(duration)}`
const renderCollapsibleContent = () => (
<>

View File

@@ -45,7 +45,7 @@ export function CredentialSelector({
previewValue,
}: CredentialSelectorProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
@@ -128,7 +128,11 @@ export function CredentialSelector({
return ''
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
const displayValue = isEditing ? editingValue : resolvedLabel
useEffect(() => {
if (!isEditing) {
setInputValue(resolvedLabel)
}
}, [resolvedLabel, isEditing])
const invalidSelection =
!isPreview &&
@@ -291,7 +295,7 @@ export function CredentialSelector({
const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => {
if (!displayValue) return null
if (!inputValue) return null
if (isCredentialSetSelected && selectedCredentialSet) {
return (
@@ -299,7 +303,7 @@ export function CredentialSelector({
<div className='mr-2 flex-shrink-0 opacity-90'>
<Users className='h-3 w-3' />
</div>
<span className='truncate'>{displayValue}</span>
<span className='truncate'>{inputValue}</span>
</div>
)
}
@@ -309,12 +313,12 @@ export function CredentialSelector({
<div className='mr-2 flex-shrink-0 opacity-90'>
{getProviderIcon(selectedCredentialProvider)}
</div>
<span className='truncate'>{displayValue}</span>
<span className='truncate'>{inputValue}</span>
</div>
)
}, [
getProviderIcon,
displayValue,
inputValue,
selectedCredentialProvider,
isCredentialSetSelected,
selectedCredentialSet,
@@ -331,6 +335,7 @@ export function CredentialSelector({
const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
if (matchedSet) {
setInputValue(matchedSet.name)
handleCredentialSetSelect(credentialSetId)
return
}
@@ -338,12 +343,13 @@ export function CredentialSelector({
const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) {
setInputValue(matchedCred.name)
handleSelect(value)
return
}
setIsEditing(true)
setEditingValue(value)
setInputValue(value)
},
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
)
@@ -353,7 +359,7 @@ export function CredentialSelector({
<Combobox
options={comboboxOptions}
groups={comboboxGroups}
value={displayValue}
value={inputValue}
selectedValue={rawSelectedId}
onChange={handleComboboxChange}
onOpenChange={handleOpenChange}

View File

@@ -908,10 +908,8 @@ const PopoverContextCapture: React.FC<{
* When in nested folders, goes back one level at a time.
* At the root folder level, closes the folder.
*/
const TagDropdownBackButton: React.FC<{ setSelectedIndex: (index: number) => void }> = ({
setSelectedIndex,
}) => {
const { isInFolder, closeFolder, size, isKeyboardNav, setKeyboardNav } = usePopoverContext()
const TagDropdownBackButton: React.FC = () => {
const { isInFolder, closeFolder, colorScheme, size } = usePopoverContext()
const nestedNav = useNestedNavigation()
if (!isInFolder) return null
@@ -924,31 +922,28 @@ const TagDropdownBackButton: React.FC<{ setSelectedIndex: (index: number) => voi
closeFolder()
}
const handleMouseEnter = () => {
if (isKeyboardNav) return
setKeyboardNav(false)
setSelectedIndex(-1)
}
return (
<PopoverItem
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleBackClick(e)
}}
onMouseEnter={handleMouseEnter}
<div
className={cn(
'flex min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base',
size === 'sm' ? 'h-[22px] text-[11px]' : 'h-[26px] text-[13px]',
colorScheme === 'inverted'
? 'text-white hover:bg-[#363636] hover:text-white dark:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
: 'text-[var(--text-primary)] hover:bg-[var(--border-1)]'
)}
role='button'
onClick={handleBackClick}
>
<svg
className={cn('shrink-0', size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5')}
className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'}
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M15 19l-7-7 7-7' />
</svg>
<span className='shrink-0'>Back</span>
</PopoverItem>
<span>Back</span>
</div>
)
}
@@ -1966,8 +1961,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<TagDropdownBackButton />
<PopoverScrollArea ref={scrollAreaRef}>
<TagDropdownBackButton setSelectedIndex={setSelectedIndex} />
{flatTagList.length === 0 ? (
<div className='px-[6px] py-[8px] text-[12px] text-[var(--white)]/60'>
No matching tags found

View File

@@ -9,9 +9,7 @@ import {
ChevronUp,
ExternalLink,
Loader2,
Lock,
Pencil,
Unlock,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
@@ -48,17 +46,10 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/** Shared style for dashed divider lines */
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
/**
* Icon component for rendering block icons.
*
@@ -98,31 +89,31 @@ export function Editor() {
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
const title = currentBlock?.name || 'Editor'
// Check if selected block is a subflow (loop or parallel)
const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Check if selected block is a workflow block
const isWorkflowBlock =
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
// Get workspace ID from params
const params = useParams()
const workspaceId = params.workspaceId as string
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null)
// Get user permissions
const userPermissions = useUserPermissionsContext()
// Check if block is locked (or inside a locked container) and compute edit permission
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
const blocks = useWorkflowStore((state) => state.blocks)
const parentId = currentBlock?.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
const canEditBlock = userPermissions.canEdit && !isLocked
// Get active workflow ID
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Get block properties (advanced/trigger modes)
const { advancedMode, triggerMode } = useEditorBlockProperties(
currentBlockId,
currentWorkflow.isSnapshotView
@@ -154,17 +145,22 @@ export function Editor() {
[subBlocksForCanonical]
)
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
const advancedValuesPresent = useMemo(
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
const advancedValuesPresent = hasAdvancedValues(
subBlocksForCanonical,
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent
const displayAdvancedOptions = userPermissions.canEdit
? advancedMode
: advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
if (subBlock.mode !== 'advanced') continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
// Check condition - skip if condition not met for current values
if (
subBlock.condition &&
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
@@ -177,6 +173,7 @@ export function Editor() {
return false
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
blockConfig || ({} as any),
currentBlockId || '',
@@ -209,44 +206,40 @@ export function Editor() {
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
// Get block connections
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
// Connections resize hook
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
subBlocksRef,
})
// Collaborative actions
const {
collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !canEditBlock) return
if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
// Rename state
const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('')
/**
* Ref callback that auto-selects the input text when mounted.
*/
const nameInputRefCallback = useCallback((element: HTMLInputElement | null) => {
if (element) {
element.select()
}
}, [])
const nameInputRef = useRef<HTMLInputElement>(null)
/**
* Handles starting the rename process.
*/
const handleStartRename = useCallback(() => {
if (!canEditBlock || !currentBlock) return
if (!userPermissions.canEdit || !currentBlock) return
setEditedName(currentBlock.name || '')
setIsRenaming(true)
}, [canEditBlock, currentBlock])
}, [userPermissions.canEdit, currentBlock])
/**
* Handles saving the renamed block.
@@ -258,6 +251,7 @@ export function Editor() {
if (trimmedName && trimmedName !== currentBlock?.name) {
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
if (!result.success) {
// Keep rename mode open on error so user can correct the name
return
}
}
@@ -272,6 +266,14 @@ export function Editor() {
setEditedName('')
}, [])
// Focus input when entering rename mode
useEffect(() => {
if (isRenaming && nameInputRef.current) {
nameInputRef.current.select()
}
}, [isRenaming])
// Trigger rename mode when signaled from context menu
useEffect(() => {
if (shouldFocusRename && currentBlock) {
handleStartRename()
@@ -282,13 +284,17 @@ export function Editor() {
/**
* Handles opening documentation link in a new secure tab.
*/
const handleOpenDocs = useCallback(() => {
const handleOpenDocs = () => {
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
if (docsLink) {
window.open(docsLink, '_blank', 'noopener,noreferrer')
}
}
// Get child workflow ID for workflow blocks
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
useWorkflowState(childWorkflowId)
@@ -301,6 +307,7 @@ export function Editor() {
}
}, [childWorkflowId, workspaceId])
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35
return (
@@ -321,7 +328,7 @@ export function Editor() {
)}
{isRenaming ? (
<input
ref={nameInputRefCallback}
ref={nameInputRef}
type='text'
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
@@ -351,36 +358,6 @@ export function Editor() {
)}
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
{isLocked && currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
<Button
variant='ghost'
className='p-0'
onClick={() => collaborativeBatchToggleLocked([currentBlockId!])}
aria-label='Unlock block'
>
<Unlock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</Button>
) : (
<div className='flex items-center justify-center'>
<Lock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</div>
)}
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{isParentLocked
? 'Parent container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Rename button */}
{currentBlock && (
<Tooltip.Root>
@@ -389,7 +366,7 @@ export function Editor() {
variant='ghost'
className='p-0'
onClick={isRenaming ? handleSaveRename : handleStartRename}
disabled={!canEditBlock}
disabled={!userPermissions.canEdit}
aria-label={isRenaming ? 'Save name' : 'Rename block'}
>
{isRenaming ? (
@@ -422,21 +399,23 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)} */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleOpenDocs}
aria-label='Open documentation'
>
<BookOpen className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Open docs</p>
</Tooltip.Content>
</Tooltip.Root>
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleOpenDocs}
aria-label='Open documentation'
>
<BookOpen className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Open docs</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
@@ -455,7 +434,7 @@ export function Editor() {
incomingConnections={incomingConnections}
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
userCanEdit={canEditBlock}
userCanEdit={userPermissions.canEdit}
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
/>
) : (
@@ -516,7 +495,13 @@ export function Editor() {
</div>
</div>
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
</>
)}
@@ -557,14 +542,14 @@ export function Editor() {
config={subBlock}
isPreview={false}
subBlockValues={subBlockState}
disabled={!canEditBlock}
disabled={!userPermissions.canEdit}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId
? {
mode: canonicalMode,
disabled: !canEditBlock,
disabled: !userPermissions.canEdit,
onToggle: () => {
if (!currentBlockId) return
const nextMode =
@@ -581,16 +566,28 @@ export function Editor() {
/>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>
)
})}
{hasAdvancedOnlyFields && canEditBlock && (
{hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
<button
type='button'
onClick={handleToggleAdvancedMode}
@@ -603,7 +600,13 @@ export function Editor() {
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/>
</button>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
@@ -621,13 +624,19 @@ export function Editor() {
config={subBlock}
isPreview={false}
subBlockValues={subBlockState}
disabled={!canEditBlock}
disabled={!userPermissions.canEdit}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
/>
{index < advancedOnlySubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
</div>

View File

@@ -45,13 +45,11 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
import type { PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Panel')
/**
@@ -121,11 +119,6 @@ export const Panel = memo(function Panel() {
hydration.phase === 'state-loading'
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Check for locked blocks (disables auto-layout)
const hasLockedBlocks = useWorkflowStore((state) =>
Object.values(state.blocks).some((block) => block.locked)
)
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
@@ -237,24 +230,11 @@ export const Panel = memo(function Panel() {
setIsAutoLayouting(true)
try {
const result = await autoLayoutWithFitView()
if (!result.success && result.error) {
useNotificationStore.getState().addNotification({
level: 'info',
message: result.error,
workflowId: activeWorkflowId || undefined,
})
}
await autoLayoutWithFitView()
} finally {
setIsAutoLayouting(false)
}
}, [
isExecuting,
userPermissions.canEdit,
isAutoLayouting,
autoLayoutWithFitView,
activeWorkflowId,
])
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView])
/**
* Handles exporting workflow as JSON
@@ -424,10 +404,7 @@ export const Panel = memo(function Panel() {
<PopoverContent align='start' side='bottom' sideOffset={8}>
<PopoverItem
onClick={handleAutoLayout}
disabled={
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
disabled={isExecuting || !userPermissions.canEdit || isAutoLayouting}
>
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span>

View File

@@ -80,7 +80,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
: undefined
const isEnabled = currentBlock?.enabled ?? true
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false
// Focus state
@@ -201,10 +200,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
{blockName}
</span>
</div>
<div className='flex items-center gap-1'>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
</div>
{!isPreview && (

View File

@@ -105,9 +105,11 @@ export function useTerminalFilters() {
})
}
// Sort by executionOrder (monotonically increasing integer from server)
// Apply sorting by timestamp
result = [...result].sort((a, b) => {
const comparison = a.executionOrder - b.executionOrder
const timeA = new Date(a.timestamp).getTime()
const timeB = new Date(b.timestamp).getTime()
const comparison = timeA - timeB
return sortConfig.direction === 'asc' ? comparison : -comparison
})

View File

@@ -24,7 +24,6 @@ import {
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { formatDuration } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -44,6 +43,7 @@ import {
type EntryNode,
type ExecutionGroup,
flattenBlockEntriesOnly,
formatDuration,
getBlockColor,
getBlockIcon,
groupEntriesByExecution,
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
<StatusDisplay
isRunning={isRunning}
isCanceled={isCanceled}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
formattedDuration={formatDuration(entry.durationMs)}
/>
</span>
</div>
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
<StatusDisplay
isRunning={hasRunningChild}
isCanceled={hasCanceledChild}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
formattedDuration={formatDuration(entry.durationMs)}
/>
</span>
</div>
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
<StatusDisplay
isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'}
formattedDuration={formatDuration(entry.durationMs)}
/>
</span>
</div>

View File

@@ -53,6 +53,17 @@ export function getBlockColor(blockType: string): string {
return '#6b7280'
}
/**
* Formats duration from milliseconds to readable format
*/
export function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '-'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Determines if a keyboard event originated from a text-editable element
*/
@@ -184,9 +195,13 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
group.blocks.push(entry)
}
// Sort blocks within each iteration by executionOrder ascending (oldest first, top-down)
// Sort blocks within each iteration by start time ascending (oldest first, top-down)
for (const group of iterationGroupsMap.values()) {
group.blocks.sort((a, b) => a.executionOrder - b.executionOrder)
group.blocks.sort((a, b) => {
const aStart = new Date(a.startedAt || a.timestamp).getTime()
const bStart = new Date(b.startedAt || b.timestamp).getTime()
return aStart - bStart
})
}
// Group iterations by iterationType to create subflow parents
@@ -221,8 +236,6 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
// Create synthetic subflow parent entry
// Use the minimum executionOrder from all child blocks for proper ordering
const subflowExecutionOrder = Math.min(...allBlocks.map((b) => b.executionOrder))
const syntheticSubflow: ConsoleEntry = {
id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(subflowStartMs).toISOString(),
@@ -232,7 +245,6 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
blockType: iterationType,
executionId: firstIteration.blocks[0]?.executionId,
startedAt: new Date(subflowStartMs).toISOString(),
executionOrder: subflowExecutionOrder,
endedAt: new Date(subflowEndMs).toISOString(),
durationMs: totalDuration,
success: !allBlocks.some((b) => b.error),
@@ -250,8 +262,6 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
)
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
// Use the minimum executionOrder from blocks in this iteration
const iterExecutionOrder = Math.min(...iterBlocks.map((b) => b.executionOrder))
const syntheticIteration: ConsoleEntry = {
id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(iterStartMs).toISOString(),
@@ -261,7 +271,6 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
blockType: iterationType,
executionId: iterBlocks[0]?.executionId,
startedAt: new Date(iterStartMs).toISOString(),
executionOrder: iterExecutionOrder,
endedAt: new Date(iterEndMs).toISOString(),
durationMs: iterDuration,
success: !iterBlocks.some((b) => b.error),
@@ -302,9 +311,14 @@ function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
nodeType: 'block' as const,
}))
// Combine all nodes and sort by executionOrder ascending (oldest first, top-down)
// Combine all nodes and sort by start time ascending (oldest first, top-down)
const allNodes = [...subflowNodes, ...regularNodes]
allNodes.sort((a, b) => a.entry.executionOrder - b.entry.executionOrder)
allNodes.sort((a, b) => {
const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
return aStart - bStart
})
return allNodes
}

View File

@@ -30,7 +30,6 @@ import {
Textarea,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
@@ -576,9 +575,7 @@ export function TrainingModal() {
<span className='text-[var(--text-muted)]'>Duration:</span>{' '}
<span className='text-[var(--text-secondary)]'>
{dataset.metadata?.duration
? formatDuration(dataset.metadata.duration, {
precision: 1,
})
? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
: 'N/A'}
</span>
</div>

View File

@@ -18,8 +18,6 @@ export interface UseBlockStateReturn {
diffStatus: DiffStatus
/** Whether this is a deleted block in diff mode */
isDeletedBlock: boolean
/** Whether the block is locked */
isLocked: boolean
}
/**
@@ -42,11 +40,6 @@ export function useBlockState(
? (data.blockState?.enabled ?? true)
: (currentBlock?.enabled ?? true)
// Determine if block is locked
const isLocked = data.isPreview
? (data.blockState?.locked ?? false)
: (currentBlock?.locked ?? false)
// Get diff status
const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
@@ -75,6 +68,5 @@ export function useBlockState(
isActive,
diffStatus,
isDeletedBlock: isDeletedBlock ?? false,
isLocked,
}
}

View File

@@ -672,7 +672,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow,
activeWorkflowId,
isEnabled,
isLocked,
handleClick,
hasRing,
ringStyles,
@@ -1101,7 +1100,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{name}
</span>
</div>
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
{isWorkflowSelector &&
childWorkflowId &&
typeof childIsDeployed === 'boolean' &&
@@ -1134,7 +1133,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Root>
)}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
<Tooltip.Root>

View File

@@ -47,7 +47,6 @@ export function useBlockVisual({
isActive: isExecuting,
diffStatus,
isDeletedBlock,
isLocked,
} = useBlockState(blockId, currentWorkflow, data)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
@@ -104,7 +103,6 @@ export function useBlockVisual({
currentWorkflow,
activeWorkflowId,
isEnabled,
isLocked,
handleClick,
hasRing,
ringStyles,

View File

@@ -31,8 +31,7 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
nodes.map((n) => {
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentBlock = parentId ? blocks[parentId] : undefined
const parentType = parentBlock?.type
const parentType = parentId ? blocks[parentId]?.type : undefined
return {
id: n.id,
type: block?.type || '',
@@ -40,9 +39,6 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
locked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
}
}),
[blocks]

View File

@@ -926,7 +926,6 @@ export function useWorkflowExecution() {
})
// Add entry to terminal immediately with isRunning=true
// Use server-provided executionOrder to ensure correct sort order
const startedAt = new Date().toISOString()
addConsole({
input: {},
@@ -934,7 +933,6 @@ export function useWorkflowExecution() {
success: undefined,
durationMs: undefined,
startedAt,
executionOrder: data.executionOrder,
endedAt: undefined,
workflowId: activeWorkflowId,
blockId: data.blockId,
@@ -950,6 +948,8 @@ export function useWorkflowExecution() {
},
onBlockCompleted: (data) => {
logger.info('onBlockCompleted received:', { data })
activeBlocksSet.delete(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
setBlockRunStatus(data.blockId, 'success')
@@ -976,7 +976,6 @@ export function useWorkflowExecution() {
success: true,
durationMs: data.durationMs,
startedAt,
executionOrder: data.executionOrder,
endedAt,
})
@@ -988,7 +987,6 @@ export function useWorkflowExecution() {
replaceOutput: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
isRunning: false,
// Pass through iteration context for subflow grouping
@@ -1029,7 +1027,6 @@ export function useWorkflowExecution() {
error: data.error,
durationMs: data.durationMs,
startedAt,
executionOrder: data.executionOrder,
endedAt,
})
@@ -1042,7 +1039,6 @@ export function useWorkflowExecution() {
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
isRunning: false,
// Pass through iteration context for subflow grouping
@@ -1167,7 +1163,6 @@ export function useWorkflowExecution() {
if (existingLogs.length === 0) {
// No blocks executed yet - this is a pre-execution error
// Use 0 for executionOrder so validation errors appear first
addConsole({
input: {},
output: {},
@@ -1175,7 +1170,6 @@ export function useWorkflowExecution() {
error: data.error,
durationMs: data.duration || 0,
startedAt: new Date(Date.now() - (data.duration || 0)).toISOString(),
executionOrder: 0,
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: 'validation',
@@ -1243,7 +1237,6 @@ export function useWorkflowExecution() {
blockType = error.blockType || blockType
}
// Use MAX_SAFE_INTEGER so execution errors appear at the end of the log
useTerminalConsoleStore.getState().addConsole({
input: {},
output: {},
@@ -1251,7 +1244,6 @@ export function useWorkflowExecution() {
error: normalizedMessage,
durationMs: 0,
startedAt: new Date().toISOString(),
executionOrder: Number.MAX_SAFE_INTEGER,
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId || '',
blockId,
@@ -1623,7 +1615,6 @@ export function useWorkflowExecution() {
success: true,
durationMs: data.durationMs,
startedAt,
executionOrder: data.executionOrder,
endedAt,
})
@@ -1633,7 +1624,6 @@ export function useWorkflowExecution() {
success: true,
durationMs: data.durationMs,
startedAt,
executionOrder: data.executionOrder,
endedAt,
workflowId,
blockId: data.blockId,
@@ -1663,7 +1653,6 @@ export function useWorkflowExecution() {
output: {},
success: false,
error: data.error,
executionOrder: data.executionOrder,
durationMs: data.durationMs,
startedAt,
endedAt,
@@ -1676,7 +1665,6 @@ export function useWorkflowExecution() {
error: data.error,
durationMs: data.durationMs,
startedAt,
executionOrder: data.executionOrder,
endedAt,
workflowId,
blockId: data.blockId,

View File

@@ -52,16 +52,6 @@ export async function applyAutoLayoutAndUpdateStore(
return { success: false, error: 'No blocks to layout' }
}
// Check for locked blocks - auto-layout is disabled when blocks are locked
const hasLockedBlocks = Object.values(blocks).some((block) => block.locked)
if (hasLockedBlocks) {
logger.info('Auto layout skipped: workflow contains locked blocks', { workflowId })
return {
success: false,
error: 'Auto-layout is disabled when blocks are locked. Unlock blocks to use auto-layout.',
}
}
// Merge with default options
const layoutOptions = {
spacing: {

View File

@@ -1,72 +0,0 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Result of filtering protected blocks from a deletion operation
*/
export interface FilterProtectedBlocksResult {
/** Block IDs that can be deleted (not protected) */
deletableIds: string[]
/** Block IDs that are protected and cannot be deleted */
protectedIds: string[]
/** Whether all blocks are protected (deletion should be cancelled entirely) */
allProtected: boolean
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if its parent container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
const block = blocks[blockId]
if (!block) return false
// Block is locked directly
if (block.locked) return true
// Block is inside a locked container
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
/**
* Checks if an edge is protected from modification.
* An edge is protected if either its source or target block is protected.
*
* @param edge - The edge to check (must have source and target)
* @param blocks - Record of all blocks in the workflow
* @returns True if the edge is protected
*/
export function isEdgeProtected(
edge: { source: string; target: string },
blocks: Record<string, BlockState>
): boolean {
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
}
/**
* Filters out protected blocks from a list of block IDs for deletion.
* Protected blocks are those that are locked or inside a locked container.
*
* @param blockIds - Array of block IDs to filter
* @param blocks - Record of all blocks in the workflow
* @returns Result containing deletable IDs, protected IDs, and whether all are protected
*/
export function filterProtectedBlocks(
blockIds: string[],
blocks: Record<string, BlockState>
): FilterProtectedBlocksResult {
const protectedIds = blockIds.filter((id) => isBlockProtected(id, blocks))
const deletableIds = blockIds.filter((id) => !protectedIds.includes(id))
return {
deletableIds,
protectedIds,
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
}
}

View File

@@ -1,5 +1,4 @@
export * from './auto-layout-utils'
export * from './block-protection-utils'
export * from './block-ring-utils'
export * from './node-position-utils'
export * from './workflow-canvas-helpers'

View File

@@ -111,7 +111,6 @@ export async function executeWorkflowWithFullLogging(
success: true,
durationMs: event.data.durationMs,
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
executionOrder: event.data.executionOrder,
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: event.data.blockId,
@@ -141,7 +140,6 @@ export async function executeWorkflowWithFullLogging(
error: event.data.error,
durationMs: event.data.durationMs,
startedAt: new Date(Date.now() - event.data.durationMs).toISOString(),
executionOrder: event.data.executionOrder,
endedAt: new Date().toISOString(),
workflowId: activeWorkflowId,
blockId: event.data.blockId,

View File

@@ -55,10 +55,7 @@ import {
clearDragHighlights,
computeClampedPositionUpdates,
estimateBlockDimensions,
filterProtectedBlocks,
getClampedPositionForNode,
isBlockProtected,
isEdgeProtected,
isInEditableElement,
resolveParentChildSelectionConflicts,
validateTriggerPaste,
@@ -546,7 +543,6 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
undo,
redo,
} = useCollaborativeWorkflow()
@@ -1073,27 +1069,8 @@ const WorkflowContent = React.memo(() => {
const handleContextDelete = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks)
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks, addNotification, activeWorkflowId, blocks])
collaborativeBatchRemoveBlocks(blockIds)
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
const handleContextToggleEnabled = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
@@ -1105,11 +1082,6 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
const handleContextToggleLocked = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
collaborativeBatchToggleLocked(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1173,7 +1145,7 @@ const WorkflowContent = React.memo(() => {
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
if (isInsideSubflow) return { canRun: false, reason: 'Cannot run from inside subflow' }
if (!dependenciesSatisfied) return { canRun: false, reason: 'Disabled: Run Blocks Before' }
if (!dependenciesSatisfied) return { canRun: false, reason: 'Run upstream blocks first' }
if (isNoteBlock) return { canRun: false, reason: undefined }
if (isExecuting) return { canRun: false, reason: undefined }
@@ -1979,6 +1951,7 @@ const WorkflowContent = React.memo(() => {
const loadingWorkflowRef = useRef<string | null>(null)
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
/** Initializes workflow when it exists in registry and needs hydration. */
useEffect(() => {
const currentId = workflowIdParam
const currentWorkspaceHydration = hydration.workspaceId
@@ -2155,7 +2128,6 @@ const WorkflowContent = React.memo(() => {
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
dragHandle: '.workflow-drag-handle',
draggable: !isBlockProtected(block.id, blocks),
data: {
...block.data,
name: block.name,
@@ -2191,7 +2163,6 @@ const WorkflowContent = React.memo(() => {
position,
parentId: block.data?.parentId,
dragHandle,
draggable: !isBlockProtected(block.id, blocks),
extent: (() => {
// Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined
@@ -2520,18 +2491,12 @@ const WorkflowContent = React.memo(() => {
const edgeIdsToRemove = changes
.filter((change: any) => change.type === 'remove')
.map((change: any) => change.id)
.filter((edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIdsToRemove.length > 0) {
collaborativeBatchRemoveEdges(edgeIdsToRemove)
}
},
[collaborativeBatchRemoveEdges, edges, blocks]
[collaborativeBatchRemoveEdges]
)
/**
@@ -2593,16 +2558,6 @@ const WorkflowContent = React.memo(() => {
if (!sourceNode || !targetNode) return
// Prevent connections to/from protected blocks
if (isEdgeProtected(connection, blocks)) {
addNotification({
level: 'info',
message: 'Cannot connect to locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
// Get parent information (handle container start node case)
const sourceParentId =
blocks[sourceNode.id]?.data?.parentId ||
@@ -2665,7 +2620,7 @@ const WorkflowContent = React.memo(() => {
connectionCompletedRef.current = true
}
},
[addEdge, getNodes, blocks, addNotification, activeWorkflowId]
[addEdge, getNodes, blocks]
)
/**
@@ -2760,9 +2715,6 @@ const WorkflowContent = React.memo(() => {
// Only consider container nodes that aren't the dragged node
if (n.type !== 'subflowNode' || n.id === node.id) return false
// Don't allow dropping into locked containers
if (blocks[n.id]?.locked) return false
// Get the container's absolute position
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
@@ -2855,8 +2807,6 @@ const WorkflowContent = React.memo(() => {
/** Captures initial parent ID and position when drag starts. */
const onNodeDragStart = useCallback(
(_event: React.MouseEvent, node: any) => {
// Note: Protected blocks are already non-draggable via the `draggable` node property
// Store the original parent ID when starting to drag
const currentParentId = blocks[node.id]?.data?.parentId || null
setDragStartParentId(currentParentId)
@@ -2885,7 +2835,7 @@ const WorkflowContent = React.memo(() => {
}
})
},
[blocks, setDragStartPosition, getNodes, setPotentialParentId]
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
)
/** Handles node drag stop to establish parent-child relationships. */
@@ -2947,18 +2897,6 @@ const WorkflowContent = React.memo(() => {
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
if (potentialParentId === dragStartParentId) return
// Prevent moving locked blocks out of locked containers
// Unlocked blocks (e.g., duplicates) can be moved out freely
if (dragStartParentId && blocks[dragStartParentId]?.locked && blocks[node.id]?.locked) {
addNotification({
level: 'info',
message: 'Cannot move locked blocks out of locked containers',
workflowId: activeWorkflowId || undefined,
})
setPotentialParentId(dragStartParentId) // Reset to original parent
return
}
// Check if this is a starter block - starter blocks should never be in containers
const isStarterBlock = node.data?.type === 'starter'
if (isStarterBlock) {
@@ -3355,16 +3293,6 @@ const WorkflowContent = React.memo(() => {
/** Stable delete handler to avoid creating new function references per edge. */
const handleEdgeDelete = useCallback(
(edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (edge && isEdgeProtected(edge, blocks)) {
addNotification({
level: 'info',
message: 'Cannot remove connections from locked blocks',
workflowId: activeWorkflowId || undefined,
})
return
}
removeEdge(edgeId)
// Remove this edge from selection (find by edge ID value)
setSelectedEdges((prev) => {
@@ -3377,7 +3305,7 @@ const WorkflowContent = React.memo(() => {
return next
})
},
[removeEdge, edges, blocks, addNotification, activeWorkflowId]
[removeEdge]
)
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
@@ -3418,15 +3346,9 @@ const WorkflowContent = React.memo(() => {
// Handle edge deletion first (edges take priority if selected)
if (selectedEdges.size > 0) {
// Get all selected edge IDs and filter out edges connected to protected blocks
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIds.length > 0) {
collaborativeBatchRemoveEdges(edgeIds)
}
// Get all selected edge IDs and batch delete them
const edgeIds = Array.from(selectedEdges.values())
collaborativeBatchRemoveEdges(edgeIds)
setSelectedEdges(new Map())
return
}
@@ -3443,29 +3365,7 @@ const WorkflowContent = React.memo(() => {
event.preventDefault()
const selectedIds = selectedNodes.map((node) => node.id)
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(
selectedIds,
blocks
)
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
collaborativeBatchRemoveBlocks(selectedIds)
}
window.addEventListener('keydown', handleKeyDown)
@@ -3476,10 +3376,6 @@ const WorkflowContent = React.memo(() => {
getNodes,
collaborativeBatchRemoveBlocks,
effectivePermissions.canEdit,
blocks,
edges,
addNotification,
activeWorkflowId,
])
return (
@@ -3600,18 +3496,12 @@ const WorkflowContent = React.memo(() => {
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)}
canRunFromBlock={runFromBlockState.canRun}
disableEdit={
!effectivePermissions.canEdit ||
contextMenuBlocks.some((b) => b.locked || b.isParentLocked)
}
userCanEdit={effectivePermissions.canEdit}
disableEdit={!effectivePermissions.canEdit}
isExecuting={isExecuting}
isPositionalTrigger={
contextMenuBlocks.length === 1 &&
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
}
onToggleLocked={handleContextToggleLocked}
canAdmin={effectivePermissions.canAdmin}
/>
<CanvasMenu
@@ -3634,7 +3524,6 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit}
canUndo={canUndo}
canRedo={canRedo}
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
/>
</>
)}

View File

@@ -29,6 +29,7 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
import { getAllBlocks } from '@/blocks'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import {
type PermissionGroup,
useBulkAddPermissionGroupMembers,
@@ -38,8 +39,7 @@ import {
usePermissionGroups,
useRemovePermissionGroupMember,
useUpdatePermissionGroup,
} from '@/ee/access-control/hooks/permission-groups'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
} from '@/hooks/queries/permission-groups'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { getAllProviderIds } from '@/providers/utils'
@@ -255,6 +255,7 @@ export function AccessControl() {
queryEnabled
)
// Show loading while dependencies load, or while permission groups query is pending
const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading)
const { data: organization } = useOrganization(activeOrganization?.id || '')
@@ -409,8 +410,10 @@ export function AccessControl() {
}, [viewingGroup, editingConfig])
const allBlocks = useMemo(() => {
// Filter out hidden blocks and start_trigger (which should never be disabled)
const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
return blocks.sort((a, b) => {
// Group by category: triggers first, then blocks, then tools
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
const catA = categoryOrder[a.category] ?? 3
const catB = categoryOrder[b.category] ?? 3
@@ -552,9 +555,10 @@ export function AccessControl() {
}, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup])
const handleOpenAddMembersModal = useCallback(() => {
const existingMemberUserIds = new Set(members.map((m) => m.userId))
setSelectedMemberIds(new Set())
setShowAddMembersModal(true)
}, [])
}, [members])
const handleAddSelectedMembers = useCallback(async () => {
if (!viewingGroup || selectedMemberIds.size === 0) return
@@ -887,6 +891,7 @@ export function AccessControl() {
prev
? {
...prev,
// When deselecting all, keep start_trigger allowed (it should never be disabled)
allowedIntegrations: allAllowed ? ['start_trigger'] : null,
}
: prev

View File

@@ -246,6 +246,7 @@ export function CredentialSets() {
setNewSetDescription('')
setNewSetProvider('google-email')
// Open detail view for the newly created group
if (result?.credentialSet) {
setViewingSet(result.credentialSet)
}
@@ -335,6 +336,7 @@ export function CredentialSets() {
email,
})
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => {
setResendCooldowns((prev) => {
@@ -391,6 +393,7 @@ export function CredentialSets() {
return <GmailIcon className='h-4 w-4' />
}
// All hooks must be called before any early returns
const activeMemberships = useMemo(
() => memberships.filter((m) => m.status === 'active'),
[memberships]
@@ -444,6 +447,7 @@ export function CredentialSets() {
<div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
{/* Group Info */}
<div className='flex items-center gap-[16px]'>
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
@@ -467,6 +471,7 @@ export function CredentialSets() {
</div>
</div>
{/* Invite Section - Email Tags Input */}
<div className='flex flex-col gap-[4px]'>
<div className='flex items-center gap-[8px]'>
<TagInput
@@ -490,6 +495,7 @@ export function CredentialSets() {
{emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>}
</div>
{/* Members List - styled like team members */}
<div className='flex flex-col gap-[16px]'>
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4>
@@ -513,6 +519,7 @@ export function CredentialSets() {
</p>
) : (
<div className='flex flex-col gap-[16px]'>
{/* Active Members */}
{activeMembers.map((member) => {
const name = member.userName || 'Unknown'
const avatarInitial = name.charAt(0).toUpperCase()
@@ -565,6 +572,7 @@ export function CredentialSets() {
)
})}
{/* Pending Invitations */}
{pendingInvitations.map((invitation) => {
const email = invitation.email || 'Unknown'
const emailPrefix = email.split('@')[0]
@@ -633,6 +641,7 @@ export function CredentialSets() {
</div>
</div>
{/* Footer Actions */}
<div className='mt-auto flex items-center justify-end'>
<Button onClick={handleBackToList} variant='tertiary'>
Back
@@ -813,6 +822,7 @@ export function CredentialSets() {
</div>
</div>
{/* Create Polling Group Modal */}
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent size='sm'>
<ModalHeader>Create Polling Group</ModalHeader>
@@ -885,6 +895,7 @@ export function CredentialSets() {
</ModalContent>
</Modal>
{/* Leave Confirmation Modal */}
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
<ModalContent size='sm'>
<ModalHeader>Leave Polling Group</ModalHeader>
@@ -912,6 +923,7 @@ export function CredentialSets() {
</ModalContent>
</Modal>
{/* Delete Confirmation Modal */}
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
<ModalContent size='sm'>
<ModalHeader>Delete Polling Group</ModalHeader>

View File

@@ -1,3 +1,4 @@
export { AccessControl } from './access-control/access-control'
export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
@@ -9,6 +10,7 @@ export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -407,12 +407,14 @@ export function MCP({ initialServerId }: MCPProps) {
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Auto-select server when initialServerId is provided
useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId)
}
}, [initialServerId, servers])
// Force refresh tools when entering server detail view to detect stale schemas
useEffect(() => {
if (selectedServerId) {
forceRefreshTools(workspaceId)
@@ -673,7 +675,6 @@ export function MCP({ initialServerId }: MCPProps) {
/**
* Opens the detail view for a specific server.
* Note: Tool refresh is handled by the useEffect that watches selectedServerId
*/
const handleViewDetails = useCallback((serverId: string) => {
setSelectedServerId(serverId)
@@ -716,6 +717,7 @@ export function MCP({ initialServerId }: MCPProps) {
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
)
// If the active workflow was updated, reload its subblock values from DB
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)

View File

@@ -11,13 +11,55 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getUserRole } from '@/lib/workspaces/organization/utils'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
import { useOrganizations } from '@/hooks/queries/organization'
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('SSO')
const TRUSTED_SSO_PROVIDERS = [
'okta',
'okta-saml',
'okta-prod',
'okta-dev',
'okta-staging',
'okta-test',
'azure-ad',
'azure-active-directory',
'azure-corp',
'azure-enterprise',
'adfs',
'adfs-company',
'adfs-corp',
'adfs-enterprise',
'auth0',
'auth0-prod',
'auth0-dev',
'auth0-staging',
'onelogin',
'onelogin-prod',
'onelogin-corp',
'jumpcloud',
'jumpcloud-prod',
'jumpcloud-corp',
'ping-identity',
'ping-federate',
'pingone',
'shibboleth',
'shibboleth-idp',
'google-workspace',
'google-sso',
'saml',
'saml2',
'saml-sso',
'oidc',
'oidc-sso',
'openid-connect',
'custom-sso',
'enterprise-sso',
'company-sso',
]
interface SSOProvider {
id: string
providerId: string
@@ -523,7 +565,7 @@ export function SSO() {
<Combobox
value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)}
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
label: id,
value: id,
}))}

View File

@@ -41,6 +41,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getUserRole } from '@/lib/workspaces/organization'
import {
AccessControl,
ApiKeys,
BYOK,
Copilot,
@@ -52,18 +53,16 @@ import {
General,
Integrations,
MCP,
SSO,
Subscription,
TeamManagement,
WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
import { AccessControl } from '@/ee/access-control/components/access-control'
import { SSO } from '@/ee/sso/components/sso-settings'
import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso'
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
@@ -205,13 +204,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const [isSuperUser, setIsSuperUser] = useState(false)
const { data: session } = useSession()
const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
@@ -230,7 +229,22 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
const isSuperUser = superUserData?.isSuperUser ?? false
// Fetch superuser status
useEffect(() => {
const fetchSuperUserStatus = async () => {
if (!userId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser)
}
} catch {
setIsSuperUser(false)
}
}
fetchSuperUserStatus()
}, [userId])
// Memoize SSO provider ownership check
const isSSOProviderOwner = useMemo(() => {
@@ -314,13 +328,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
generalSettings?.superUserModeEnabled,
])
const effectiveActiveSection = useMemo(() => {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
return 'general'
}
return activeSection
}, [activeSection])
// Memoized callbacks to prevent infinite loops in child components
const registerEnvironmentBeforeLeaveHandler = useCallback(
(handler: (onProceed: () => void) => void) => {
environmentBeforeLeaveHandler.current = handler
@@ -334,18 +342,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const handleSectionChange = useCallback(
(sectionId: SettingsSection) => {
if (sectionId === effectiveActiveSection) return
if (sectionId === activeSection) return
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return
}
setActiveSection(sectionId)
},
[effectiveActiveSection]
[activeSection]
)
// Apply initial section from store when modal opens
useEffect(() => {
if (open && initialSection) {
setActiveSection(initialSection)
@@ -356,6 +365,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}, [open, initialSection, mcpServerId, clearInitialState])
// Clear pending server ID when section changes away from MCP
useEffect(() => {
if (activeSection !== 'mcp') {
setPendingMcpServerId(null)
@@ -381,6 +391,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}, [onOpenChange])
// Redirect away from billing tabs if billing is disabled
useEffect(() => {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
setActiveSection('general')
}
}, [activeSection])
// Prefetch functions for React Query
const prefetchGeneral = () => {
queryClient.prefetchQuery({
queryKey: generalSettingsKeys.settings(),
@@ -471,17 +489,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
// Handle dialog close - delegate to environment component if it's active
const handleDialogOpenChange = (newOpen: boolean) => {
if (
!newOpen &&
effectiveActiveSection === 'environment' &&
environmentBeforeLeaveHandler.current
) {
if (!newOpen && activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
} else if (
!newOpen &&
effectiveActiveSection === 'integrations' &&
integrationsCloseHandler.current
) {
} else if (!newOpen && activeSection === 'integrations' && integrationsCloseHandler.current) {
integrationsCloseHandler.current(newOpen)
} else {
onOpenChange(newOpen)
@@ -512,7 +522,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{sectionItems.map((item) => (
<SModalSidebarItem
key={item.id}
active={effectiveActiveSection === item.id}
active={activeSection === item.id}
icon={<item.icon />}
onMouseEnter={() => handlePrefetch(item.id)}
onClick={() => handleSectionChange(item.id)}
@@ -528,36 +538,35 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<SModalMain>
<SModalMainHeader>
{navigationItems.find((item) => item.id === effectiveActiveSection)?.label ||
effectiveActiveSection}
{navigationItems.find((item) => item.id === activeSection)?.label || activeSection}
</SModalMainHeader>
<SModalMainBody>
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'environment' && (
{activeSection === 'general' && <General onOpenChange={onOpenChange} />}
{activeSection === 'environment' && (
<EnvironmentVariables
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
/>
)}
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
{effectiveActiveSection === 'integrations' && (
{activeSection === 'template-profile' && <TemplateProfile />}
{activeSection === 'integrations' && (
<Integrations
onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
{effectiveActiveSection === 'access-control' && <AccessControl />}
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'files' && <FileUploads />}
{isBillingEnabled && effectiveActiveSection === 'subscription' && <Subscription />}
{isBillingEnabled && effectiveActiveSection === 'team' && <TeamManagement />}
{effectiveActiveSection === 'sso' && <SSO />}
{effectiveActiveSection === 'byok' && <BYOK />}
{effectiveActiveSection === 'copilot' && <Copilot />}
{effectiveActiveSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{effectiveActiveSection === 'custom-tools' && <CustomTools />}
{effectiveActiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveActiveSection === 'debug' && <Debug />}
{activeSection === 'credential-sets' && <CredentialSets />}
{activeSection === 'access-control' && <AccessControl />}
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
{activeSection === 'files' && <FileUploads />}
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
{activeSection === 'sso' && <SSO />}
{activeSection === 'byok' && <BYOK />}
{activeSection === 'copilot' && <Copilot />}
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />}
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{activeSection === 'debug' && <Debug />}
</SModalMainBody>
</SModalMain>
</SModalContent>

View File

@@ -231,8 +231,6 @@ export function FolderItem({
const isFolderSelected = store.selectedFolders.has(folder.id)
if (!isFolderSelected) {
// Replace selection with just this folder (Finder/Explorer pattern)
store.clearAllSelection()
store.selectFolder(folder.id)
}

View File

@@ -189,9 +189,6 @@ export function WorkflowItem({
const isCurrentlySelected = store.selectedWorkflows.has(workflow.id)
if (!isCurrentlySelected) {
// Replace selection with just this item (Finder/Explorer pattern)
// This clears both workflow and folder selections
store.clearAllSelection()
store.selectWorkflow(workflow.id)
}

View File

@@ -19,7 +19,6 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -228,6 +227,12 @@ async function deliverWebhook(
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}m`
}
function formatCost(cost?: Record<string, unknown>): string {
if (!cost?.total) return 'N/A'
const total = cost.total as number
@@ -297,7 +302,7 @@ async function deliverEmail(
workflowName: payload.data.workflowName || 'Unknown Workflow',
status: payload.data.status,
trigger: payload.data.trigger,
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-',
duration: formatDuration(payload.data.totalDurationMs),
cost: formatCost(payload.data.cost),
logUrl,
alertReason,
@@ -310,7 +315,7 @@ async function deliverEmail(
to: subscription.emailRecipients,
subject,
html,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
emailType: 'notifications',
})
@@ -368,10 +373,7 @@ async function deliverSlack(
fields: [
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
{
type: 'mrkdwn',
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
},
{ type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
],
},

View File

@@ -458,8 +458,8 @@ export function getCodeEditorProps(options?: {
'caret-[var(--text-primary)] dark:caret-white',
// Font smoothing
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
// Disable interaction for streaming/preview/disabled
(isStreaming || isPreview || disabled) && 'pointer-events-none'
// Disable interaction for streaming/preview
(isStreaming || isPreview) && 'pointer-events-none'
),
}
}

View File

@@ -260,9 +260,6 @@ const Popover: React.FC<PopoverProps> = ({
setIsKeyboardNav(false)
setSelectedIndex(-1)
registeredItemsRef.current = []
} else {
// Reset hover state when opening to prevent stale submenu from previous menu
setLastHoveredItem(null)
}
}, [open])

View File

@@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
interface ToolCallProps {
toolCall: ToolCallState
@@ -226,6 +225,11 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
const isError = toolCall.state === 'error'
const isAborted = toolCall.state === 'aborted'
const formatDuration = (duration?: number) => {
if (!duration) return ''
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
}
return (
<div
className={cn(
@@ -275,7 +279,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
)}
style={{ fontSize: '0.625rem' }}
>
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''}
{formatDuration(toolCall.duration)}
</Badge>
)}
</div>

View File

@@ -1,43 +0,0 @@
Sim Enterprise License
Copyright (c) 2025-present Sim Studio, Inc.
This software and associated documentation files (the "Software") are licensed
under the following terms:
1. LICENSE GRANT
Subject to the terms of this license, Sim Studio, Inc. grants you a limited,
non-exclusive, non-transferable license to use the Software for:
- Development, testing, and evaluation purposes
- Internal non-production use
Production use of the Software requires a valid Sim Enterprise subscription.
2. RESTRICTIONS
You may not:
- Use the Software in production without a valid Enterprise subscription
- Modify, adapt, or create derivative works of the Software
- Redistribute, sublicense, or transfer the Software
- Remove or alter any proprietary notices in the Software
3. ENTERPRISE SUBSCRIPTION
Production deployment of enterprise features requires an active Sim Enterprise
subscription. Contact sales@simstudio.ai for licensing information.
4. DISCLAIMER
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
5. LIMITATION OF LIABILITY
IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
For questions about enterprise licensing, contact: sales@simstudio.ai

View File

@@ -1,21 +0,0 @@
# Sim Enterprise Edition
This directory contains enterprise features that require a Sim Enterprise subscription
for production use.
## Features
- **SSO (Single Sign-On)**: OIDC and SAML authentication integration
- **Access Control**: Permission groups for fine-grained user access management
- **Credential Sets**: Shared credential pools for email polling workflows
## Licensing
See [LICENSE](./LICENSE) for terms. Development and testing use is permitted.
Production deployment requires an active Enterprise subscription.
## Architecture
Enterprise features are imported directly throughout the codebase. The `ee/` directory
is required at build time. Feature visibility is controlled at runtime via environment
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`).

View File

@@ -5,7 +5,6 @@ import {
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
buildResumeApiUrl,
@@ -21,18 +20,18 @@ import {
generatePauseContextId,
mapNodeMetadataToPauseScopes,
} from '@/executor/human-in-the-loop/utils.ts'
import {
type BlockHandler,
type BlockLog,
type BlockState,
type ExecutionContext,
getNextExecutionOrder,
type NormalizedBlockOutput,
import type {
BlockHandler,
BlockLog,
BlockState,
ExecutionContext,
NormalizedBlockOutput,
} from '@/executor/types'
import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter'
import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
@@ -69,7 +68,7 @@ export class BlockExecutor {
if (!isSentinel) {
blockLog = this.createBlockLog(ctx, node.id, block, node)
ctx.blockLogs.push(blockLog)
this.callOnBlockStart(ctx, node, block, blockLog.executionOrder)
this.callOnBlockStart(ctx, node, block)
}
const startTime = performance.now()
@@ -160,7 +159,7 @@ export class BlockExecutor {
this.state.setBlockOutput(node.id, normalizedOutput, duration)
if (!isSentinel && blockLog) {
if (!isSentinel) {
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
block,
})
@@ -171,9 +170,8 @@ export class BlockExecutor {
this.sanitizeInputsForLog(resolvedInputs),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt
blockLog!.startedAt,
blockLog!.endedAt
)
}
@@ -270,7 +268,7 @@ export class BlockExecutor {
}
)
if (!isSentinel && blockLog) {
if (!isSentinel) {
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
this.callOnBlockComplete(
ctx,
@@ -279,9 +277,8 @@ export class BlockExecutor {
this.sanitizeInputsForLog(input),
displayOutput,
duration,
blockLog.startedAt,
blockLog.executionOrder,
blockLog.endedAt
blockLog!.startedAt,
blockLog!.endedAt
)
}
@@ -349,7 +346,6 @@ export class BlockExecutor {
blockName,
blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE,
startedAt: new Date().toISOString(),
executionOrder: getNextExecutionOrder(ctx),
endedAt: '',
durationMs: 0,
success: false,
@@ -413,12 +409,7 @@ export class BlockExecutor {
return result
}
private callOnBlockStart(
ctx: ExecutionContext,
node: DAGNode,
block: SerializedBlock,
executionOrder: number
): void {
private callOnBlockStart(ctx: ExecutionContext, node: DAGNode, block: SerializedBlock): void {
const blockId = node.id
const blockName = block.metadata?.name ?? blockId
const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE
@@ -426,13 +417,7 @@ export class BlockExecutor {
const iterationContext = this.getIterationContext(ctx, node)
if (this.contextExtensions.onBlockStart) {
this.contextExtensions.onBlockStart(
blockId,
blockName,
blockType,
executionOrder,
iterationContext
)
this.contextExtensions.onBlockStart(blockId, blockName, blockType, iterationContext)
}
}
@@ -444,7 +429,6 @@ export class BlockExecutor {
output: NormalizedBlockOutput,
duration: number,
startedAt: string,
executionOrder: number,
endedAt: string
): void {
const blockId = node.id
@@ -463,7 +447,6 @@ export class BlockExecutor {
output,
executionTime: duration,
startedAt,
executionOrder,
endedAt,
},
iterationContext

View File

@@ -55,13 +55,7 @@ export interface IterationContext {
export interface ExecutionCallbacks {
onStream?: (streamingExec: any) => Promise<void>
onBlockStart?: (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext
) => Promise<void>
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
onBlockComplete?: (
blockId: string,
blockName: string,
@@ -103,7 +97,6 @@ export interface ContextExtensions {
blockId: string,
blockName: string,
blockType: string,
executionOrder: number,
iterationContext?: IterationContext
) => Promise<void>
onBlockComplete?: (
@@ -115,7 +108,6 @@ export interface ContextExtensions {
output: NormalizedBlockOutput
executionTime: number
startedAt: string
executionOrder: number
endedAt: string
},
iterationContext?: IterationContext

View File

@@ -6,12 +6,6 @@ import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/ee/access-control/utils/permission-check'
import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
import { memoryService } from '@/executor/handlers/agent/memory'
import type {
@@ -24,6 +18,12 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/executor/utils/permission-check'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -6,7 +6,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import {
BlockType,
DEFAULTS,
@@ -16,6 +15,7 @@ import {
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'

View File

@@ -212,11 +212,11 @@ export class WorkflowBlockHandler implements BlockHandler {
/**
* Parses a potentially nested workflow error message to extract:
* - The chain of workflow names
* - The actual root error message (preserving the block name prefix for the failing block)
* - The actual root error message (preserving the block prefix for the failing block)
*
* Handles formats like:
* - "workflow-name" failed: error
* - Block Name: "workflow-name" failed: error
* - [block_type] Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error
*/
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
@@ -234,8 +234,8 @@ export class WorkflowBlockHandler implements BlockHandler {
// Extract workflow names from patterns like:
// - "workflow-name" failed:
// - Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
// - [block_type] Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null
let lastIndex = 0
@@ -247,7 +247,7 @@ export class WorkflowBlockHandler implements BlockHandler {
}
// The root error is everything after the last match
// Keep the block name prefix (e.g., Function 1:) so we know which block failed
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' }

View File

@@ -7,11 +7,7 @@ import type { DAG } from '@/executor/dag/builder'
import type { EdgeManager } from '@/executor/execution/edge-manager'
import type { LoopScope } from '@/executor/execution/state'
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
import {
type ExecutionContext,
getNextExecutionOrder,
type NormalizedBlockOutput,
} from '@/executor/types'
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import type { LoopConfigWithNodes } from '@/executor/types/loop'
import { replaceValidReferences } from '@/executor/utils/reference-validation'
import {
@@ -290,7 +286,6 @@ export class LoopOrchestrator {
output,
executionTime: DEFAULTS.EXECUTION_TIME,
startedAt: now,
executionOrder: getNextExecutionOrder(ctx),
endedAt: now,
})
}

View File

@@ -3,11 +3,7 @@ import { DEFAULTS } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import type { ParallelScope } from '@/executor/execution/state'
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
import {
type ExecutionContext,
getNextExecutionOrder,
type NormalizedBlockOutput,
} from '@/executor/types'
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
import type { ParallelConfigWithNodes } from '@/executor/types/parallel'
import { ParallelExpander } from '@/executor/utils/parallel-expansion'
import {
@@ -274,7 +270,6 @@ export class ParallelOrchestrator {
output,
executionTime: 0,
startedAt: now,
executionOrder: getNextExecutionOrder(ctx),
endedAt: now,
})
}

View File

@@ -114,11 +114,6 @@ export interface BlockLog {
loopId?: string
parallelId?: string
iterationIndex?: number
/**
* Monotonically increasing integer (1, 2, 3, ...) for accurate block ordering.
* Generated via getNextExecutionOrder() to ensure deterministic sorting.
*/
executionOrder: number
/**
* Child workflow trace spans for nested workflow execution.
* Stored separately from output to keep output clean for display
@@ -232,12 +227,7 @@ export interface ExecutionContext {
edges?: Array<{ source: string; target: string }>
onStream?: (streamingExecution: StreamingExecution) => Promise<void>
onBlockStart?: (
blockId: string,
blockName: string,
blockType: string,
executionOrder: number
) => Promise<void>
onBlockStart?: (blockId: string, blockName: string, blockType: string) => Promise<void>
onBlockComplete?: (
blockId: string,
blockName: string,
@@ -278,23 +268,6 @@ export interface ExecutionContext {
* Stop execution after this block completes. Used for "run until block" feature.
*/
stopAfterBlockId?: string
/**
* Counter for generating monotonically increasing execution order values.
* Starts at 0 and increments for each block. Use getNextExecutionOrder() to access.
*/
executionOrderCounter?: { value: number }
}
/**
* Gets the next execution order value for a block.
* Returns a simple incrementing integer (1, 2, 3, ...) for clear ordering.
*/
export function getNextExecutionOrder(ctx: ExecutionContext): number {
if (!ctx.executionOrderCounter) {
ctx.executionOrderCounter = { value: 0 }
}
return ++ctx.executionOrderCounter.value
}
export interface ExecutionResult {

View File

@@ -47,7 +47,7 @@ export function buildBlockExecutionError(details: BlockExecutionErrorDetails): E
const blockName = details.block.metadata?.name || details.block.id
const blockType = details.block.metadata?.id || 'unknown'
const error = new Error(`${blockName}: ${errorMessage}`)
const error = new Error(`[${blockType}] ${blockName}: ${errorMessage}`)
Object.assign(error, {
blockId: details.block.id,

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { LOOP, PARALLEL, PARSING, REFERENCE } from '@/executor/constants'
import type { ContextExtensions } from '@/executor/execution/types'
import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types'
import type { BlockLog, ExecutionContext } from '@/executor/types'
import type { VariableResolver } from '@/executor/variables/resolver'
const logger = createLogger('SubflowUtils')
@@ -208,7 +208,6 @@ export function addSubflowErrorLog(
contextExtensions: ContextExtensions | null
): void {
const now = new Date().toISOString()
const execOrder = getNextExecutionOrder(ctx)
const block = ctx.workflow?.blocks?.find((b) => b.id === blockId)
const blockName = block?.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel')
@@ -218,7 +217,6 @@ export function addSubflowErrorLog(
blockName,
blockType,
startedAt: now,
executionOrder: execOrder,
endedAt: now,
durationMs: 0,
success: false,
@@ -235,7 +233,6 @@ export function addSubflowErrorLog(
output: { error: errorMessage },
executionTime: 0,
startedAt: now,
executionOrder: execOrder,
endedAt: now,
})
}

View File

@@ -1,5 +1,3 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,5 +1,3 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,5 +1,3 @@
'use client'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -77,3 +75,39 @@ export function useConfigureSSO() {
},
})
}
/**
* Delete SSO provider mutation
*/
interface DeleteSSOParams {
providerId: string
orgId?: string
}
export function useDeleteSSO() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ providerId }: DeleteSSOParams) => {
const response = await fetch(`/api/auth/sso/providers/${providerId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to delete SSO provider')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.detail(variables.orgId),
})
}
},
})
}

View File

@@ -9,7 +9,6 @@ const logger = createLogger('UserProfileQuery')
export const userProfileKeys = {
all: ['userProfile'] as const,
profile: () => [...userProfileKeys.all, 'profile'] as const,
superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const,
}
/**
@@ -110,37 +109,3 @@ export function useUpdateUserProfile() {
},
})
}
/**
* Superuser status response type
*/
interface SuperUserStatus {
isSuperUser: boolean
}
/**
* Fetch superuser status from API
*/
async function fetchSuperUserStatus(): Promise<SuperUserStatus> {
const response = await fetch('/api/user/super-user')
if (!response.ok) {
return { isSuperUser: false }
}
const data = await response.json()
return { isSuperUser: data.isSuperUser ?? false }
}
/**
* Hook to fetch superuser status
* @param userId - User ID for cache isolation (required for proper per-user caching)
*/
export function useSuperUserStatus(userId?: string) {
return useQuery({
queryKey: userProfileKeys.superUser(userId),
queryFn: fetchSuperUserStatus,
enabled: Boolean(userId),
staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes
})
}

View File

@@ -409,20 +409,6 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-toggle-handles from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const { blockIds } = payload
logger.info('Received batch-toggle-locked from remote user', {
userId,
count: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0) {
useWorkflowStore.getState().batchToggleLocked(blockIds)
}
logger.info('Successfully applied batch-toggle-locked from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
const { updates } = payload
logger.info('Received batch-update-parent from remote user', {
@@ -744,23 +730,6 @@ export function useCollaborativeWorkflow() {
const collaborativeUpdateBlockName = useCallback(
(id: string, name: string): { success: boolean; error?: string } => {
const blocks = useWorkflowStore.getState().blocks
const block = blocks[id]
if (block) {
const parentId = block.data?.parentId
const isParentLocked = parentId ? blocks[parentId]?.locked : false
if (block.locked || isParentLocked) {
logger.error('Cannot rename locked block')
useNotificationStore.getState().addNotification({
level: 'info',
message: 'Cannot rename locked blocks',
workflowId: activeWorkflowId || undefined,
})
return { success: false, error: 'Block is locked' }
}
}
const trimmedName = name.trim()
const normalizedNewName = normalizeName(trimmedName)
@@ -854,27 +823,14 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
// For each ID, collect non-locked blocks and their children for undo/redo
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
// Skip locked blocks
if (block.locked) continue
validIds.push(id)
previousStates[id] = block.enabled
// If it's a loop or parallel, also capture children's previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
previousStates[blockId] = b.enabled
}
})
const block = useWorkflowStore.getState().blocks[id]
if (block) {
previousStates[id] = block.enabled
validIds.push(id)
}
}
@@ -1036,23 +992,12 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return
const blocks = useWorkflowStore.getState().blocks
const isProtected = (blockId: string): boolean => {
const block = blocks[blockId]
if (!block) return false
if (block.locked) return true
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
for (const id of ids) {
const block = blocks[id]
if (block && !isProtected(id)) {
const block = useWorkflowStore.getState().blocks[id]
if (block) {
previousStates[id] = block.horizontalHandles ?? false
validIds.push(id)
}
@@ -1080,56 +1025,6 @@ export function useCollaborativeWorkflow() {
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
)
const collaborativeBatchToggleLocked = useCallback(
(ids: string[]) => {
if (isBaselineDiffView) {
return
}
if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
validIds.push(id)
previousStates[id] = block.locked ?? false
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
previousStates[blockId] = b.locked ?? false
}
})
}
}
if (validIds.length === 0) return
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validIds, previousStates },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
useWorkflowStore.getState().batchToggleLocked(validIds)
undoRedo.recordBatchToggleLocked(validIds, previousStates)
},
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
)
const collaborativeBatchAddEdges = useCallback(
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
if (isBaselineDiffView) {
@@ -1143,6 +1038,7 @@ export function useCollaborativeWorkflow() {
if (edges.length === 0) return false
// Filter out invalid edges (e.g., edges targeting trigger blocks) and duplicates
const blocks = useWorkflowStore.getState().blocks
const currentEdges = useWorkflowStore.getState().edges
const validEdges = filterValidEdges(edges, blocks)
@@ -1773,7 +1669,6 @@ export function useCollaborativeWorkflow() {
collaborativeToggleBlockAdvancedMode,
collaborativeSetBlockCanonicalMode,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeBatchAddEdges,

View File

@@ -1,5 +1,3 @@
'use client'
import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
@@ -7,8 +5,8 @@ import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
} from '@/lib/permission-groups/types'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { useOrganizations } from '@/hooks/queries/organization'
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
export interface PermissionConfigResult {
config: PermissionGroupConfig

View File

@@ -20,7 +20,6 @@ import {
type BatchRemoveEdgesOperation,
type BatchToggleEnabledOperation,
type BatchToggleHandlesOperation,
type BatchToggleLockedOperation,
type BatchUpdateParentOperation,
captureLatestEdges,
captureLatestSubBlockValues,
@@ -416,36 +415,6 @@ export function useUndoRedo() {
[activeWorkflowId, userId]
)
const recordBatchToggleLocked = useCallback(
(blockIds: string[], previousStates: Record<string, boolean>) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const inverse: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const entry = createOperationEntry(operation, inverse)
useUndoRedoStore.getState().push(activeWorkflowId, userId, entry)
logger.debug('Recorded batch toggle locked', { blockIds, previousStates })
},
[activeWorkflowId, userId]
)
const undo = useCallback(async () => {
if (!activeWorkflowId) return
@@ -808,9 +777,7 @@ export function useUndoRedo() {
const toggleOp = entry.inverse as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data
// Restore all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
break
@@ -821,14 +788,14 @@ export function useUndoRedo() {
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
payload: { blockIds: validBlockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Use setBlockEnabled to directly restore to previous state
// This restores all affected blocks including children of containers
// This is more robust than conditional toggle in collaborative scenarios
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
})
@@ -862,36 +829,6 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.inverse as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Restore all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Use setBlockLocked to directly restore to previous state
// This restores all affected blocks including children of containers
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, previousStates[blockId])
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data
@@ -1428,9 +1365,7 @@ export function useUndoRedo() {
const toggleOp = entry.operation as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data
// Process all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
break
@@ -1441,18 +1376,16 @@ export function useUndoRedo() {
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
payload: { blockIds: validBlockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Compute target state the same way batchToggleEnabled does:
// use !firstBlock.enabled, where firstBlock is blockIds[0]
const firstBlockId = blockIds[0]
const targetEnabled = !previousStates[firstBlockId]
// Use setBlockEnabled to directly set to toggled state
// Redo sets to !previousStates (the state after the original toggle)
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, targetEnabled)
useWorkflowStore.getState().setBlockEnabled(blockId, !previousStates[blockId])
})
break
}
@@ -1484,38 +1417,6 @@ export function useUndoRedo() {
})
break
}
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.operation as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Process all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Compute target state the same way batchToggleLocked does:
// use !firstBlock.locked, where firstBlock is blockIds[0]
const firstBlockId = blockIds[0]
const targetLocked = !previousStates[firstBlockId]
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, targetLocked)
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
// Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any
@@ -1837,7 +1738,6 @@ export function useUndoRedo() {
recordBatchUpdateParent,
recordBatchToggleEnabled,
recordBatchToggleHandles,
recordBatchToggleLocked,
recordApplyDiff,
recordAcceptDiff,
recordRejectDiff,

View File

@@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth')

View File

@@ -1,7 +1,3 @@
/**
* List of trusted SSO provider identifiers.
* Used for validation and autocomplete in SSO configuration.
*/
export const SSO_TRUSTED_PROVIDERS = [
'okta',
'okta-saml',

View File

@@ -1,37 +1,20 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
const logger = createLogger('BillingAuthorization')
/**
* Check if a user is authorized to manage billing for a given reference ID
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
*
* This function also performs duplicate subscription validation for organizations:
* - Rejects if an organization already has an active subscription (prevents duplicates)
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
*/
export async function authorizeSubscriptionReference(
userId: string,
referenceId: string
): Promise<boolean> {
// User can always manage their own subscriptions (Pro upgrades, etc.)
// User can always manage their own subscriptions
if (referenceId === userId) {
return true
}
// For organizations: check for existing active subscriptions to prevent duplicates
if (await hasActiveSubscription(referenceId)) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
referenceId,
})
return false
}
// Check if referenceId is an organizationId the user has admin rights to
const members = await db
.select()

View File

@@ -25,11 +25,9 @@ export function useSubscriptionUpgrade() {
}
let currentSubscriptionId: string | undefined
let allSubscriptions: any[] = []
try {
const listResult = await client.subscription.list()
allSubscriptions = listResult.data || []
const activePersonalSub = allSubscriptions.find(
const activePersonalSub = listResult.data?.find(
(sub: any) => sub.status === 'active' && sub.referenceId === userId
)
currentSubscriptionId = activePersonalSub?.id
@@ -52,25 +50,6 @@ export function useSubscriptionUpgrade() {
)
if (existingOrg) {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
(sub: any) =>
sub.status === 'active' &&
sub.referenceId === existingOrg.id &&
(sub.plan === 'team' || sub.plan === 'enterprise')
)
if (existingTeamSub) {
logger.warn('Organization already has an active team subscription', {
userId,
organizationId: existingOrg.id,
existingSubscriptionId: existingTeamSub.id,
})
throw new Error(
'This organization already has an active team subscription. Please manage it from the billing settings.'
)
}
logger.info('Using existing organization for team plan upgrade', {
userId,
organizationId: existingOrg.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
@@ -26,22 +26,10 @@ export async function getHighestPrioritySubscription(userId: string) {
let orgSubs: typeof personalSubs = []
if (orgIds.length > 0) {
// Verify orgs exist to filter out orphaned subscriptions
const existingOrgs = await db
.select({ id: organization.id })
.from(organization)
.where(inArray(organization.id, orgIds))
const validOrgIds = existingOrgs.map((o) => o.id)
if (validOrgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
)
}
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]

View File

@@ -25,28 +25,6 @@ const logger = createLogger('SubscriptionCore')
export { getHighestPrioritySubscription }
/**
* Check if a referenceId (user ID or org ID) has an active subscription
* Used for duplicate subscription prevention
*
* Fails closed: returns true on error to prevent duplicate creation
*/
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
try {
const [activeSub] = await db
.select({ id: subscription.id })
.from(subscription)
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
.limit(1)
return !!activeSub
} catch (error) {
logger.error('Error checking active subscription', { error, referenceId })
// Fail closed: assume subscription exists to prevent duplicate creation
return true
}
}
/**
* Check if user is on Pro plan (direct or via organization)
*/

View File

@@ -11,7 +11,6 @@ export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
hasAccessControlAccess,
hasActiveSubscription,
hasCredentialSetsAccess,
hasSSOAccess,
isEnterpriseOrgAdminOrOwner,
@@ -33,11 +32,6 @@ export {
} from '@/lib/billing/core/usage'
export * from '@/lib/billing/credits/balance'
export * from '@/lib/billing/credits/purchase'
export {
blockOrgMembers,
getOrgMemberIds,
unblockOrgMembers,
} from '@/lib/billing/organizations/membership'
export * from '@/lib/billing/subscriptions/utils'
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
export * from '@/lib/billing/types'

View File

@@ -8,7 +8,6 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasActiveSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -160,16 +159,6 @@ export async function ensureOrganizationForTeamSubscription(
if (existingMembership.length > 0) {
const membership = existingMembership[0]
if (membership.role === 'owner' || membership.role === 'admin') {
// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(membership.organizationId)) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
newSubscriptionId: subscription.id,
})
throw new Error('Organization already has an active subscription')
}
logger.info('User already owns/admins an org, using it', {
userId,
organizationId: membership.organizationId,

View File

@@ -15,86 +15,13 @@ import {
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { and, eq, sql } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
const logger = createLogger('OrganizationMembership')
export type BillingBlockReason = 'payment_failed' | 'dispute'
/**
* Get all member user IDs for an organization
*/
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
return members.map((m) => m.userId)
}
/**
* Block all members of an organization for billing reasons
* Returns the number of members actually blocked
*
* Reason priority: dispute > payment_failed
* A payment_failed block won't overwrite an existing dispute block
*/
export async function blockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
const whereClause =
reason === 'payment_failed'
? and(
inArray(userStats.userId, memberIds),
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
)
: inArray(userStats.userId, memberIds)
const result = await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: reason })
.where(whereClause)
.returning({ userId: userStats.userId })
return result.length
}
/**
* Unblock all members of an organization blocked for a specific reason
* Only unblocks members blocked for the specified reason (not other reasons)
* Returns the number of members actually unblocked
*/
export async function unblockOrgMembers(
organizationId: string,
reason: BillingBlockReason
): Promise<number> {
const memberIds = await getOrgMemberIds(organizationId)
if (memberIds.length === 0) {
return 0
}
const result = await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
.returning({ userId: userStats.userId })
return result.length
}
export interface RestoreProResult {
restored: boolean
usageRestored: boolean

View File

@@ -1,9 +1,8 @@
import { db } from '@sim/db'
import { subscription, user, userStats } from '@sim/db/schema'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
const logger = createLogger('DisputeWebhooks')
@@ -58,34 +57,36 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await blockOrgMembers(orgId, 'dispute')
if (memberCount > 0) {
logger.warn('Blocked all org members due to dispute', {
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
.where(eq(userStats.userId, owners[0].userId))
logger.warn('Blocked org owner due to dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
memberCount,
})
}
}
}
/**
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
*
* Status meanings:
* - 'won': Merchant won, customer's chargeback denied → unblock
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
* Handles charge.dispute.closed - unblocks user if dispute was won
*/
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
const dispute = event.data.object as Stripe.Dispute
// Only unblock if we won or the warning was closed without a full dispute
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
if (!shouldUnblock) {
logger.info('Dispute resolved against us, user remains blocked', {
if (dispute.status !== 'won') {
logger.info('Dispute not won, user remains blocked', {
disputeId: dispute.id,
status: dispute.status,
})
@@ -97,7 +98,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
return
}
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
// Find and unblock user (Pro plans)
const users = await db
.select({ id: user.id })
.from(user)
@@ -108,17 +109,16 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
.where(eq(userStats.userId, users[0].id))
logger.info('Unblocked user after dispute resolved in our favor', {
logger.info('Unblocked user after winning dispute', {
disputeId: dispute.id,
userId: users[0].id,
status: dispute.status,
})
return
}
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
// Find and unblock org owner (Team/Enterprise)
const subs = await db
.select({ referenceId: subscription.referenceId })
.from(subscription)
@@ -127,13 +127,24 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
if (subs.length > 0) {
const orgId = subs[0].referenceId
const memberCount = await unblockOrgMembers(orgId, 'dispute')
logger.info('Unblocked all org members after dispute resolved in our favor', {
disputeId: dispute.id,
organizationId: orgId,
memberCount,
status: dispute.status,
})
const owners = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
.limit(1)
if (owners.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(eq(userStats.userId, owners[0].userId))
logger.info('Unblocked org owner after winning dispute', {
disputeId: dispute.id,
ownerId: owners[0].userId,
organizationId: orgId,
})
}
}
}

View File

@@ -8,13 +8,12 @@ import {
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -503,7 +502,24 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
}
if (sub.plan === 'team' || sub.plan === 'enterprise') {
await unblockOrgMembers(sub.referenceId, 'payment_failed')
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
// Only unblock users blocked for payment_failed, not disputes
await db
.update(userStats)
.set({ billingBlocked: false, billingBlockedReason: null })
.where(
and(
inArray(userStats.userId, memberIds),
eq(userStats.billingBlockedReason, 'payment_failed')
)
)
}
} else {
// Only unblock users blocked for payment_failed, not disputes
await db
@@ -600,26 +616,28 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
if (records.length > 0) {
const sub = records[0]
if (sub.plan === 'team' || sub.plan === 'enterprise') {
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const memberIds = members.map((m) => m.userId)
if (memberIds.length > 0) {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(inArray(userStats.userId, memberIds))
}
logger.info('Blocked team/enterprise members due to payment failure', {
organizationId: sub.referenceId,
memberCount,
memberCount: members.length,
isOverageInvoice,
})
} else {
// Don't overwrite dispute blocks (dispute > payment_failed priority)
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(
and(
eq(userStats.userId, sub.referenceId),
or(
ne(userStats.billingBlockedReason, 'dispute'),
isNull(userStats.billingBlockedReason)
)
)
)
.where(eq(userStats.userId, sub.referenceId))
logger.info('Blocked user due to payment failure', {
userId: sub.referenceId,
isOverageInvoice,

View File

@@ -3,7 +3,6 @@ import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
@@ -53,37 +52,14 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise<nu
/**
* Cleanup organization when team/enterprise subscription is deleted.
* - Checks if other active subscriptions point to this org (skip deletion if so)
* - Restores member Pro subscriptions
* - Deletes the organization (only if no other active subs)
* - Deletes the organization
* - Syncs usage limits for former members (resets to free or Pro tier)
*/
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
restoredProCount: number
membersSynced: number
organizationDeleted: boolean
}> {
// Check if other active subscriptions still point to this org
// Note: The subscription being deleted is already marked as 'canceled' by better-auth
// before this handler runs, so we only find truly active ones
if (await hasActiveSubscription(organizationId)) {
logger.info('Skipping organization deletion - other active subscriptions exist', {
organizationId,
})
// Still sync limits for members since this subscription was deleted
const memberUserIds = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
for (const m of memberUserIds) {
await syncUsageLimitsFromSubscription(m.userId)
}
return { restoredProCount: 0, membersSynced: memberUserIds.length, organizationDeleted: false }
}
// Get member userIds before deletion (needed for limit syncing after org deletion)
const memberUserIds = await db
.select({ userId: member.userId })
@@ -99,7 +75,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
await syncUsageLimitsFromSubscription(m.userId)
}
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
return { restoredProCount, membersSynced: memberUserIds.length }
}
/**
@@ -196,14 +172,15 @@ export async function handleSubscriptionDeleted(subscription: {
referenceId: subscription.referenceId,
})
const { restoredProCount, membersSynced, organizationDeleted } =
await cleanupOrganizationSubscription(subscription.referenceId)
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
subscription.referenceId
)
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
restoredProCount,
organizationDeleted,
organizationDeleted: true,
membersSynced,
})
return
@@ -320,7 +297,7 @@ export async function handleSubscriptionDeleted(subscription: {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced
organizationDeleted = cleanup.organizationDeleted
organizationDeleted = true
} else if (subscription.plan === 'pro') {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1

View File

@@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import type { ChatContext } from '@/stores/panel/copilot/types'
export type AgentContextType =

View File

@@ -7,7 +7,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -6,7 +6,7 @@ import {
type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { tools as toolsRegistry } from '@/tools/registry'
export const getBlockOptionsServerTool: BaseServerTool<

View File

@@ -6,7 +6,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const getBlocksAndToolsServerTool: BaseServerTool<
ReturnType<typeof GetBlocksAndToolsInput.parse>,

View File

@@ -8,7 +8,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry'
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const GetTriggerBlocksInput = z.object({})
export const GetTriggerBlocksResult = z.object({

View File

@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -54,7 +54,6 @@ type SkippedItemType =
| 'block_not_found'
| 'invalid_block_type'
| 'block_not_allowed'
| 'block_locked'
| 'tool_not_allowed'
| 'invalid_edge_target'
| 'invalid_edge_source'
@@ -619,7 +618,6 @@ function createBlockFromParams(
subBlocks: {},
outputs: outputs,
data: parentId ? { parentId, extent: 'parent' as const } : {},
locked: false,
}
// Add validated inputs as subBlocks
@@ -1522,24 +1520,6 @@ function applyOperationsToWorkflowState(
break
}
// Check if block is locked or inside a locked container
const deleteBlock = modifiedState.blocks[block_id]
const deleteParentId = deleteBlock.data?.parentId as string | undefined
const deleteParentLocked = deleteParentId
? modifiedState.blocks[deleteParentId]?.locked
: false
if (deleteBlock.locked || deleteParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'delete',
blockId: block_id,
reason: deleteParentLocked
? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted`
: `Block "${block_id}" is locked and cannot be deleted`,
})
break
}
// Find all child blocks to remove
const blocksToRemove = new Set<string>([block_id])
const findChildren = (parentId: string) => {
@@ -1575,21 +1555,6 @@ function applyOperationsToWorkflowState(
const block = modifiedState.blocks[block_id]
// Check if block is locked or inside a locked container
const editParentId = block.data?.parentId as string | undefined
const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false
if (block.locked || editParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'edit',
blockId: block_id,
reason: editParentLocked
? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited`
: `Block "${block_id}" is locked and cannot be edited`,
})
break
}
// Ensure block has essential properties
if (!block.type) {
logger.warn(`Block ${block_id} missing type property, skipping edit`, {
@@ -2157,19 +2122,6 @@ function applyOperationsToWorkflowState(
// Handle nested nodes (for loops/parallels created from scratch)
if (params.nestedNodes) {
// Defensive check: verify parent is not locked before adding children
// (Parent was just created with locked: false, but check for consistency)
const parentBlock = modifiedState.blocks[block_id]
if (parentBlock?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'add_nested_nodes',
blockId: block_id,
reason: `Container "${block_id}" is locked - cannot add nested nodes`,
})
break
}
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
// Validate childId is a valid string
if (!isValidKey(childId)) {
@@ -2257,18 +2209,6 @@ function applyOperationsToWorkflowState(
break
}
// Check if subflow is locked
if (subflowBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`,
details: { subflowId },
})
break
}
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
logger.error('Subflow block has invalid type', {
subflowId,
@@ -2307,17 +2247,6 @@ function applyOperationsToWorkflowState(
break
}
// Check if existing block is locked
if (existingBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be moved into a subflow`,
})
break
}
// Moving existing block into subflow - just update parent
existingBlock.data = {
...existingBlock.data,
@@ -2463,30 +2392,6 @@ function applyOperationsToWorkflowState(
break
}
// Check if block is locked
if (block.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be extracted from subflow`,
})
break
}
// Check if parent subflow is locked
const parentSubflow = modifiedState.blocks[subflowId]
if (parentSubflow?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`,
details: { subflowId },
})
break
}
// Verify it's actually a child of this subflow
if (block.data?.parentId !== subflowId) {
logger.warn('Block is not a child of specified subflow', {

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