improvement(misc): settings organization, chat agent deploy request keys (#6195)

* improvement(misc): settings organization, chat agent deploy request keys

* fix subtitle

* adjust perms
This commit is contained in:
Vikhyath Mondreti
2026-08-02 14:07:19 -07:00
committed by GitHub
parent b8ec114382
commit 746756d8a9
27 changed files with 919 additions and 110 deletions
@@ -168,6 +168,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
<Billing
scope={organizationId ? 'organization' : 'account'}
organizationId={organizationId ?? undefined}
governingWorkspaceName={hostContext.workspace.name}
creditUsageHref={`/workspace/${hostContext.workspace.id}/settings/billing/credit-usage`}
/>
)}
@@ -151,7 +151,7 @@ export function CreditUsageView({ backHref = '/account/settings/billing' }: Cred
return (
<SettingsPanel
back={{
text: 'Billing',
text: 'Subscription',
icon: ArrowLeft,
onSelect: () => router.push(backHref),
}}
@@ -17,7 +17,7 @@ export function CreditUsageLoading({ backHref }: CreditUsageLoadingProps) {
return (
<SettingsPanel
back={{
text: 'Billing',
text: 'Subscription',
icon: ArrowLeft,
onSelect: () => router.push(backHref),
}}
@@ -161,7 +161,12 @@ vi.mock(
)
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({
SettingsPanel: ({ children }: { children: ReactNode }) => <main>{children}</main>,
SettingsPanel: ({ children, description }: { children: ReactNode; description?: string }) => (
<main>
{description && <p>{description}</p>}
{children}
</main>
),
}))
vi.mock(
@@ -259,7 +264,13 @@ describe('Billing payer scope', () => {
it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => {
await act(async () => {
root.render(<Billing scope='organization' organizationId='org-target' />)
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Production'
/>
)
})
expect(mockUseSubscriptionData).toHaveBeenCalledWith(
@@ -270,6 +281,9 @@ describe('Billing payer scope', () => {
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
).toBe('Explore organization plans')
expect(container.textContent).toContain('Organization Max for Teams plan')
expect(container.textContent).toContain(
'Target organization’s subscription governs Production.'
)
expect(container.textContent).toContain('billed annually')
expect(container.textContent).toContain('Access until')
expect(container.textContent).toContain('Subscription canceled')
@@ -293,13 +307,35 @@ describe('Billing payer scope', () => {
it('uses a guaranteed personal payer workspace for account upgrades', async () => {
await act(async () => {
root.render(<Billing scope='account' />)
root.render(<Billing scope='account' governingWorkspaceName='Personal workspace' />)
})
expect(
container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent
).toBe('Explore personal plans')
expect(container.textContent).toContain('Personal Pro plan')
expect(container.textContent).toContain(
'Your personal subscription governs Personal workspace.'
)
})
it('does not show a governing subscription description for a free personal workspace', async () => {
mockPersonalQuery.current = {
data: {
success: true,
context: 'user',
data: { ...PERSONAL_DATA, plan: 'free', status: 'active' },
},
isLoading: false,
refetch: vi.fn(),
}
await act(async () => {
root.render(<Billing scope='account' governingWorkspaceName='Free workspace' />)
})
expect(container.textContent).toContain('Personal Free plan')
expect(container.querySelector('main > p')).toBeNull()
})
it('renders an explicit free organization state without subscription controls', async () => {
@@ -319,12 +355,19 @@ describe('Billing payer scope', () => {
}
await act(async () => {
root.render(<Billing scope='organization' organizationId='org-target' />)
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Free organization workspace'
/>
)
})
expect(container.textContent).toContain('Organization Free plan')
expect(container.textContent).toContain('No active organization subscription')
expect(container.textContent).not.toContain('Payment method')
expect(container.querySelector('main > p')).toBeNull()
})
it('renders lapsed organization plans as ended rather than active', async () => {
@@ -342,12 +385,19 @@ describe('Billing payer scope', () => {
}
await act(async () => {
root.render(<Billing scope='organization' organizationId='org-target' />)
root.render(
<Billing
scope='organization'
organizationId='org-target'
governingWorkspaceName='Lapsed organization workspace'
/>
)
})
expect(container.textContent).toContain('Organization Max for Teams plan ended')
expect(container.textContent).toContain('Choose a new plan for this organization')
expect(container.textContent).not.toContain('Cancel subscription')
expect(container.querySelector('main > p')).toBeNull()
expect(
container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent
).toBe('Explore organization plans')
@@ -102,9 +102,15 @@ interface BillingProps {
scope: 'account' | 'organization'
organizationId?: string
creditUsageHref?: string
governingWorkspaceName?: string
}
export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) {
export function Billing({
scope,
organizationId,
creditUsageHref,
governingWorkspaceName,
}: BillingProps) {
const router = useRouter()
const isOrganizationScope = scope === 'organization'
@@ -447,9 +453,16 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
const explorePlansLabel = isOrganizationScope
? 'Explore organization plans'
: 'Explore personal plans'
const subscriptionOwner = isOrganizationScope
? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription`
: 'Your personal subscription'
const settingsDescription =
governingWorkspaceName && subscription.isPaid
? `${subscriptionOwner} governs ${governingWorkspaceName}.`
: undefined
return (
<SettingsPanel>
<SettingsPanel description={settingsDescription}>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2.5'>
<div className='size-9 flex-shrink-0'>
@@ -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
@@ -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()
@@ -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) => (
@@ -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', () => {
+63 -43
View File
@@ -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: {
@@ -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')
})
})
@@ -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(),
+3 -1
View File
@@ -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(),
@@ -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',
})
})
})
+13 -6
View File
@@ -255,6 +255,18 @@ class ToolExecutionTimeoutError extends Error {
}
}
/** Builds the per-call context from the turn-scoped execution context. */
export function buildToolExecutionContext(
toolCall: Pick<ToolCallState, 'id' | 'parentToolCallId'>,
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<typeof setTimeout> | undefined
try {
@@ -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')
@@ -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
@@ -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()
})
})
@@ -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.`
}
@@ -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',
})
})
})
@@ -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)
@@ -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')],
},
})
})
})
@@ -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: {
@@ -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'),
})
})
})
@@ -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
@@ -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 = {
+52 -8
View File
@@ -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<PerformFullDeployResult> {
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<string, unknown> {
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, unknown>): 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<PerformActivateVersionResult> {
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) {