diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
index 5c2a5bfe3e8..ed53da7b24d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts
@@ -21,50 +21,88 @@ import {
} from '@/app/workspace/[workspaceId]/settings/navigation'
describe('unified settings navigation', () => {
- it('preserves the original settings groups', () => {
+ it('groups settings by the scope they affect', () => {
expect(sectionConfig).toEqual([
{ key: 'account', title: 'Account' },
- { key: 'tools', title: 'Tools' },
- { key: 'subscription', title: 'Subscription' },
- { key: 'system', title: 'System' },
- { key: 'desktop', title: 'Desktop' },
- { key: 'enterprise', title: 'Enterprise' },
- { key: 'superuser', title: 'Superuser' },
+ { key: 'workspace', title: 'Workspace' },
+ { key: 'organization', title: 'Organization' },
+ { key: 'platform', title: 'Platform' },
])
})
it('keeps account, workspace, organization, and platform settings in one catalog', () => {
expect(allNavigationItems.map(({ id, label, section }) => ({ id, label, section }))).toEqual([
{ id: 'general', label: 'General', section: 'account' },
- { id: 'desktop', label: 'Desktop', section: 'desktop' },
- { id: 'browser', label: 'Browser', section: 'desktop' },
- { id: 'terminal', label: 'Terminal', section: 'desktop' },
- { id: 'access-control', label: 'Access control', section: 'enterprise' },
- { id: 'audit-logs', label: 'Audit logs', section: 'enterprise' },
- { id: 'forks', label: 'Workspace Forks', section: 'enterprise' },
- { id: 'billing', label: 'Billing', section: 'subscription' },
- { id: 'teammates', label: 'Teammates', section: 'subscription' },
- { id: 'organization', label: 'Organization', section: 'subscription' },
- { id: 'secrets', label: 'Secrets', section: 'account' },
- { id: 'custom-tools', label: 'Custom tools', section: 'tools' },
- { id: 'mcp', label: 'MCP tools', section: 'tools' },
- { id: 'apikeys', label: 'Sim API keys', section: 'system' },
- { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'system' },
- { id: 'byok', label: 'BYOK', section: 'system' },
- { id: 'sandboxes', label: 'Sandboxes', section: 'system' },
- { id: 'inbox', label: 'Sim mailer', section: 'system' },
- { id: 'recently-deleted', label: 'Recently deleted', section: 'system' },
- { id: 'sso', label: 'Single sign-on', section: 'enterprise' },
- { id: 'sessions', label: 'Session policies', section: 'enterprise' },
- { id: 'data-retention', label: 'Data retention', section: 'enterprise' },
- { id: 'data-drains', label: 'Data drains', section: 'enterprise' },
- { id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' },
- { id: 'custom-blocks', label: 'Custom blocks', section: 'enterprise' },
- { id: 'admin', label: 'Admin', section: 'superuser' },
- { id: 'mothership', label: 'Mothership', section: 'superuser' },
+ { id: 'desktop', label: 'Desktop', section: 'account' },
+ { id: 'browser', label: 'Browser', section: 'account' },
+ { id: 'terminal', label: 'Terminal', section: 'account' },
+ { id: 'access-control', label: 'Permission groups', section: 'organization' },
+ { id: 'audit-logs', label: 'Audit logs', section: 'organization' },
+ { id: 'forks', label: 'Workspace forks', section: 'organization' },
+ { id: 'billing', label: 'Subscription', section: 'account' },
+ { id: 'teammates', label: 'Teammates', section: 'workspace' },
+ { id: 'organization', label: 'Members', section: 'organization' },
+ { id: 'secrets', label: 'Secrets', section: 'workspace' },
+ { id: 'custom-tools', label: 'Custom tools', section: 'workspace' },
+ { id: 'mcp', label: 'MCP tools', section: 'workspace' },
+ { id: 'apikeys', label: 'Sim API keys', section: 'workspace' },
+ { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'workspace' },
+ { id: 'byok', label: 'BYOK', section: 'workspace' },
+ { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' },
+ { id: 'inbox', label: 'Sim Mailer', section: 'workspace' },
+ { id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' },
+ { id: 'sso', label: 'Single sign-on', section: 'organization' },
+ { id: 'sessions', label: 'Session policies', section: 'organization' },
+ { id: 'data-retention', label: 'Data retention', section: 'organization' },
+ { id: 'data-drains', label: 'Data drains', section: 'organization' },
+ { id: 'whitelabeling', label: 'White-labeling', section: 'organization' },
+ { id: 'custom-blocks', label: 'Custom blocks', section: 'organization' },
+ { id: 'admin', label: 'Admin', section: 'platform' },
+ { id: 'mothership', label: 'Mothership', section: 'platform' },
])
})
+ it('orders each scope around its primary settings', () => {
+ const idsForSection = (section: (typeof sectionConfig)[number]['key']) =>
+ allNavigationItems
+ .filter((item) => item.section === section)
+ .sort((left, right) => left.order - right.order)
+ .map(({ id }) => id)
+
+ expect(idsForSection('account')).toEqual([
+ 'general',
+ 'billing',
+ 'desktop',
+ 'browser',
+ 'terminal',
+ ])
+ expect(idsForSection('workspace')).toEqual([
+ 'teammates',
+ 'secrets',
+ 'mcp',
+ 'custom-tools',
+ 'byok',
+ 'inbox',
+ 'workflow-mcp-servers',
+ 'apikeys',
+ 'sandboxes',
+ 'recently-deleted',
+ ])
+ expect(idsForSection('organization')).toEqual([
+ 'organization',
+ 'custom-blocks',
+ 'forks',
+ 'access-control',
+ 'audit-logs',
+ 'whitelabeling',
+ 'sso',
+ 'sessions',
+ 'data-retention',
+ 'data-drains',
+ ])
+ expect(idsForSection('platform')).toEqual(['admin', 'mothership'])
+ })
+
it('derives every unified item from exactly one registry entry', () => {
expect(allNavigationItems).toHaveLength(
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified).length
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
index bcd36c0299e..d659a983c0b 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
@@ -16,12 +16,9 @@ export const isBillingEnabled = SETTINGS_NAVIGATION_BILLING_ENABLED
export const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'account', title: 'Account' },
- { key: 'tools', title: 'Tools' },
- { key: 'subscription', title: 'Subscription' },
- { key: 'system', title: 'System' },
- { key: 'desktop', title: 'Desktop' },
- { key: 'enterprise', title: 'Enterprise' },
- { key: 'superuser', title: 'Superuser' },
+ { key: 'workspace', title: 'Workspace' },
+ { key: 'organization', title: 'Organization' },
+ { key: 'platform', title: 'Platform' },
]
export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation()
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
index 64c756ea880..5654c2e6cb1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
@@ -316,7 +316,9 @@ export function SettingsSidebar({
.map(({ key, title }) => ({
key,
title,
- items: navigationItems.filter((item) => item.section === key),
+ items: navigationItems
+ .filter((item) => item.section === key)
+ .sort((left, right) => left.order - right.order),
}))
.filter(({ items }) => items.length > 0)
.map(({ key, title, items: sectionItems }, index) => (
diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts
index 0bf912e7df4..ca1b92fd241 100644
--- a/apps/sim/components/settings/navigation.test.ts
+++ b/apps/sim/components/settings/navigation.test.ts
@@ -191,7 +191,7 @@ describe('settings navigation boundaries', () => {
expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink)
})
- it('keeps scope-specific labels only where the surface genuinely differs', () => {
+ it('uses scope-specific labels consistently across settings surfaces', () => {
const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members')
const unifiedOrganization = buildUnifiedSettingsNavigation().find(
({ id }) => id === 'organization'
@@ -199,7 +199,37 @@ describe('settings navigation boundaries', () => {
expect(organizationMembers?.label).toBe('Members')
expect(organizationMembers?.description).toBe('Manage organization members, roles, and seats.')
- expect(unifiedOrganization?.label).toBe('Organization')
+ expect(unifiedOrganization?.label).toBe('Members')
+ })
+
+ it('keeps self-host settings on their standalone account projection', () => {
+ expect(
+ SELFHOST_SETTINGS_ITEMS.map(({ id, label, description, group }) => ({
+ id,
+ label,
+ description,
+ group,
+ }))
+ ).toEqual([
+ {
+ id: 'general',
+ label: 'General',
+ description: 'Manage your profile, appearance, and preferences.',
+ group: 'account',
+ },
+ {
+ id: 'billing',
+ label: 'Subscription',
+ description: 'Manage your personal plan, usage, and invoices.',
+ group: 'account',
+ },
+ {
+ id: 'chat-keys',
+ label: 'Chat keys',
+ description: 'Manage the model-provider keys that power Chat.',
+ group: 'developer',
+ },
+ ])
})
it('builds canonical settings hrefs across all three planes', () => {
diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts
index 3557160e1d1..1e9a2c79c79 100644
--- a/apps/sim/components/settings/navigation.ts
+++ b/apps/sim/components/settings/navigation.ts
@@ -120,14 +120,7 @@ export type UnifiedSettingsSection =
| 'mothership'
| 'recently-deleted'
-export type UnifiedNavigationSection =
- | 'account'
- | 'subscription'
- | 'tools'
- | 'system'
- | 'desktop'
- | 'enterprise'
- | 'superuser'
+export type UnifiedNavigationSection = 'account' | 'workspace' | 'organization' | 'platform'
/**
* A bridge surface the desktop shell must expose for a section to be worth
@@ -142,6 +135,7 @@ export interface UnifiedSettingsNavigationItem {
description: string
icon: ComponentType<{ className?: string }>
section: UnifiedNavigationSection
+ order: number
hideWhenBillingDisabled?: boolean
requiresTeam?: boolean
requiresEnterprise?: boolean
@@ -384,6 +378,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
id: 'general',
description: 'Manage your profile, appearance, and preferences.',
group: 'account',
+ order: 0,
},
planes: {
account: { id: 'general', group: 'account', order: 0 },
@@ -396,7 +391,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'desktop',
description: 'Manage notifications, startup, local folders, and updates.',
- group: 'desktop',
+ group: 'account',
+ order: 2,
requiresDesktopSurface: 'settings',
},
},
@@ -406,7 +402,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'browser',
description: 'Control the browser Chat drives and the data it keeps.',
- group: 'desktop',
+ group: 'account',
+ order: 3,
requiresDesktopSurface: 'browser',
},
},
@@ -416,18 +413,20 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'terminal',
description: 'Control the shells Chat runs commands in.',
- group: 'desktop',
+ group: 'account',
+ order: 4,
requiresDesktopSurface: 'terminal',
},
},
{
- label: 'Access control',
+ label: 'Permission groups',
icon: ShieldCheck,
docsLink: 'https://docs.sim.ai/platform/enterprise/access-control',
unified: {
id: 'access-control',
description: 'Manage permission groups across your organization.',
- group: 'enterprise',
+ group: 'organization',
+ order: 3,
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl,
@@ -443,7 +442,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'audit-logs',
description: 'Review activity and changes across your organization.',
- group: 'enterprise',
+ group: 'organization',
+ order: 4,
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs,
@@ -453,25 +453,27 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
},
},
{
- label: 'Workspace Forks',
+ label: 'Workspace forks',
icon: Shuffle,
docsLink: 'https://docs.sim.ai/platform/enterprise/forks',
unified: {
id: 'forks',
description: 'Fork this workspace and sync changes with its parent.',
- group: 'enterprise',
+ group: 'organization',
+ order: 2,
},
planes: {
workspace: { id: 'forks', group: 'enterprise', order: 10 },
},
},
{
- label: 'Billing',
+ label: 'Subscription',
icon: ClipboardList,
unified: {
id: 'billing',
description: 'Manage your plan, pricing, and invoices.',
- group: 'subscription',
+ group: 'account',
+ order: 1,
hideWhenBillingDisabled: true,
},
planes: {
@@ -501,19 +503,21 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'teammates',
description: 'Manage your teammates in this workspace.',
- group: 'subscription',
+ group: 'workspace',
+ order: 0,
},
planes: {
workspace: { id: 'teammates', group: 'workspace', order: 0 },
},
},
{
- label: 'Organization',
+ label: 'Members',
icon: Users,
unified: {
id: 'organization',
description: "Manage your organization's members and seats.",
- group: 'subscription',
+ group: 'organization',
+ order: 0,
hideWhenBillingDisabled: true,
requiresHosted: true,
requiresTeam: true,
@@ -521,7 +525,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
planes: {
organization: {
id: 'members',
- label: 'Members',
description: 'Manage organization members, roles, and seats.',
group: 'organization',
order: 0,
@@ -534,7 +537,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'secrets',
description: 'Store environment variables for your workflows.',
- group: 'account',
+ group: 'workspace',
+ order: 1,
},
planes: {
workspace: { id: 'secrets', group: 'workspace', order: 1 },
@@ -546,7 +550,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'custom-tools',
description: 'Create and manage custom tools for your agents.',
- group: 'tools',
+ group: 'workspace',
+ order: 3,
},
planes: {
workspace: { id: 'custom-tools', group: 'tools', order: 4 },
@@ -557,8 +562,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
icon: McpIcon,
unified: {
id: 'mcp',
- description: 'Connect MCP servers and use their tools in workflows.',
- group: 'tools',
+ description: 'Connect external MCP servers and use their tools in this workspace.',
+ group: 'workspace',
+ order: 2,
},
planes: {
workspace: { id: 'mcp', group: 'tools', order: 5 },
@@ -570,7 +576,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'apikeys',
description: 'Create and manage API keys for the Sim API.',
- group: 'system',
+ group: 'workspace',
+ order: 7,
},
planes: {
account: {
@@ -592,8 +599,9 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
icon: Server,
unified: {
id: 'workflow-mcp-servers',
- description: 'Expose your workflows as tools on an MCP server.',
- group: 'system',
+ description: 'Expose workflows from this workspace as tools on an MCP server.',
+ group: 'workspace',
+ order: 6,
},
planes: {
workspace: { id: 'workflow-mcp-servers', group: 'tools', order: 6 },
@@ -605,7 +613,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'byok',
description: 'Bring your own model-provider API keys.',
- group: 'system',
+ group: 'workspace',
+ order: 4,
requiresHosted: true,
},
planes: {
@@ -619,7 +628,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'sandboxes',
description: 'Install Python or npm packages for Function blocks to import.',
- group: 'system',
+ group: 'workspace',
+ order: 8,
requiresMax: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sandboxes,
showWhenLocked: true,
@@ -641,12 +651,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
},
},
{
- label: 'Sim mailer',
+ label: 'Sim Mailer',
icon: Send,
unified: {
id: 'inbox',
description: 'Trigger and process workflows from incoming email.',
- group: 'system',
+ group: 'workspace',
+ order: 5,
requiresMax: true,
requiresHosted: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.inbox,
@@ -662,7 +673,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'recently-deleted',
description: 'Restore items deleted in the last 30 days.',
- group: 'system',
+ group: 'workspace',
+ order: 9,
},
planes: {
workspace: { id: 'recently-deleted', group: 'system', order: 9 },
@@ -675,7 +687,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'sso',
description: 'Configure single sign-on for your organization.',
- group: 'enterprise',
+ group: 'organization',
+ order: 6,
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso,
@@ -691,7 +704,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'sessions',
description: 'Limit session lifetimes and sign out members org-wide.',
- group: 'enterprise',
+ group: 'organization',
+ order: 7,
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies,
@@ -708,7 +722,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
id: 'data-retention',
description:
'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.',
- group: 'enterprise',
+ group: 'organization',
+ order: 8,
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention,
@@ -724,7 +739,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'data-drains',
description: 'Stream your logs and events to external destinations.',
- group: 'enterprise',
+ group: 'organization',
+ order: 9,
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains,
@@ -734,13 +750,14 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
},
},
{
- label: 'Whitelabeling',
+ label: 'White-labeling',
icon: Palette,
docsLink: 'https://docs.sim.ai/platform/enterprise/whitelabeling',
unified: {
id: 'whitelabeling',
description: 'Customize your workspace branding and appearance.',
- group: 'enterprise',
+ group: 'organization',
+ order: 5,
requiresHosted: true,
requiresEnterprise: true,
selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling,
@@ -756,7 +773,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'custom-blocks',
description: 'Publish workflows as reusable blocks for your organization.',
- group: 'enterprise',
+ group: 'organization',
+ order: 1,
requiresHosted: true,
requiresEnterprise: true,
allowNonOrgAdmin: true,
@@ -772,7 +790,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'admin',
description: 'Superuser administration and workspace tools.',
- group: 'superuser',
+ group: 'platform',
+ order: 0,
requiresAdminRole: true,
},
planes: {
@@ -785,7 +804,8 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
unified: {
id: 'mothership',
description: 'Internal Sim operations and license management.',
- group: 'superuser',
+ group: 'platform',
+ order: 1,
requiresAdminRole: true,
},
planes: {
diff --git a/apps/sim/components/settings/standalone-settings-shell.test.ts b/apps/sim/components/settings/standalone-settings-shell.test.ts
index 6927d523def..720bcdfff56 100644
--- a/apps/sim/components/settings/standalone-settings-shell.test.ts
+++ b/apps/sim/components/settings/standalone-settings-shell.test.ts
@@ -8,6 +8,7 @@ import {
ORGANIZATION_SETTINGS_ITEMS,
ORGANIZATION_SETTINGS_PATH_ALIASES,
parseSettingsPathSection,
+ SELFHOST_SETTINGS_ITEMS,
} from '@/components/settings/navigation'
describe('standalone settings section resolution', () => {
@@ -32,4 +33,14 @@ describe('standalone settings section resolution', () => {
})
).toBe('audit-logs')
})
+
+ it('keeps Subscription active for the self-host billing route', () => {
+ expect(
+ parseSettingsPathSection({
+ path: '/selfhost/settings/billing',
+ items: SELFHOST_SETTINGS_ITEMS,
+ defaultSection: 'general',
+ })
+ ).toBe('billing')
+ })
})
diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts
index db8e82adb6b..197ebec5c22 100644
--- a/apps/sim/lib/api/contracts/deployments.ts
+++ b/apps/sim/lib/api/contracts/deployments.ts
@@ -80,6 +80,7 @@ export const deploymentOperationSummarySchema = z.object({
version: z.number().int().positive(),
action: z.enum(DEPLOYMENT_OPERATION_ACTIONS),
status: deploymentOperationStatusSchema,
+ isCurrent: z.boolean().optional().default(true),
readiness: deploymentReadinessSchema,
requestedAt: z.string(),
activatedAt: z.string().nullable().optional(),
diff --git a/apps/sim/lib/api/contracts/v1/workflows.ts b/apps/sim/lib/api/contracts/v1/workflows.ts
index 10ef1c35205..8f15312ffaf 100644
--- a/apps/sim/lib/api/contracts/v1/workflows.ts
+++ b/apps/sim/lib/api/contracts/v1/workflows.ts
@@ -102,7 +102,9 @@ const v1DeploymentStateSchema = z.object({
* accepted, while `isDeployed` reflects whether a version is actually live.
* `latestDeploymentAttempt` carries the lifecycle status
* (preparing/activating/active/failed/superseded) so API consumers can poll
- * to a terminal state instead of guessing from `isDeployed` alone.
+ * to a terminal state instead of guessing from `isDeployed` alone. Its
+ * `isCurrent` field is false when the operation is historical and no longer
+ * describes the active deployment.
*/
const v1DeploymentLifecycleSchema = v1DeploymentStateSchema.extend({
activeDeployment: activeDeploymentSummarySchema.nullable(),
diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts
index 656feb72663..8d60e889a05 100644
--- a/apps/sim/lib/copilot/request/tools/executor.test.ts
+++ b/apps/sim/lib/copilot/request/tools/executor.test.ts
@@ -3,9 +3,11 @@ import '@sim/testing/mocks/executor'
import { describe, expect, it } from 'vitest'
import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants'
import {
+ buildToolExecutionContext,
pendingToolWaitBudgetMs,
toolWatchdogTimeoutMs,
} from '@/lib/copilot/request/tools/executor'
+import type { ExecutionContext } from '@/lib/copilot/request/types'
describe('toolWatchdogTimeoutMs', () => {
it('gives request-scoped MCP tools the long-running watchdog', () => {
@@ -32,3 +34,27 @@ describe('pendingToolWaitBudgetMs', () => {
)
})
})
+
+describe('buildToolExecutionContext', () => {
+ it('threads logical tool-call identity into the handler context', () => {
+ const executionContext: ExecutionContext = {
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ runId: 'run-1',
+ }
+
+ expect(
+ buildToolExecutionContext(
+ {
+ id: 'call-1',
+ parentToolCallId: 'parent-1',
+ },
+ executionContext
+ )
+ ).toMatchObject({
+ runId: 'run-1',
+ toolCallId: 'call-1',
+ parentToolCallId: 'parent-1',
+ })
+ })
+})
diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts
index 17106c64ce3..10f0f84402c 100644
--- a/apps/sim/lib/copilot/request/tools/executor.ts
+++ b/apps/sim/lib/copilot/request/tools/executor.ts
@@ -255,6 +255,18 @@ class ToolExecutionTimeoutError extends Error {
}
}
+/** Builds the per-call context from the turn-scoped execution context. */
+export function buildToolExecutionContext(
+ toolCall: Pick
,
+ execContext: ExecutionContext
+): ExecutionContext {
+ return {
+ ...execContext,
+ toolCallId: toolCall.id,
+ ...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}),
+ }
+}
+
/**
* Execute a tool with a hard settlement guarantee. If the handler neither
* resolves nor rejects within the tool's watchdog cap, throw a timeout error
@@ -265,12 +277,7 @@ class ToolExecutionTimeoutError extends Error {
*/
async function executeToolWithWatchdog(toolCall: ToolCallState, execContext: ExecutionContext) {
const timeoutMs = toolWatchdogTimeoutMs(toolCall.name)
- // Thread the invoking subagent's channel id per call (execContext is shared
- // across the whole turn, so the channel id can't live on it) — server tools
- // use it to scope the workspace_file -> edit_content intent handoff.
- const toolContext = toolCall.parentToolCallId
- ? { ...execContext, parentToolCallId: toolCall.parentToolCallId }
- : execContext
+ const toolContext = buildToolExecutionContext(toolCall, execContext)
const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext)
let timer: ReturnType | undefined
try {
diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts
index 4cd2d3f7140..5c75b645730 100644
--- a/apps/sim/lib/copilot/request/tools/permission.test.ts
+++ b/apps/sim/lib/copilot/request/tools/permission.test.ts
@@ -94,6 +94,18 @@ describe('toolCallNeedsApproval', () => {
expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false)
})
+ it.each(['deploy_api', 'deploy_chat', 'deploy_mcp'])(
+ 'honors the saved permission for a %s undeploy',
+ (toolName) => {
+ const context = makeContext()
+ context.toolPermissions.autoAllowed.add(toolName)
+
+ expect(toolCallNeedsApproval(toolName, context, {}, false, { action: 'undeploy' })).toBe(
+ false
+ )
+ }
+ )
+
it('applies the normal saved permission to code with a secret reference', () => {
const context = makeContext()
context.toolPermissions.autoAllowed.add('function_execute')
diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts
index 93db4b4eb23..f25e27e24c2 100644
--- a/apps/sim/lib/copilot/tool-executor/types.ts
+++ b/apps/sim/lib/copilot/tool-executor/types.ts
@@ -11,6 +11,8 @@ export interface ToolExecutionContext {
messageId?: string
executionId?: string
runId?: string
+ /** Stable identity of the individual tool call being executed. */
+ toolCallId?: string
billingAttribution?: BillingAttributionSnapshot
copilotToolExecution?: boolean
requestMode?: string
diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts
new file mode 100644
index 00000000000..3fb9d61ea97
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts
@@ -0,0 +1,39 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ getCopilotDeploymentIdempotencyKey,
+ getHistoricalDeploymentAttemptError,
+} from '@/lib/copilot/tools/handlers/deployment/context'
+
+describe('getCopilotDeploymentIdempotencyKey', () => {
+ it('is stable for a replay of the same logical tool call', () => {
+ const context = { executionId: 'execution-1', runId: 'run-1', toolCallId: 'call-1' }
+
+ expect(getCopilotDeploymentIdempotencyKey(context)).toBe(
+ getCopilotDeploymentIdempotencyKey(context)
+ )
+ })
+
+ it('separates different tool calls within the same Mothership execution', () => {
+ expect(
+ getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-1' })
+ ).not.toBe(
+ getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1', toolCallId: 'call-2' })
+ )
+ })
+
+ it('does not derive a turn-wide key when the tool-call identity is unavailable', () => {
+ expect(getCopilotDeploymentIdempotencyKey({ executionId: 'execution-1' })).toBeUndefined()
+ })
+})
+
+describe('getHistoricalDeploymentAttemptError', () => {
+ it('requires a new tool call when the persisted attempt is no longer current', () => {
+ expect(getHistoricalDeploymentAttemptError({ isCurrent: false }, 'redeploy')).toContain(
+ 'Start a new tool call'
+ )
+ expect(getHistoricalDeploymentAttemptError({ isCurrent: true }, 'redeploy')).toBeNull()
+ })
+})
diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts
new file mode 100644
index 00000000000..c4affb106a6
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts
@@ -0,0 +1,35 @@
+import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types'
+
+type DeploymentToolContext = Pick<
+ ToolExecutionContext,
+ 'executionId' | 'messageId' | 'runId' | 'toolCallId'
+>
+
+interface DeploymentAttemptCurrentState {
+ isCurrent?: boolean
+}
+
+/**
+ * Builds a replay-stable idempotency key for one logical Copilot tool call.
+ * The orchestration layer generates a fresh key when legacy callers do not
+ * provide a tool-call identity.
+ */
+export function getCopilotDeploymentIdempotencyKey(
+ context: DeploymentToolContext
+): string | undefined {
+ if (!context.toolCallId) return undefined
+
+ const executionScope = context.executionId ?? context.runId ?? context.messageId
+ return executionScope
+ ? `copilot:${executionScope}:tool-call:${context.toolCallId}`
+ : `copilot:tool-call:${context.toolCallId}`
+}
+
+/** Rejects a replay whose persisted operation no longer describes production. */
+export function getHistoricalDeploymentAttemptError(
+ attempt: DeploymentAttemptCurrentState | null | undefined,
+ action: string
+): string | null {
+ if (attempt?.isCurrent !== false) return null
+ return `The ${action} operation associated with this tool call is historical and no longer describes production. Start a new tool call to create a new logical deployment operation.`
+}
diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts
new file mode 100644
index 00000000000..e0595796572
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts
@@ -0,0 +1,237 @@
+/**
+ * @vitest-environment node
+ */
+import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCheckChatAccess,
+ mockEnsureWorkflowAccess,
+ mockPerformChatUndeploy,
+ mockPerformDeleteWorkflowMcpTool,
+ mockPerformFullDeploy,
+ mockPerformFullUndeploy,
+} = vi.hoisted(() => ({
+ mockCheckChatAccess: vi.fn(),
+ mockEnsureWorkflowAccess: vi.fn(),
+ mockPerformChatUndeploy: vi.fn(),
+ mockPerformDeleteWorkflowMcpTool: vi.fn(),
+ mockPerformFullDeploy: vi.fn(),
+ mockPerformFullUndeploy: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/orchestration', () => ({
+ performChatDeploy: vi.fn(),
+ performChatUndeploy: mockPerformChatUndeploy,
+ performFullDeploy: mockPerformFullDeploy,
+ performFullUndeploy: mockPerformFullUndeploy,
+}))
+
+vi.mock('@/lib/mcp/orchestration', () => ({
+ performCreateWorkflowMcpTool: vi.fn(),
+ performDeleteWorkflowMcpTool: mockPerformDeleteWorkflowMcpTool,
+ performUpdateWorkflowMcpTool: vi.fn(),
+}))
+
+vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
+ getDeployedWorkflowInputFormat: vi.fn(),
+}))
+
+vi.mock('@/lib/mcp/workflow-tool-schema', () => ({
+ applyDescriptionOverrides: vi.fn(),
+ generateToolInputSchema: vi.fn(),
+ sanitizeToolName: vi.fn(),
+}))
+
+vi.mock('@/app/api/chat/utils', () => ({
+ checkChatAccess: mockCheckChatAccess,
+ checkWorkflowAccessForChatCreation: vi.fn(),
+}))
+
+vi.mock('@/ee/access-control/utils/permission-check', () => ({
+ ChatDeployAuthNotAllowedError: class ChatDeployAuthNotAllowedError extends Error {},
+ validateChatDeployAuth: vi.fn(),
+}))
+
+vi.mock('@/lib/copilot/tools/handlers/access', () => ({
+ ensureWorkflowAccess: mockEnsureWorkflowAccess,
+}))
+
+import {
+ executeDeployApi,
+ executeDeployChat,
+ executeDeployMcp,
+ executeRedeploy,
+} from '@/lib/copilot/tools/handlers/deployment/deploy'
+
+describe('deployment handlers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockEnsureWorkflowAccess.mockResolvedValue({
+ workflow: { id: 'workflow-1', workspaceId: 'workspace-1' },
+ })
+ })
+
+ it('undeploys the API without approval context when permission gating is disabled', async () => {
+ mockPerformFullUndeploy.mockResolvedValue({ success: true })
+
+ const result = await executeDeployApi(
+ { workflowId: 'workflow-1', action: 'undeploy' },
+ {
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ toolCallId: 'call-1',
+ }
+ )
+
+ expect(result.success).toBe(true)
+ expect(mockPerformFullUndeploy).toHaveBeenCalledWith({
+ workflowId: 'workflow-1',
+ userId: 'user-1',
+ })
+ })
+
+ it('uses the tool-call identity for deployment idempotency', async () => {
+ mockPerformFullDeploy.mockResolvedValue({
+ success: true,
+ activeDeployment: null,
+ latestDeploymentAttempt: { status: 'preparing' },
+ })
+
+ await executeDeployApi(
+ {
+ workflowId: 'workflow-1',
+ action: 'deploy',
+ versionName: 'Safe deploy',
+ versionDescription: 'Deploy the latest workflow changes',
+ },
+ {
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ toolCallId: 'call-1',
+ }
+ )
+
+ expect(mockPerformFullDeploy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ idempotencyKey: 'copilot:execution-1:tool-call:call-1',
+ })
+ )
+ })
+
+ it('rejects a replay whose active deployment attempt became historical', async () => {
+ mockPerformFullDeploy.mockResolvedValue({
+ success: true,
+ activeDeployment: null,
+ latestDeploymentAttempt: { status: 'active', isCurrent: false },
+ })
+
+ const result = await executeDeployApi(
+ {
+ workflowId: 'workflow-1',
+ action: 'deploy',
+ versionName: 'Safe deploy',
+ versionDescription: 'Deploy the latest workflow changes',
+ },
+ {
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ toolCallId: 'call-1',
+ }
+ )
+
+ expect(result).toMatchObject({
+ success: false,
+ error: expect.stringContaining('historical'),
+ })
+ })
+
+ it('does not report a historical active attempt as a successful redeploy', async () => {
+ mockPerformFullDeploy.mockResolvedValue({
+ success: true,
+ activeDeployment: null,
+ latestDeploymentAttempt: { status: 'active', isCurrent: false },
+ })
+
+ const result = await executeRedeploy(
+ {
+ workflowId: 'workflow-1',
+ versionName: 'Safe redeploy',
+ versionDescription: 'Redeploy the latest workflow changes',
+ },
+ {
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ toolCallId: 'call-1',
+ }
+ )
+
+ expect(result).toMatchObject({
+ success: false,
+ error: expect.stringContaining('historical'),
+ })
+ })
+
+ it('undeploys chat without approval context when permission gating is disabled', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ id: 'chat-1',
+ identifier: 'production-helper',
+ title: 'Production Helper',
+ description: null,
+ authType: 'public',
+ allowedEmails: [],
+ outputConfigs: [],
+ includeThinking: false,
+ includeToolCalls: false,
+ customizations: null,
+ },
+ ])
+ mockCheckChatAccess.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1' })
+ mockPerformChatUndeploy.mockResolvedValue({ success: true })
+
+ const result = await executeDeployChat(
+ { workflowId: 'workflow-1', action: 'undeploy' },
+ {
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ toolCallId: 'call-1',
+ }
+ )
+
+ expect(result.success).toBe(true)
+ expect(mockPerformChatUndeploy).toHaveBeenCalledWith({
+ chatId: 'chat-1',
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ })
+ })
+
+ it('undeploys MCP without approval context when permission gating is disabled', async () => {
+ dbChainMockFns.limit
+ .mockResolvedValueOnce([{ id: 'server-1', name: 'Production MCP' }])
+ .mockResolvedValueOnce([{ id: 'tool-1' }])
+ mockPerformDeleteWorkflowMcpTool.mockResolvedValue({ success: true })
+
+ const result = await executeDeployMcp(
+ { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' },
+ {
+ userId: 'user-1',
+ workflowId: 'workflow-1',
+ toolCallId: 'call-1',
+ }
+ )
+
+ expect(result.success).toBe(true)
+ expect(mockPerformDeleteWorkflowMcpTool).toHaveBeenCalledWith({
+ serverId: 'server-1',
+ toolId: 'tool-1',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ })
+ })
+})
diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
index f0d14b9ba29..a1db42f3e4c 100644
--- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
@@ -28,6 +28,7 @@ import {
} from '@/ee/access-control/utils/permission-check'
import { ensureWorkflowAccess } from '../access'
import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types'
+import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context'
function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string {
return `${baseUrl}/api/workflows/${workflowId}/execute`
@@ -190,10 +191,16 @@ export async function executeDeployApi(
userId: context.userId,
versionDescription,
versionName,
+ idempotencyKey: getCopilotDeploymentIdempotencyKey(context),
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to deploy workflow' }
}
+ const historicalAttemptError = getHistoricalDeploymentAttemptError(
+ result.latestDeploymentAttempt,
+ 'deploy'
+ )
+ if (historicalAttemptError) return { success: false, error: historicalAttemptError }
const baseUrl = getBaseUrl()
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
@@ -451,6 +458,7 @@ export async function executeDeployChat(
includeThinking: resolvedIncludeThinking,
includeToolCalls: resolvedIncludeToolCalls,
workspaceId: workflowRecord.workspaceId,
+ idempotencyKey: getCopilotDeploymentIdempotencyKey(context),
})
if (!result.success) {
@@ -827,10 +835,16 @@ export async function executeRedeploy(
userId: context.userId,
versionDescription,
versionName,
+ idempotencyKey: getCopilotDeploymentIdempotencyKey(context),
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to redeploy workflow' }
}
+ const historicalAttemptError = getHistoricalDeploymentAttemptError(
+ result.latestDeploymentAttempt,
+ 'redeploy'
+ )
+ if (historicalAttemptError) return { success: false, error: historicalAttemptError }
const baseUrl = getBaseUrl()
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts
index eb2cc11467b..fd8ba3c06d2 100644
--- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts
@@ -169,12 +169,18 @@ describe('executePromoteToLive', () => {
performActivateVersionMock.mockResolvedValue({
success: true,
deployedAt: new Date('2026-05-30T00:00:00.000Z'),
+ activeDeployment: {
+ deploymentVersionId: 'dv-3',
+ version: 3,
+ deployedAt: '2026-05-30T00:00:00.000Z',
+ },
latestDeploymentAttempt: {
id: 'op-1',
deploymentVersionId: 'dv-3',
version: 3,
action: 'activate',
status: 'active',
+ isCurrent: true,
readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' },
requestedAt: '2026-05-30T00:00:00.000Z',
activatedAt: '2026-05-30T00:00:00.000Z',
@@ -185,6 +191,8 @@ describe('executePromoteToLive', () => {
const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, {
userId: 'user-1',
workflowId: 'wf-1',
+ executionId: 'execution-1',
+ toolCallId: 'call-1',
} as ExecutionContext)
expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin')
@@ -192,6 +200,7 @@ describe('executePromoteToLive', () => {
workflowId: 'wf-1',
version: 3,
userId: 'user-1',
+ idempotencyKey: 'copilot:execution-1:tool-call:call-1',
})
expect(result.success).toBe(true)
expect(result.output).toMatchObject({
@@ -203,6 +212,37 @@ describe('executePromoteToLive', () => {
})
})
+ it('does not report a historical active operation as a successful promotion', async () => {
+ performActivateVersionMock.mockResolvedValue({
+ success: true,
+ activeDeployment: null,
+ latestDeploymentAttempt: {
+ id: 'op-old',
+ deploymentVersionId: 'dv-3',
+ version: 3,
+ action: 'activate',
+ status: 'active',
+ isCurrent: false,
+ readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' },
+ requestedAt: '2026-05-30T00:00:00.000Z',
+ activatedAt: '2026-05-30T00:00:00.000Z',
+ error: null,
+ },
+ })
+
+ const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, {
+ userId: 'user-1',
+ workflowId: 'wf-1',
+ executionId: 'execution-1',
+ toolCallId: 'call-1',
+ } as ExecutionContext)
+
+ expect(result).toMatchObject({
+ success: false,
+ error: expect.stringContaining('historical'),
+ })
+ })
+
it('rejects a non-numeric version like "live"', async () => {
const result = await executePromoteToLive({ workflowId: 'wf-1', version: 'live' as never }, {
userId: 'user-1',
@@ -376,4 +416,44 @@ describe('executeCheckDeploymentStatus', () => {
},
})
})
+
+ it('separates a historical active attempt from the current undeployed state', async () => {
+ getWorkflowDeploymentSummaryMock.mockResolvedValue({
+ activeDeployment: null,
+ latestDeploymentAttempt: {
+ id: 'op-historical',
+ deploymentVersionId: 'dv-old',
+ version: 1,
+ action: 'deploy',
+ status: 'active',
+ isCurrent: false,
+ readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' },
+ requestedAt: '2026-05-28T00:00:00.000Z',
+ activatedAt: '2026-05-28T00:00:00.000Z',
+ error: null,
+ },
+ warnings: ['The latest successful deployment attempt is historical.'],
+ })
+ queueTableRows(schemaMock.workflow, [{ deployedAt: null }])
+
+ const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, {
+ userId: 'user-1',
+ workflowId: 'wf-1',
+ } as ExecutionContext)
+
+ expect(result.success).toBe(true)
+ expect(result.output).toMatchObject({
+ isDeployed: false,
+ api: {
+ isDeployed: false,
+ activeDeployment: null,
+ latestDeploymentAttempt: {
+ status: 'active',
+ isCurrent: false,
+ },
+ currentDeploymentAttempt: null,
+ warnings: [expect.stringContaining('historical')],
+ },
+ })
+ })
})
diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts
index ff61d1762d1..4224a0d0ce5 100644
--- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts
@@ -32,6 +32,7 @@ import type {
UpdateDeploymentVersionParams,
UpdateWorkspaceMcpServerParams,
} from '../param-types'
+import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context'
import { resolveWorkflowStateRef } from './state-refs'
export async function executeCheckDeploymentStatus(
@@ -79,6 +80,9 @@ export async function executeCheckDeploymentStatus(
*/
const isApiDeployed = deploymentSummary.activeDeployment !== null
const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false
+ const currentDeploymentAttempt = deploymentSummary.latestDeploymentAttempt?.isCurrent
+ ? deploymentSummary.latestDeploymentAttempt
+ : null
const apiDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy[0]?.deployedAt || null,
@@ -87,6 +91,7 @@ export async function executeCheckDeploymentStatus(
needsRedeployment,
activeDeployment: deploymentSummary.activeDeployment,
latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt,
+ currentDeploymentAttempt,
warnings: deploymentSummary.warnings ?? [],
}
@@ -557,13 +562,19 @@ export async function executePromoteToLive(
workflowId,
version,
userId: context.userId,
+ idempotencyKey: getCopilotDeploymentIdempotencyKey(context),
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to promote version' }
}
+ const historicalAttemptError = getHistoricalDeploymentAttemptError(
+ result.latestDeploymentAttempt,
+ 'promotion'
+ )
+ if (historicalAttemptError) return { success: false, error: historicalAttemptError }
- const isActive = result.latestDeploymentAttempt?.status === 'active'
+ const isActive = result.activeDeployment?.version === version
return {
success: true,
output: {
diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts
index 7b48bab36e1..9ee006b26be 100644
--- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts
+++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts
@@ -89,4 +89,24 @@ describe('performChatDeploy password guards', () => {
error: 'Password is required when using password protection',
})
})
+
+ it('does not create a chat from a historical active deployment attempt', async () => {
+ mockGetWorkflowDeploymentSummary.mockResolvedValue({
+ activeDeployment: null,
+ latestDeploymentAttempt: { status: 'active', isCurrent: false },
+ warnings: [],
+ })
+ mockPerformFullDeploy.mockResolvedValue({
+ success: true,
+ activeDeployment: null,
+ latestDeploymentAttempt: { status: 'active', isCurrent: false },
+ })
+
+ const result = await performChatDeploy(basePayload)
+
+ expect(result).toMatchObject({
+ success: false,
+ error: expect.stringContaining('historical'),
+ })
+ })
})
diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts
index 106cb090df0..cdfffe62237 100644
--- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts
+++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts
@@ -35,6 +35,8 @@ export interface ChatDeployPayload {
/** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */
includeToolCalls?: boolean
workspaceId?: string | null
+ /** Stable identity for the underlying workflow deployment operation. */
+ idempotencyKey?: string
}
export interface PerformChatDeployResult {
@@ -114,10 +116,18 @@ export async function performChatDeploy(
userId,
versionDescription: params.versionDescription,
versionName: params.versionName,
+ idempotencyKey: params.idempotencyKey,
})
if (!deployResult.success) {
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
}
+ if (deployResult.latestDeploymentAttempt?.isCurrent === false) {
+ return {
+ success: false,
+ error:
+ 'The workflow deployment attempt is historical and no longer describes production. Retry chat deployment as a new tool call.',
+ }
+ }
if (deployResult.latestDeploymentAttempt?.status !== 'active') {
return {
success: false,
@@ -126,6 +136,12 @@ export async function performChatDeploy(
'Workflow deployment is still preparing. Retry chat deployment after it becomes active.',
}
}
+ if (!deployResult.activeDeployment) {
+ return {
+ success: false,
+ error: 'Workflow deployment reported active without a live deployment version.',
+ }
+ }
}
let encryptedPassword: string | null = null
diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts
index e70aea0eba0..4d194892e17 100644
--- a/apps/sim/lib/workflows/orchestration/deploy.test.ts
+++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts
@@ -101,6 +101,7 @@ vi.mock('@/lib/workflows/schedules', () => ({
// Resolves to the global @sim/platform-authz/workflow mock, so instanceof matches.
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
import {
+ getWorkflowDeploymentSummary,
performActivateVersion,
performFullDeploy,
performFullUndeploy,
@@ -262,6 +263,50 @@ describe('performFullDeploy workspace event emission', () => {
})
})
+ it('marks the latest active operation historical when no matching version is live', async () => {
+ const now = new Date('2026-07-14T08:00:00.000Z')
+ mockGetWorkflowDeploymentStatus.mockResolvedValueOnce({
+ activeDeployment: null,
+ latestOperation: {
+ id: 'operation-historical',
+ workflowId: 'workflow-1',
+ deploymentVersionId: 'dv-old',
+ version: 3,
+ previousActiveVersionId: null,
+ action: 'deploy',
+ protocolVersion: 2,
+ generation: 1,
+ status: 'active',
+ componentReadiness: {
+ webhooks: { status: 'ready', updatedAt: now.toISOString() },
+ schedules: { status: 'ready', updatedAt: now.toISOString() },
+ mcp: { status: 'ready', updatedAt: now.toISOString() },
+ },
+ errorCode: null,
+ errorMessage: null,
+ idempotencyKey: 'request-historical',
+ requestHash: 'hash',
+ actorId: 'user-1',
+ completedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ },
+ })
+
+ const result = await getWorkflowDeploymentSummary('workflow-1')
+
+ expect(result).toMatchObject({
+ activeDeployment: null,
+ latestDeploymentAttempt: {
+ id: 'operation-historical',
+ status: 'active',
+ isCurrent: false,
+ error: null,
+ },
+ warnings: [expect.stringContaining('historical')],
+ })
+ })
+
it('always admits deploys through v2 without legacy immediate activation', async () => {
const result = await performFullDeploy({
workflowId: 'workflow-1',
@@ -277,6 +322,62 @@ describe('performFullDeploy workspace event emission', () => {
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
+ it('does not reuse a correlation request ID as an implicit idempotency key', async () => {
+ queueTableRows(schemaMock.workflow, [
+ { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
+ { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
+ ])
+
+ const params = { workflowId: 'workflow-1', userId: 'user-1', requestId: 'request-1' }
+ await performFullDeploy(params)
+ await performFullDeploy(params)
+
+ const firstKey = mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey
+ const secondKey = mockPrepareWorkflowDeployment.mock.calls[1][0].idempotencyKey
+ expect(firstKey).toEqual(expect.any(String))
+ expect(secondKey).toEqual(expect.any(String))
+ expect(firstKey).not.toBe('request-1')
+ expect(firstKey).not.toBe(secondKey)
+ })
+
+ it('keeps the request hash stable across snapshot timestamps and edge order', async () => {
+ queueTableRows(schemaMock.workflow, [
+ { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
+ { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
+ ])
+ const baseState = {
+ blocks: {},
+ edges: [
+ { id: 'edge-b', source: 'block-2', target: 'block-3' },
+ { id: 'edge-a', source: 'block-1', target: 'block-2' },
+ ],
+ loops: {},
+ parallels: {},
+ variables: {},
+ lastSaved: 1,
+ }
+ mockLoadWorkflowDeploymentSnapshot.mockResolvedValueOnce(baseState).mockResolvedValueOnce({
+ ...baseState,
+ edges: [...baseState.edges].reverse(),
+ lastSaved: 2,
+ })
+
+ const params = {
+ workflowId: 'workflow-1',
+ userId: 'user-1',
+ idempotencyKey: 'copilot:execution-1:tool-call:call-1',
+ }
+ await performFullDeploy(params)
+ await performFullDeploy(params)
+
+ expect(mockPrepareWorkflowDeployment.mock.calls[0][0].requestHash).toBe(
+ mockPrepareWorkflowDeployment.mock.calls[1][0].requestHash
+ )
+ expect(mockPrepareWorkflowDeployment.mock.calls[0][0].idempotencyKey).toBe(
+ 'copilot:execution-1:tool-call:call-1'
+ )
+ })
+
it('keeps a first deploy pending without claiming an active deployment', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts
index 2abd4e04e67..5ab57a53b0d 100644
--- a/apps/sim/lib/workflows/orchestration/deploy.ts
+++ b/apps/sim/lib/workflows/orchestration/deploy.ts
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
@@ -11,6 +12,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { captureServerEvent } from '@/lib/posthog/server'
import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy'
+import { normalizedStringify } from '@/lib/workflows/comparison/normalize'
import {
DEPLOYMENT_ERROR_CODES,
type DeploymentComponentStatus,
@@ -59,6 +61,8 @@ export interface DeploymentAttemptResult {
version: number
action: 'deploy' | 'activate'
status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded'
+ /** Whether this attempt still describes the workflow's current deployment lifecycle. */
+ isCurrent: boolean
readiness: {
webhooks: DeploymentReadinessSummaryStatus
schedules: DeploymentReadinessSummaryStatus
@@ -88,6 +92,9 @@ export interface PerformFullDeployParams {
* endpoint, so it stays optional here.
*/
versionName?: string
+ /** Stable identity for one logical deployment operation. */
+ idempotencyKey?: string
+ /** Correlation ID for logging and outbox tracing. */
requestId?: string
/**
* Override the actor ID used in audit logs and the `deployedBy` field.
@@ -125,7 +132,9 @@ export interface PerformFullDeployResult {
/**
* Admits a deployment through the v2 prepare/activate protocol. The candidate
- * version remains inactive until every required side effect is ready.
+ * version remains inactive until every required side effect is ready. Callers
+ * that can replay a logical operation must provide a stable `idempotencyKey`;
+ * `requestId` is correlation metadata only.
*/
export async function performFullDeploy(
params: PerformFullDeployParams
@@ -133,6 +142,7 @@ export async function performFullDeploy(
const { workflowId, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
+ const idempotencyKey = params.idempotencyKey ?? generateId()
// Backstop for every caller — routes may assert first to render their own 423,
// but the copilot deploy tools call this directly.
@@ -154,6 +164,7 @@ export async function performFullDeploy(
params,
actorId,
requestId,
+ idempotencyKey,
})
} catch (error) {
logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error })
@@ -169,6 +180,7 @@ async function performStableFullDeploy(params: {
params: PerformFullDeployParams
actorId: string
requestId: string
+ idempotencyKey: string
}): Promise {
const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId)
if (!workflowState) {
@@ -190,11 +202,11 @@ async function performStableFullDeploy(params: {
action: 'deploy',
workflowId: params.params.workflowId,
userId: params.params.userId,
- workflowState,
+ workflowState: canonicalizeDeploymentWorkflowState(workflowState),
versionName: params.params.versionName ?? null,
versionDescription: params.params.versionDescription ?? null,
}),
- idempotencyKey: params.requestId,
+ idempotencyKey: params.idempotencyKey,
workflowState,
name: params.params.versionName,
description: params.params.versionDescription,
@@ -291,8 +303,22 @@ async function validateDeploymentState(
return { success: true }
}
+function canonicalizeDeploymentWorkflowState(
+ workflowState: WorkflowState
+): Record {
+ const { lastSaved: _lastSaved, edges, ...stableState } = workflowState
+ const sortedEdges = [...edges].sort((left, right) => {
+ if (left.id !== right.id) return left.id < right.id ? -1 : 1
+ const normalizedLeft = normalizedStringify(left)
+ const normalizedRight = normalizedStringify(right)
+ if (normalizedLeft === normalizedRight) return 0
+ return normalizedLeft < normalizedRight ? -1 : 1
+ })
+ return { ...stableState, edges: sortedEdges }
+}
+
function createDeploymentRequestHash(value: Record): string {
- return sha256Hex(JSON.stringify(value))
+ return sha256Hex(normalizedStringify(value))
}
function mapPrepareFailureCode(
@@ -337,7 +363,10 @@ function buildStableDeploymentResult(
deployedAt: status.activeDeployment.deployedAt.toISOString(),
}
: null
- const latestDeploymentAttempt = summarizeDeploymentOperation(status.latestOperation)
+ const latestDeploymentAttempt = summarizeDeploymentOperation(
+ status.latestOperation,
+ status.activeDeployment?.deploymentVersionId ?? null
+ )
const warning = getStableDeploymentWarning(
latestDeploymentAttempt,
processResult,
@@ -375,7 +404,8 @@ export async function getWorkflowDeploymentSummary(workflowId: string): Promise<
}
function summarizeDeploymentOperation(
- operation: WorkflowDeploymentOperation | null
+ operation: WorkflowDeploymentOperation | null,
+ activeDeploymentVersionId: string | null
): DeploymentAttemptResult | null {
if (!operation) return null
if (
@@ -395,6 +425,10 @@ function summarizeDeploymentOperation(
version: operation.version,
action: operation.action,
status: operation.status,
+ isCurrent:
+ operation.status === 'active'
+ ? operation.deploymentVersionId === activeDeploymentVersionId
+ : operation.status !== 'superseded',
readiness: {
webhooks: componentStatus('webhooks'),
schedules: componentStatus('schedules'),
@@ -420,6 +454,9 @@ function getStableDeploymentWarning(
hasActiveDeployment: boolean
): string | undefined {
if (!attempt) return undefined
+ if (attempt.status === 'active' && !attempt.isCurrent) {
+ return 'The latest successful deployment attempt is historical; no matching deployment version is currently active.'
+ }
if (attempt.status === 'preparing' || attempt.status === 'activating') {
if (processResult === 'processing_error') {
return hasActiveDeployment
@@ -547,6 +584,9 @@ export interface PerformActivateVersionParams {
workflowId: string
version: number
userId: string
+ /** Stable identity for one logical activation operation. */
+ idempotencyKey?: string
+ /** Correlation ID for logging and outbox tracing. */
requestId?: string
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
@@ -582,7 +622,8 @@ export interface PerformRevertToVersionResult {
}
/**
- * Admits an existing version through the v2 prepare/activate protocol.
+ * Admits an existing version through the v2 prepare/activate protocol. Callers
+ * that can replay a logical operation must provide a stable `idempotencyKey`.
*/
export async function performActivateVersion(
params: PerformActivateVersionParams
@@ -590,6 +631,7 @@ export async function performActivateVersion(
const { workflowId, version, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
+ const idempotencyKey = params.idempotencyKey ?? generateId()
const lockDenial = await workflowLockDenial(workflowId)
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' }
@@ -665,6 +707,7 @@ export async function performActivateVersion(
userId,
actorId,
requestId,
+ idempotencyKey,
})
} catch (error) {
logger.error(`[${requestId}] Version activation preparation failed`, {
@@ -687,6 +730,7 @@ async function performStableVersionActivation(params: {
userId: string
actorId: string
requestId: string
+ idempotencyKey: string
}): Promise {
let outboxEventId: string | undefined
const prepared = await prepareWorkflowVersionActivation({
@@ -700,7 +744,7 @@ async function performStableVersionActivation(params: {
version: params.version,
userId: params.userId,
}),
- idempotencyKey: params.requestId,
+ idempotencyKey: params.idempotencyKey,
readinessComponents: DEPLOYMENT_READINESS_COMPONENTS,
onPrepareTransaction: async (tx, operation) => {
if (!operation.deploymentVersionId || operation.version === null) {