Repository navigation
fix(chat): bring a restored workflow's chat back online when it is relaunched #9888
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
d538314
fix(chat): bring a restored workflow's chat back online when it is re…
waleedlatif1 b67e578
fix(chat): refuse a chat update on a superseded or version-less deplo…
waleedlatif1 0e22b66
fix(chat): never re-enable a chat archived while its relaunch was dep…
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
218 changes: 218 additions & 0 deletions
218
apps/sim/lib/chat-deployments/__integration__/relaunch-after-restore.integration.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.