Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

812 lines
26 KiB
TypeScript

/**
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
queueTableRows,
resetDbChainMock,
schemaMock,
workflowAuthzMockFns,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSaveWorkflowToNormalizedTables,
mockRecordAudit,
mockCaptureServerEvent,
mockValidateWorkflowSchedules,
mockValidateTriggerWebhookConfigForDeploy,
mockEmitWorkflowDeployedEvent,
mockPrepareWorkflowDeployment,
mockPrepareWorkflowVersionActivation,
mockGetWorkflowDeploymentStatus,
mockEnqueueWorkflowDeploymentPreparation,
mockProcessWorkflowDeploymentOutboxEvent,
mockNotifySocketDeploymentChanged,
mockLoadWorkflowDeploymentSnapshot,
mockTx,
} = vi.hoisted(() => ({
mockSaveWorkflowToNormalizedTables: vi.fn(),
mockRecordAudit: vi.fn(),
mockCaptureServerEvent: vi.fn(),
mockValidateWorkflowSchedules: vi.fn(),
mockValidateTriggerWebhookConfigForDeploy: vi.fn(),
mockEmitWorkflowDeployedEvent: vi.fn(),
mockPrepareWorkflowDeployment: vi.fn(),
mockPrepareWorkflowVersionActivation: vi.fn(),
mockGetWorkflowDeploymentStatus: vi.fn(),
mockEnqueueWorkflowDeploymentPreparation: vi.fn(),
mockProcessWorkflowDeploymentOutboxEvent: vi.fn(),
mockNotifySocketDeploymentChanged: vi.fn(),
mockLoadWorkflowDeploymentSnapshot: vi.fn(),
/**
* Sentinel transaction handle the mocked prepare functions hand to the real
* onPrepareTransaction callback, which only forwards it into the (mocked)
* outbox enqueue — identity is asserted, never chained on.
*/
mockTx: { sentinel: 'tx' },
}))
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
vi.mock('@sim/audit', () => ({
AuditAction: {
WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED',
WORKFLOW_DEPLOYED: 'WORKFLOW_DEPLOYED',
WORKFLOW_UNDEPLOYED: 'WORKFLOW_UNDEPLOYED',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED',
},
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
recordAudit: mockRecordAudit,
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowDeploymentPreparation: mockEnqueueWorkflowDeploymentPreparation,
enqueueWorkflowUndeploySideEffects: vi.fn().mockResolvedValue('outbox-2'),
notifySocketDeploymentChanged: mockNotifySocketDeploymentChanged,
processWorkflowDeploymentOutboxEvent: mockProcessWorkflowDeploymentOutboxEvent,
DEPLOYMENT_READINESS_COMPONENTS: ['webhooks', 'schedules', 'mcp'],
}))
vi.mock('@/lib/workflows/persistence/deployment-operations', () => ({
getWorkflowDeploymentStatus: mockGetWorkflowDeploymentStatus,
prepareWorkflowDeployment: mockPrepareWorkflowDeployment,
prepareWorkflowVersionActivation: mockPrepareWorkflowVersionActivation,
}))
vi.mock('@/lib/workspace-events/emitter', () => ({
emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent,
emitWorkflowUndeployedEvent: vi.fn(),
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
loadWorkflowDeploymentSnapshot: mockLoadWorkflowDeploymentSnapshot,
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
undeployWorkflow: vi.fn(),
}))
vi.mock('@/lib/webhooks/deploy', () => ({
validateTriggerWebhookConfigForDeploy: mockValidateTriggerWebhookConfigForDeploy,
}))
vi.mock('@/lib/workflows/schedules', () => ({
validateWorkflowSchedules: mockValidateWorkflowSchedules,
}))
// Resolves to the global @sim/platform-authz/workflow mock, so instanceof matches.
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
import {
getWorkflowDeploymentSummary,
performActivateVersion,
performFullDeploy,
performFullUndeploy,
performRevertToVersion,
} from '@/lib/workflows/orchestration/deploy'
afterAll(() => {
resetDbChainMock()
})
describe('performRevertToVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
})
it('restores variables when the deployment snapshot includes them', async () => {
queueTableRows(schemaMock.workflowDeploymentVersion, [
{
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
},
},
])
const result = await performRevertToVersion({
workflowId: 'workflow-1',
version: 3,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith(
'workflow-1',
expect.objectContaining({
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
}),
dbChainMock.db
)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
})
)
})
it('preserves existing variables when reverting a legacy snapshot without variables', async () => {
queueTableRows(schemaMock.workflowDeploymentVersion, [
{
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
},
},
])
const result = await performRevertToVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
const savedState = mockSaveWorkflowToNormalizedTables.mock.calls[0][1]
expect(Object.hasOwn(savedState, 'variables')).toBe(false)
const workflowUpdate = dbChainMockFns.set.mock.calls[0][0]
expect(Object.hasOwn(workflowUpdate, 'variables')).toBe(false)
})
})
describe('performFullDeploy workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-default',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-1',
version: 4,
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-default',
requestHash: 'hash',
actorId: 'user-1',
completedAt: now,
createdAt: now,
updatedAt: now,
}
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed')
mockNotifySocketDeploymentChanged.mockResolvedValue(undefined)
queueTableRows(schemaMock.workflow, [
{ id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
])
mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {},
lastSaved: now.getTime(),
})
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-default')
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 4,
deployedAt: now,
},
latestOperation: operation,
})
})
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',
userId: 'user-1',
})
expect(result.success).toBe(true)
expect(mockPrepareWorkflowDeployment).toHaveBeenCalledTimes(1)
expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledWith(
mockTx,
expect.objectContaining({ protocolVersion: 2 })
)
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 retry identity stable across snapshot timestamps, edge order, and label wording', 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,
versionName: 'First wording',
versionDescription: 'First description',
})
await performFullDeploy({
...params,
versionName: 'Retry wording',
versionDescription: 'Rephrased description',
})
expect(mockPrepareWorkflowDeployment.mock.calls[0][0].requestHash).toBe(
mockPrepareWorkflowDeployment.mock.calls[1][0].requestHash
)
const firstRequest = mockPrepareWorkflowDeployment.mock.calls[0][0]
expect(firstRequest.idempotencyKey).toBe(
`copilot:execution-1:tool-call:call-1:request:${firstRequest.requestHash}`
)
})
it('keeps a first deploy pending without claiming an active deployment', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-1',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-candidate',
version: 1,
previousActiveVersionId: null,
action: 'deploy',
protocolVersion: 2,
generation: 1,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'pending', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-1',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {},
lastSaved: now.getTime(),
})
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-1')
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: null,
latestOperation: operation,
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
requestId: 'request-1',
})
expect(result).toMatchObject({
success: true,
activeDeployment: null,
latestDeploymentAttempt: {
id: 'operation-1',
status: 'preparing',
deploymentVersionId: 'dv-candidate',
},
warnings: [expect.stringContaining('workflow remains undeployed')],
})
expect(result.deployedAt).toBeUndefined()
})
it('preserves the old active deployment while a redeploy prepares', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-2',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-candidate',
version: 5,
previousActiveVersionId: 'dv-live',
action: 'deploy',
protocolVersion: 2,
generation: 2,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'ready', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-2',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {},
lastSaved: now.getTime(),
})
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-2')
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-live',
version: 4,
deployedAt: now,
},
latestOperation: operation,
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
requestId: 'request-2',
})
expect(result).toMatchObject({
success: true,
/**
* Top-level version identifies the snapshot this call admitted, while
* activeDeployment keeps reporting what is actually live during the
* pending cutover.
*/
deploymentVersionId: 'dv-candidate',
version: 5,
activeDeployment: {
deploymentVersionId: 'dv-live',
version: 4,
},
latestDeploymentAttempt: {
deploymentVersionId: 'dv-candidate',
status: 'preparing',
},
})
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('surfaces v2 admission failure without falling back to legacy activation', async () => {
mockPrepareWorkflowDeployment.mockResolvedValueOnce({
success: false,
reason: 'invalid_request',
error: 'nope',
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(false)
expect(result.error).toBe('nope')
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('returns a failure response when this request attempt fails terminally inline', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-conflict',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-candidate',
version: 5,
previousActiveVersionId: null,
action: 'deploy',
protocolVersion: 2,
generation: 2,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'pending', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-conflict',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockPrepareWorkflowDeployment.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: null,
latestOperation: {
...operation,
status: 'failed',
errorCode: 'webhook_path_conflict',
errorMessage: 'Webhook path "/leads" is already in use. Choose a different path.',
completedAt: now,
},
})
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
requestId: 'request-conflict',
})
expect(result).toMatchObject({
success: false,
error: 'Webhook path "/leads" is already in use. Choose a different path.',
errorCode: 'conflict',
})
})
})
describe('performActivateVersion workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-activate-default',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-2',
version: 2,
previousActiveVersionId: 'dv-1',
action: 'activate',
protocolVersion: 2,
generation: 4,
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-activate-default',
requestHash: 'hash',
actorId: 'user-1',
completedAt: now,
createdAt: now,
updatedAt: now,
}
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('completed')
mockNotifySocketDeploymentChanged.mockResolvedValue(undefined)
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
queueTableRows(schemaMock.workflowDeploymentVersion, [
{ id: 'dv-2', state: { blocks: {} }, isActive: false },
])
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate-default')
mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-2',
version: 2,
deployedAt: now,
},
latestOperation: operation,
})
})
it('always admits version activation through v2 without legacy activation', async () => {
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
})
expect(result.success).toBe(true)
expect(mockPrepareWorkflowVersionActivation).toHaveBeenCalledTimes(1)
expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledWith(
mockTx,
expect.objectContaining({ protocolVersion: 2 })
)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('keeps the current version active while version activation prepares', async () => {
const now = new Date('2026-07-14T08:00:00.000Z')
const operation = {
id: 'operation-activate',
workflowId: 'workflow-1',
deploymentVersionId: 'dv-2',
version: 2,
previousActiveVersionId: 'dv-1',
action: 'activate',
protocolVersion: 2,
generation: 4,
status: 'preparing',
componentReadiness: {
webhooks: { status: 'pending', updatedAt: now.toISOString() },
schedules: { status: 'pending', updatedAt: now.toISOString() },
mcp: { status: 'pending', updatedAt: now.toISOString() },
},
errorCode: null,
errorMessage: null,
idempotencyKey: 'request-activate',
requestHash: 'hash',
actorId: 'user-1',
completedAt: null,
createdAt: now,
updatedAt: now,
}
mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-activate')
mockPrepareWorkflowVersionActivation.mockImplementation(async (input) => {
await input.onPrepareTransaction?.(mockTx, operation)
return { success: true, operation, reused: false }
})
mockProcessWorkflowDeploymentOutboxEvent.mockResolvedValue('pending')
mockGetWorkflowDeploymentStatus.mockResolvedValue({
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 1,
deployedAt: now,
},
latestOperation: operation,
})
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
requestId: 'request-activate',
})
expect(result).toMatchObject({
success: true,
activeDeployment: {
deploymentVersionId: 'dv-1',
version: 1,
},
latestDeploymentAttempt: {
id: 'operation-activate',
deploymentVersionId: 'dv-2',
status: 'preparing',
},
warnings: [expect.stringContaining('prior workflow version remains active')],
})
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('does not emit when the version is already active (no-op activation)', async () => {
/**
* Per-chain overrides answer the two selects directly (version row, then
* workflow deployedAt); the default row queued in beforeEach stays
* unconsumed and is cleared by the next reset.
*/
dbChainMockFns.limit
.mockResolvedValueOnce([{ id: 'dv-2', state: { blocks: {} }, isActive: true }])
.mockResolvedValueOnce([{ deployedAt: new Date() }])
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('surfaces v2 activation admission failure without legacy fallback', async () => {
mockPrepareWorkflowVersionActivation.mockResolvedValueOnce({
success: false,
reason: 'invalid_request',
error: 'nope',
})
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
})
expect(result.success).toBe(false)
expect(result.error).toBe('nope')
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
})
describe('mutation lock on the orchestration entry points', () => {
const mockAssertMutable = workflowAuthzMockFns.mockAssertWorkflowMutable
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockAssertMutable.mockRejectedValue(new WorkflowLockedError('Workflow is locked'))
})
it.each([
['performFullDeploy', () => performFullDeploy({ workflowId: 'wf-1', userId: 'user-1' })],
['performFullUndeploy', () => performFullUndeploy({ workflowId: 'wf-1', userId: 'user-1' })],
[
'performActivateVersion',
() => performActivateVersion({ workflowId: 'wf-1', version: 2, userId: 'user-1' }),
],
])('%s returns a lock denial instead of throwing', async (_name, call) => {
// Callers like performChatDeploy and the copilot deploy tools consume the
// result object; a throw surfaces as a generic 500 instead of a denial.
const result = await call()
expect(result.success).toBe(false)
expect(result.error).toContain('locked')
expect(mockRecordAudit).not.toHaveBeenCalled()
})
it('proceeds past the gate when the workflow is mutable', async () => {
mockAssertMutable.mockResolvedValue(undefined)
await performFullUndeploy({ workflowId: 'wf-1', userId: 'user-1' })
expect(mockAssertMutable).toHaveBeenCalledWith('wf-1')
})
})