diff --git a/apps/sim/lib/chat-deployments/__integration__/relaunch-after-restore.integration.ts b/apps/sim/lib/chat-deployments/__integration__/relaunch-after-restore.integration.ts new file mode 100644 index 00000000000..257c5e51b1a --- /dev/null +++ b/apps/sim/lib/chat-deployments/__integration__/relaunch-after-restore.integration.ts @@ -0,0 +1,218 @@ +/** + * A chat deployment whose workflow was deleted and then restored, against real PostgreSQL. + * + * Archiving a workflow sets its chat to `isActive: false`, and restore deliberately leaves it + * that way: re-enabling is an explicit redeploy. These cases prove each explicit chat deploy + * path — the editor's "Launch Chat" update and the create-or-update deploy shared by the + * internal route, the v2 API, and Copilot — puts the restored chat back online, and that + * nothing reactivates a chat whose workflow is still deleted. + */ +import assert from 'node:assert/strict' +import { db } from '@sim/db' +import { chat, permissions, project, user, workflow, workspace } from '@sim/db/schema' +import { insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { updateChatDeployment } from '@/lib/chat-deployments/application/update-chat-deployment' +import { readWorkflowChatDeploymentStatus } from '@/lib/chat-deployments/application/workflow-chat-deployment' +import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' +import { performChatDeploy } from '@/lib/workflows/orchestration/chat-deploy' +import { admitWorkflowState, saveAdmittedWorkflowState } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const userId = generateId() +const workspaceId = generateId() +const principal = { + kind: 'personal_api_key' as const, + userId, + keyId: generateId(), + access: 'write' as const, +} + +/** A start → function graph; block ids are a global key, so each workflow gets its own. */ +function graph(): WorkflowState { + const start = generateId() + const compute = generateId() + return { + blocks: { + [start]: { + id: start, + type: 'start_trigger', + name: 'Start', + enabled: true, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + }, + [compute]: { + id: compute, + type: 'function', + name: 'Compute', + enabled: true, + position: { x: 200, y: 0 }, + subBlocks: { + language: { id: 'language', type: 'dropdown', value: 'javascript' }, + code: { id: 'code', type: 'code', value: 'return 42' }, + }, + outputs: {}, + }, + }, + edges: [ + { + id: generateId(), + source: start, + target: compute, + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + loops: {}, + parallels: {}, + variables: {}, + } +} + +/** A saved workflow with a live chat deployment, created through the real deploy path. */ +async function workflowWithLiveChat() { + const workflowId = generateId() + const now = new Date() + await db.insert(workflow).values({ + id: workflowId, + userId, + workspaceId, + name: `Chat relaunch fixture ${workflowId}`, + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + const admitted = await admitWorkflowState(graph(), { subjectUserId: userId, workspaceId }) + await db.transaction((tx) => saveAdmittedWorkflowState(tx, workflowId, admitted)) + + const identifier = `relaunch-${generateId().slice(0, 8)}` + const deployed = await performChatDeploy({ + workflowId, + userId, + workspaceId, + identifier, + title: 'Support', + }) + expect(deployed, deployed.error).toMatchObject({ success: true, isUpdate: false }) + assert(deployed.chatId) + return { workflowId, chatId: deployed.chatId, identifier } +} + +async function chatRow(chatId: string) { + const [row] = await db.select().from(chat).where(eq(chat.id, chatId)) + assert(row) + return row +} + +async function editorChatStatus(workflowId: string) { + return readWorkflowChatDeploymentStatus.execute({ principal, input: { workflowId } }) +} + +/** Deletes then restores the workflow, asserting the chat comes back present but offline. */ +async function deleteAndRestore(workflowId: string, chatId: string) { + expect((await archiveWorkflow(workflowId, { requestId: generateId() })).archived).toBe(true) + expect((await restoreWorkflow(workflowId, { requestId: generateId() })).restored).toBe(true) + const restored = await chatRow(chatId) + expect(restored).toMatchObject({ archivedAt: null, isActive: false }) + expect(await editorChatStatus(workflowId)).toMatchObject({ + isDeployed: false, + deployment: { id: chatId }, + }) +} + +describe('relaunching the chat of a restored workflow', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Chat relaunch fixture', + email: `${userId}@chat-relaunch.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Chat relaunch fixture', + ownerId: userId, + billedAccountUserId: userId, + allowPersonalApiKeys: true, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + }) + + afterAll(async () => { + await db.transaction(async (tx) => { + await tx.delete(workspace).where(inArray(workspace.id, [workspaceId])) + await tx.delete(project).where(eq(project.ownerId, userId)) + await tx.delete(user).where(eq(user.id, userId)) + }) + await db.$client.end() + }) + + it('puts the chat back online when the editor relaunches it', async () => { + const { workflowId, chatId, identifier } = await workflowWithLiveChat() + await deleteAndRestore(workflowId, chatId) + + const relaunched = await updateChatDeployment.execute({ + principal, + input: { chatDeploymentId: chatId, workflowId, title: 'Support' }, + }) + + expect(relaunched.deployment).toMatchObject({ id: chatId, identifier, isActive: true }) + expect(await chatRow(chatId)).toMatchObject({ isActive: true, archivedAt: null }) + expect(await editorChatStatus(workflowId)).toMatchObject({ isDeployed: true }) + const [restoredWorkflow] = await db + .select({ isDeployed: workflow.isDeployed }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + expect(restoredWorkflow?.isDeployed).toBe(true) + }) + + it('puts the chat back online when it is redeployed through the shared deploy path', async () => { + const { workflowId, chatId, identifier } = await workflowWithLiveChat() + await deleteAndRestore(workflowId, chatId) + + const redeployed = await performChatDeploy({ + workflowId, + userId, + workspaceId, + identifier, + title: 'Support', + }) + + expect(redeployed, redeployed.error).toMatchObject({ + success: true, + isUpdate: true, + chatId, + }) + expect(await chatRow(chatId)).toMatchObject({ isActive: true, archivedAt: null }) + expect(await editorChatStatus(workflowId)).toMatchObject({ isDeployed: true }) + }) + + it('cannot reactivate the chat of a workflow that is still deleted', async () => { + const { workflowId, chatId } = await workflowWithLiveChat() + expect((await archiveWorkflow(workflowId, { requestId: generateId() })).archived).toBe(true) + + await expect( + updateChatDeployment.execute({ + principal, + input: { chatDeploymentId: chatId, workflowId, title: 'Support' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + const archived = await chatRow(chatId) + expect(archived.isActive).toBe(false) + expect(archived.archivedAt).not.toBeNull() + }) +}) diff --git a/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts index 5db776e929a..c0732340857 100644 --- a/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts +++ b/apps/sim/lib/chat-deployments/application/update-chat-deployment.ts @@ -143,6 +143,12 @@ async function redeployWorkflowIfDrifted(workflowId: string, userId: string): Pr } throw new Error(message) } + if (deployResult.latestDeploymentAttempt?.isCurrent === false) { + throw new OrchestrationError( + 'conflict', + 'The workflow deployment attempt is historical and no longer describes production. Retry the chat update.' + ) + } if (deployResult.latestDeploymentAttempt?.status !== 'active') { throw new OrchestrationError( 'conflict', @@ -150,6 +156,9 @@ async function redeployWorkflowIfDrifted(workflowId: string, userId: string): Pr 'Workflow deployment is still preparing. Retry the chat update after it becomes active.' ) } + if (!deployResult.activeDeployment) { + throw new Error('Workflow deployment reported active without a live deployment version.') + } logger.info(`Redeployed workflow ${workflowId} for chat update (v${deployResult.version})`) } @@ -204,7 +213,9 @@ export const updateChatDeployment = defineAuthorizedWorkspaceUseCase({ await redeployWorkflowIfDrifted(existing.workflowId, actingUserId) - const values: Partial = {} + // The workflow is live by now; this is the explicit re-enable for a chat restored with + // its archive's `isActive: false`. + const values: Partial = { isActive: true } if (input.identifier) values.identifier = input.identifier if (input.title) values.title = input.title if (input.description !== undefined) values.description = input.description diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index cb6b542cf32..7a093a4ee41 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -444,8 +444,8 @@ export const FOLDER_RESOURCES: Record * `status: 'disabled'` with `nextRunAt` cleared, webhooks and chats to `isActive: false`. * Archive does not record what those were, so restoring them to a constant would re-enable * a schedule the user had disabled and re-run a completed one. Re-enabling stays explicit - * (redeploy re-activates a schedule), matching deployment state, which restore also leaves - * off on purpose. + * (redeploy re-activates a schedule; deploying or updating the chat re-activates the chat), + * matching deployment state, which restore also leaves off on purpose. */ restoreDependents: [ { diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index dccef0844f7..0b01e31ad6a 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -271,7 +271,7 @@ export async function performChatDeploy( passwordToStore = null } - await db + const [updated] = await db .update(chat) .set({ identifier, @@ -284,9 +284,16 @@ export async function performChatDeploy( outputConfigs, includeThinking, includeToolCalls, + // Deploying is the explicit re-enable for a chat restored with its archive's `isActive: false`. + isActive: true, updatedAt: new Date(), }) - .where(eq(chat.id, chatId)) + // An archive landing during the deploy above must not be undone by this write. + .where(and(eq(chat.id, chatId), isNull(chat.archivedAt))) + .returning({ id: chat.id }) + if (!updated) { + return { success: false, error: 'Chat deployment not found', errorCode: 'not_found' } + } } else { chatId = generateId() await db.insert(chat).values({