mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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`}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+3
-1
@@ -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', () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user