Skip to content

Commit b6985b4

Browse files
committed
fix(chat): bring a restored workflow's chat back online when it is relaunched
1 parent b719649 commit b6985b4

4 files changed

Lines changed: 225 additions & 3 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* A chat deployment whose workflow was deleted and then restored, against real PostgreSQL.
3+
*
4+
* Archiving a workflow sets its chat to `isActive: false`, and restore deliberately leaves it
5+
* that way: re-enabling is an explicit redeploy. These cases prove each explicit chat deploy
6+
* path — the editor's "Launch Chat" update and the create-or-update deploy shared by the
7+
* internal route, the v2 API, and Copilot — puts the restored chat back online, and that
8+
* nothing reactivates a chat whose workflow is still deleted.
9+
*/
10+
import assert from 'node:assert/strict'
11+
import { db } from '@sim/db'
12+
import { chat, permissions, project, user, workflow, workspace } from '@sim/db/schema'
13+
import { insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
14+
import { generateId } from '@sim/utils/id'
15+
import { eq, inArray } from 'drizzle-orm'
16+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
17+
import { updateChatDeployment } from '@/lib/chat-deployments/application/update-chat-deployment'
18+
import { readWorkflowChatDeploymentStatus } from '@/lib/chat-deployments/application/workflow-chat-deployment'
19+
import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle'
20+
import { performChatDeploy } from '@/lib/workflows/orchestration/chat-deploy'
21+
import { admitWorkflowState, saveAdmittedWorkflowState } from '@/lib/workflows/persistence/utils'
22+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
23+
24+
const userId = generateId()
25+
const workspaceId = generateId()
26+
const principal = {
27+
kind: 'personal_api_key' as const,
28+
userId,
29+
keyId: generateId(),
30+
access: 'write' as const,
31+
}
32+
33+
/** A start → function graph; block ids are a global key, so each workflow gets its own. */
34+
function graph(): WorkflowState {
35+
const start = generateId()
36+
const compute = generateId()
37+
return {
38+
blocks: {
39+
[start]: {
40+
id: start,
41+
type: 'start_trigger',
42+
name: 'Start',
43+
enabled: true,
44+
position: { x: 0, y: 0 },
45+
subBlocks: {},
46+
outputs: {},
47+
},
48+
[compute]: {
49+
id: compute,
50+
type: 'function',
51+
name: 'Compute',
52+
enabled: true,
53+
position: { x: 200, y: 0 },
54+
subBlocks: {
55+
language: { id: 'language', type: 'dropdown', value: 'javascript' },
56+
code: { id: 'code', type: 'code', value: 'return 42' },
57+
},
58+
outputs: {},
59+
},
60+
},
61+
edges: [
62+
{
63+
id: generateId(),
64+
source: start,
65+
target: compute,
66+
sourceHandle: 'source',
67+
targetHandle: 'target',
68+
},
69+
],
70+
loops: {},
71+
parallels: {},
72+
variables: {},
73+
}
74+
}
75+
76+
/** A saved workflow with a live chat deployment, created through the real deploy path. */
77+
async function workflowWithLiveChat() {
78+
const workflowId = generateId()
79+
const now = new Date()
80+
await db.insert(workflow).values({
81+
id: workflowId,
82+
userId,
83+
workspaceId,
84+
name: `Chat relaunch fixture ${workflowId}`,
85+
lastSynced: now,
86+
createdAt: now,
87+
updatedAt: now,
88+
})
89+
const admitted = await admitWorkflowState(graph(), { subjectUserId: userId, workspaceId })
90+
await db.transaction((tx) => saveAdmittedWorkflowState(tx, workflowId, admitted))
91+
92+
const identifier = `relaunch-${generateId().slice(0, 8)}`
93+
const deployed = await performChatDeploy({
94+
workflowId,
95+
userId,
96+
workspaceId,
97+
identifier,
98+
title: 'Support',
99+
})
100+
expect(deployed, deployed.error).toMatchObject({ success: true, isUpdate: false })
101+
assert(deployed.chatId)
102+
return { workflowId, chatId: deployed.chatId, identifier }
103+
}
104+
105+
async function chatRow(chatId: string) {
106+
const [row] = await db.select().from(chat).where(eq(chat.id, chatId))
107+
assert(row)
108+
return row
109+
}
110+
111+
async function editorChatStatus(workflowId: string) {
112+
return readWorkflowChatDeploymentStatus.execute({ principal, input: { workflowId } })
113+
}
114+
115+
/** Deletes then restores the workflow, asserting the chat comes back present but offline. */
116+
async function deleteAndRestore(workflowId: string, chatId: string) {
117+
expect((await archiveWorkflow(workflowId, { requestId: generateId() })).archived).toBe(true)
118+
expect((await restoreWorkflow(workflowId, { requestId: generateId() })).restored).toBe(true)
119+
const restored = await chatRow(chatId)
120+
expect(restored).toMatchObject({ archivedAt: null, isActive: false })
121+
expect(await editorChatStatus(workflowId)).toMatchObject({
122+
isDeployed: false,
123+
deployment: { id: chatId },
124+
})
125+
}
126+
127+
describe('relaunching the chat of a restored workflow', () => {
128+
beforeAll(async () => {
129+
const now = new Date()
130+
await db.insert(user).values({
131+
id: userId,
132+
name: 'Chat relaunch fixture',
133+
email: `${userId}@chat-relaunch.test`,
134+
emailVerified: true,
135+
createdAt: now,
136+
updatedAt: now,
137+
})
138+
await insertWorkspaceFixture(db, {
139+
id: workspaceId,
140+
name: 'Chat relaunch fixture',
141+
ownerId: userId,
142+
billedAccountUserId: userId,
143+
allowPersonalApiKeys: true,
144+
})
145+
await db.insert(permissions).values({
146+
id: generateId(),
147+
userId,
148+
entityType: 'workspace',
149+
entityId: workspaceId,
150+
permissionType: 'admin',
151+
})
152+
})
153+
154+
afterAll(async () => {
155+
await db.transaction(async (tx) => {
156+
await tx.delete(workspace).where(inArray(workspace.id, [workspaceId]))
157+
await tx.delete(project).where(eq(project.ownerId, userId))
158+
await tx.delete(user).where(eq(user.id, userId))
159+
})
160+
await db.$client.end()
161+
})
162+
163+
it('puts the chat back online when the editor relaunches it', async () => {
164+
const { workflowId, chatId, identifier } = await workflowWithLiveChat()
165+
await deleteAndRestore(workflowId, chatId)
166+
167+
const relaunched = await updateChatDeployment.execute({
168+
principal,
169+
input: { chatDeploymentId: chatId, workflowId, title: 'Support' },
170+
})
171+
172+
expect(relaunched.deployment).toMatchObject({ id: chatId, identifier, isActive: true })
173+
expect(await chatRow(chatId)).toMatchObject({ isActive: true, archivedAt: null })
174+
expect(await editorChatStatus(workflowId)).toMatchObject({ isDeployed: true })
175+
const [restoredWorkflow] = await db
176+
.select({ isDeployed: workflow.isDeployed })
177+
.from(workflow)
178+
.where(eq(workflow.id, workflowId))
179+
expect(restoredWorkflow?.isDeployed).toBe(true)
180+
})
181+
182+
it('puts the chat back online when it is redeployed through the shared deploy path', async () => {
183+
const { workflowId, chatId, identifier } = await workflowWithLiveChat()
184+
await deleteAndRestore(workflowId, chatId)
185+
186+
const redeployed = await performChatDeploy({
187+
workflowId,
188+
userId,
189+
workspaceId,
190+
identifier,
191+
title: 'Support',
192+
})
193+
194+
expect(redeployed, redeployed.error).toMatchObject({
195+
success: true,
196+
isUpdate: true,
197+
chatId,
198+
})
199+
expect(await chatRow(chatId)).toMatchObject({ isActive: true, archivedAt: null })
200+
expect(await editorChatStatus(workflowId)).toMatchObject({ isDeployed: true })
201+
})
202+
203+
it('cannot reactivate the chat of a workflow that is still deleted', async () => {
204+
const { workflowId, chatId } = await workflowWithLiveChat()
205+
expect((await archiveWorkflow(workflowId, { requestId: generateId() })).archived).toBe(true)
206+
207+
await expect(
208+
updateChatDeployment.execute({
209+
principal,
210+
input: { chatDeploymentId: chatId, workflowId, title: 'Support' },
211+
})
212+
).rejects.toMatchObject({ code: 'not_found' })
213+
214+
const archived = await chatRow(chatId)
215+
expect(archived.isActive).toBe(false)
216+
expect(archived.archivedAt).not.toBeNull()
217+
})
218+
})

‎apps/sim/lib/chat-deployments/application/update-chat-deployment.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,9 @@ export const updateChatDeployment = defineAuthorizedWorkspaceUseCase({
204204

205205
await redeployWorkflowIfDrifted(existing.workflowId, actingUserId)
206206

207-
const values: Partial<ChatDeploymentRow> = {}
207+
// The workflow is live by now; this is the explicit re-enable for a chat restored with
208+
// its archive's `isActive: false`.
209+
const values: Partial<ChatDeploymentRow> = { isActive: true }
208210
if (input.identifier) values.identifier = input.identifier
209211
if (input.title) values.title = input.title
210212
if (input.description !== undefined) values.description = input.description

‎apps/sim/lib/folders/config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,8 @@ export const FOLDER_RESOURCES: Record<FolderResourceType, FolderResourceConfig>
444444
* `status: 'disabled'` with `nextRunAt` cleared, webhooks and chats to `isActive: false`.
445445
* Archive does not record what those were, so restoring them to a constant would re-enable
446446
* a schedule the user had disabled and re-run a completed one. Re-enabling stays explicit
447-
* (redeploy re-activates a schedule), matching deployment state, which restore also leaves
448-
* off on purpose.
447+
* (redeploy re-activates a schedule; deploying or updating the chat re-activates the chat),
448+
* matching deployment state, which restore also leaves off on purpose.
449449
*/
450450
restoreDependents: [
451451
{

‎apps/sim/lib/workflows/orchestration/chat-deploy.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@ export async function performChatDeploy(
284284
outputConfigs,
285285
includeThinking,
286286
includeToolCalls,
287+
// Deploying is the explicit re-enable for a chat restored with its archive's `isActive: false`.
288+
isActive: true,
287289
updatedAt: new Date(),
288290
})
289291
.where(eq(chat.id, chatId))

0 commit comments

Comments
 (0)