Skip to content

Commit e1bea05

Browse files
committed
Superuser debug
1 parent 5f45db4 commit e1bea05

File tree

5 files changed

+409
-2
lines changed

5 files changed

+409
-2
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { db } from '@sim/db'
2+
import { copilotChats, user, workflow, workspace } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { eq } from 'drizzle-orm'
5+
import { NextRequest, NextResponse } from 'next/server'
6+
import { getSession } from '@/lib/auth'
7+
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
8+
import {
9+
loadWorkflowFromNormalizedTables,
10+
saveWorkflowToNormalizedTables,
11+
} from '@/lib/workflows/persistence/utils'
12+
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
13+
14+
const logger = createLogger('SuperUserImportWorkflow')
15+
16+
interface ImportWorkflowRequest {
17+
workflowId: string
18+
targetWorkspaceId: string
19+
}
20+
21+
/**
22+
* POST /api/superuser/import-workflow
23+
*
24+
* Superuser endpoint to import a workflow by ID along with its copilot chats.
25+
* This creates a copy of the workflow in the target workspace with new IDs.
26+
* Only the workflow structure and copilot chats are copied - no deployments,
27+
* webhooks, triggers, or other sensitive data.
28+
*/
29+
export async function POST(request: NextRequest) {
30+
try {
31+
const session = await getSession()
32+
if (!session?.user?.id) {
33+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
34+
}
35+
36+
// Verify the user is a superuser
37+
const [currentUser] = await db
38+
.select({ isSuperUser: user.isSuperUser })
39+
.from(user)
40+
.where(eq(user.id, session.user.id))
41+
.limit(1)
42+
43+
if (!currentUser?.isSuperUser) {
44+
logger.warn('Non-superuser attempted to access import-workflow endpoint', {
45+
userId: session.user.id,
46+
})
47+
return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 })
48+
}
49+
50+
const body: ImportWorkflowRequest = await request.json()
51+
const { workflowId, targetWorkspaceId } = body
52+
53+
if (!workflowId) {
54+
return NextResponse.json({ error: 'workflowId is required' }, { status: 400 })
55+
}
56+
57+
if (!targetWorkspaceId) {
58+
return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 })
59+
}
60+
61+
// Verify target workspace exists
62+
const [targetWorkspace] = await db
63+
.select({ id: workspace.id, ownerId: workspace.ownerId })
64+
.from(workspace)
65+
.where(eq(workspace.id, targetWorkspaceId))
66+
.limit(1)
67+
68+
if (!targetWorkspace) {
69+
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
70+
}
71+
72+
// Get the source workflow
73+
const [sourceWorkflow] = await db
74+
.select()
75+
.from(workflow)
76+
.where(eq(workflow.id, workflowId))
77+
.limit(1)
78+
79+
if (!sourceWorkflow) {
80+
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
81+
}
82+
83+
// Load the workflow state from normalized tables
84+
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
85+
86+
if (!normalizedData) {
87+
return NextResponse.json(
88+
{ error: 'Workflow has no normalized data - cannot import' },
89+
{ status: 400 }
90+
)
91+
}
92+
93+
// Use existing export logic to create export format
94+
const workflowState = {
95+
blocks: normalizedData.blocks,
96+
edges: normalizedData.edges,
97+
loops: normalizedData.loops,
98+
parallels: normalizedData.parallels,
99+
metadata: {
100+
name: sourceWorkflow.name,
101+
description: sourceWorkflow.description ?? undefined,
102+
color: sourceWorkflow.color,
103+
},
104+
}
105+
106+
const exportData = sanitizeForExport(workflowState)
107+
108+
// Use existing import logic (parseWorkflowJson regenerates IDs automatically)
109+
const { data: importedData, errors } = parseWorkflowJson(JSON.stringify(exportData))
110+
111+
if (!importedData || errors.length > 0) {
112+
return NextResponse.json(
113+
{ error: `Failed to parse workflow: ${errors.join(', ')}` },
114+
{ status: 400 }
115+
)
116+
}
117+
118+
// Create new workflow record
119+
const newWorkflowId = crypto.randomUUID()
120+
const now = new Date()
121+
122+
await db.insert(workflow).values({
123+
id: newWorkflowId,
124+
userId: session.user.id,
125+
workspaceId: targetWorkspaceId,
126+
folderId: null, // Don't copy folder association
127+
name: `[Debug Import] ${sourceWorkflow.name}`,
128+
description: sourceWorkflow.description,
129+
color: sourceWorkflow.color,
130+
lastSynced: now,
131+
createdAt: now,
132+
updatedAt: now,
133+
isDeployed: false, // Never copy deployment status
134+
runCount: 0,
135+
variables: sourceWorkflow.variables || {},
136+
})
137+
138+
// Save using existing persistence logic
139+
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData)
140+
141+
if (!saveResult.success) {
142+
// Clean up the workflow record if save failed
143+
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
144+
return NextResponse.json(
145+
{ error: `Failed to save workflow state: ${saveResult.error}` },
146+
{ status: 500 }
147+
)
148+
}
149+
150+
// Copy copilot chats associated with the source workflow
151+
const sourceCopilotChats = await db
152+
.select()
153+
.from(copilotChats)
154+
.where(eq(copilotChats.workflowId, workflowId))
155+
156+
let copilotChatsImported = 0
157+
158+
for (const chat of sourceCopilotChats) {
159+
await db.insert(copilotChats).values({
160+
userId: session.user.id,
161+
workflowId: newWorkflowId,
162+
title: chat.title ? `[Import] ${chat.title}` : null,
163+
messages: chat.messages,
164+
model: chat.model,
165+
conversationId: null, // Don't copy conversation ID
166+
previewYaml: chat.previewYaml,
167+
planArtifact: chat.planArtifact,
168+
config: chat.config,
169+
createdAt: new Date(),
170+
updatedAt: new Date(),
171+
})
172+
copilotChatsImported++
173+
}
174+
175+
logger.info('Superuser imported workflow', {
176+
userId: session.user.id,
177+
sourceWorkflowId: workflowId,
178+
newWorkflowId,
179+
targetWorkspaceId,
180+
copilotChatsImported,
181+
})
182+
183+
return NextResponse.json({
184+
success: true,
185+
newWorkflowId,
186+
copilotChatsImported,
187+
})
188+
} catch (error) {
189+
logger.error('Error importing workflow', error)
190+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
191+
}
192+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1477,7 +1477,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
14771477
toolCall.name === 'mark_todo_in_progress' ||
14781478
toolCall.name === 'tool_search_tool_regex' ||
14791479
toolCall.name === 'user_memory' ||
1480-
toolCall.name === 'edit_responsd' ||
1480+
toolCall.name === 'edit_respond' ||
14811481
toolCall.name === 'debug_respond' ||
14821482
toolCall.name === 'plan_respond'
14831483
)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { useParams, useRouter } from 'next/navigation'
5+
import { useQueryClient } from '@tanstack/react-query'
6+
import { AlertTriangle, Download, ExternalLink, Loader2 } from 'lucide-react'
7+
import { createLogger } from '@sim/logger'
8+
import { Button } from '@/components/ui/button'
9+
import { Input } from '@/components/ui/input'
10+
import { Label } from '@/components/ui/label'
11+
import { workflowKeys } from '@/hooks/queries/workflows'
12+
13+
const logger = createLogger('DebugSettings')
14+
15+
interface ImportResult {
16+
success: boolean
17+
newWorkflowId?: string
18+
copilotChatsImported?: number
19+
error?: string
20+
}
21+
22+
/**
23+
* Debug settings component for superusers.
24+
* Allows importing workflows by ID for debugging purposes.
25+
*/
26+
export function Debug() {
27+
const params = useParams()
28+
const router = useRouter()
29+
const queryClient = useQueryClient()
30+
const workspaceId = params?.workspaceId as string
31+
32+
const [workflowId, setWorkflowId] = useState('')
33+
const [isImporting, setIsImporting] = useState(false)
34+
const [result, setResult] = useState<ImportResult | null>(null)
35+
36+
const handleImport = async () => {
37+
if (!workflowId.trim()) return
38+
39+
setIsImporting(true)
40+
setResult(null)
41+
42+
try {
43+
const response = await fetch('/api/superuser/import-workflow', {
44+
method: 'POST',
45+
headers: { 'Content-Type': 'application/json' },
46+
body: JSON.stringify({
47+
workflowId: workflowId.trim(),
48+
targetWorkspaceId: workspaceId,
49+
}),
50+
})
51+
52+
const data = await response.json()
53+
54+
if (!response.ok) {
55+
setResult({ success: false, error: data.error || 'Failed to import workflow' })
56+
return
57+
}
58+
59+
// Invalidate workflow list cache to show the new workflow immediately
60+
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
61+
62+
setResult({
63+
success: true,
64+
newWorkflowId: data.newWorkflowId,
65+
copilotChatsImported: data.copilotChatsImported,
66+
})
67+
68+
setWorkflowId('')
69+
logger.info('Workflow imported successfully', {
70+
originalWorkflowId: workflowId.trim(),
71+
newWorkflowId: data.newWorkflowId,
72+
copilotChatsImported: data.copilotChatsImported,
73+
})
74+
} catch (error) {
75+
logger.error('Failed to import workflow', error)
76+
setResult({ success: false, error: 'An unexpected error occurred' })
77+
} finally {
78+
setIsImporting(false)
79+
}
80+
}
81+
82+
const handleNavigateToWorkflow = () => {
83+
if (result?.newWorkflowId) {
84+
router.push(`/workspace/${workspaceId}/w/${result.newWorkflowId}`)
85+
}
86+
}
87+
88+
const handleKeyDown = (e: React.KeyboardEvent) => {
89+
if (e.key === 'Enter' && !isImporting && workflowId.trim()) {
90+
handleImport()
91+
}
92+
}
93+
94+
return (
95+
<div className="flex flex-col gap-6 p-1">
96+
<div className="flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/10 p-4">
97+
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-amber-500" />
98+
<p className="text-sm text-amber-200">
99+
This is a superuser debug feature. Use with caution. Imported workflows and copilot chats
100+
will be copied to your current workspace.
101+
</p>
102+
</div>
103+
104+
<div className="flex flex-col gap-4">
105+
<div>
106+
<h3 className="mb-1 text-base font-medium text-white">Import Workflow by ID</h3>
107+
<p className="text-sm text-muted-foreground">
108+
Enter a workflow ID to import it along with its associated copilot chats into your
109+
current workspace. Only the workflow structure and copilot conversations will be copied
110+
- no deployments, webhooks, or triggers.
111+
</p>
112+
</div>
113+
114+
<div className="flex flex-col gap-2">
115+
<Label htmlFor="workflow-id">Workflow ID</Label>
116+
<div className="flex gap-2">
117+
<Input
118+
id="workflow-id"
119+
value={workflowId}
120+
onChange={(e) => setWorkflowId(e.target.value)}
121+
onKeyDown={handleKeyDown}
122+
placeholder="Enter workflow ID (e.g., abc123-def456-...)"
123+
disabled={isImporting}
124+
className="flex-1"
125+
/>
126+
<Button onClick={handleImport} disabled={isImporting || !workflowId.trim()}>
127+
{isImporting ? (
128+
<>
129+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
130+
Importing...
131+
</>
132+
) : (
133+
<>
134+
<Download className="mr-2 h-4 w-4" />
135+
Import
136+
</>
137+
)}
138+
</Button>
139+
</div>
140+
</div>
141+
142+
{result && (
143+
<div
144+
className={`rounded-lg border p-4 ${
145+
result.success
146+
? 'border-green-500/20 bg-green-500/10'
147+
: 'border-red-500/20 bg-red-500/10'
148+
}`}
149+
>
150+
{result.success ? (
151+
<div className="flex flex-col gap-2">
152+
<p className="font-medium text-green-400">Workflow imported successfully!</p>
153+
<p className="text-sm text-green-300">
154+
New workflow ID: <code className="font-mono">{result.newWorkflowId}</code>
155+
</p>
156+
<p className="text-sm text-green-300">
157+
Copilot chats imported: {result.copilotChatsImported}
158+
</p>
159+
<Button
160+
variant="outline"
161+
size="sm"
162+
onClick={handleNavigateToWorkflow}
163+
className="mt-2 w-fit"
164+
>
165+
<ExternalLink className="mr-2 h-4 w-4" />
166+
Open Workflow
167+
</Button>
168+
</div>
169+
) : (
170+
<p className="text-red-400">{result.error}</p>
171+
)}
172+
</div>
173+
)}
174+
</div>
175+
</div>
176+
)
177+
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { BYOK } from './byok/byok'
44
export { Copilot } from './copilot/copilot'
55
export { CredentialSets } from './credential-sets/credential-sets'
66
export { CustomTools } from './custom-tools/custom-tools'
7+
export { Debug } from './debug/debug'
78
export { EnvironmentVariables } from './environment/environment'
89
export { Files as FileUploads } from './files/files'
910
export { General } from './general/general'

0 commit comments

Comments
 (0)