Skip to content

Commit 772b710

Browse files
authored
fix(mothership): canonical folder ids (#5741)
* folder fix * fix bug
1 parent 92feb24 commit 772b710

5 files changed

Lines changed: 154 additions & 31 deletions

File tree

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,11 @@ export const CreateWorkflow: ToolCatalogEntry = {
439439
parameters: {
440440
type: 'object',
441441
properties: {
442-
folderId: { type: 'string', description: 'Optional folder ID.' },
442+
folderPath: {
443+
type: 'string',
444+
description:
445+
'Optional canonical workflow-folder VFS path copied from glob("workflows/**"), for example "workflows/Dream" or "workflows/Client%20Work/Intake". Omit for the workspace root.',
446+
},
443447
name: { type: 'string', description: 'Workflow name.' },
444448
workspaceId: { type: 'string', description: 'Optional workspace ID.' },
445449
},

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
221221
parameters: {
222222
type: 'object',
223223
properties: {
224-
folderId: {
224+
folderPath: {
225225
type: 'string',
226-
description: 'Optional folder ID.',
226+
description:
227+
'Optional canonical workflow-folder VFS path copied from glob("workflows/**"), for example "workflows/Dream" or "workflows/Client%20Work/Intake". Omit for the workspace root.',
227228
},
228229
name: {
229230
type: 'string',

apps/sim/lib/copilot/tools/handlers/param-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface GetBlockUpstreamReferencesParams {
3232
export interface CreateWorkflowParams {
3333
name?: string
3434
workspaceId?: string
35+
/** Canonical workflow-folder VFS path, for example `workflows/Dream`. */
36+
folderPath?: string
37+
/** Legacy executor input. New tool calls use folderPath and resolve the ID internally. */
3538
folderId?: string
3639
}
3740

apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ vi.mock('../access', () => ({
124124

125125
import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context'
126126
import { performUpdateWorkflow } from '@/lib/workflows/orchestration'
127-
import { verifyFolderWorkspace } from '@/lib/workflows/utils'
127+
import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils'
128128
import {
129129
executeCreateWorkflow,
130130
executeMoveWorkflow,
@@ -136,6 +136,7 @@ import {
136136
} from './mutations'
137137

138138
const performUpdateWorkflowMock = vi.mocked(performUpdateWorkflow)
139+
const listFoldersMock = vi.mocked(listFolders)
139140
const verifyFolderWorkspaceMock = vi.mocked(verifyFolderWorkspace)
140141
const billingAttribution: BillingAttributionSnapshot = {
141142
actorUserId: 'user-1',
@@ -291,6 +292,7 @@ describe('executeCreateWorkflow billing attribution', () => {
291292
payerUsage: { currentUsage: 1, limit: 10 },
292293
})
293294
reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true })
295+
listFoldersMock.mockResolvedValue([])
294296
})
295297

296298
it('ignores legacy description input instead of persisting it', async () => {
@@ -320,6 +322,89 @@ describe('executeCreateWorkflow billing attribution', () => {
320322
})
321323
})
322324

325+
it('canonicalizes a workflow-folder VFS path and resolves its internal ID', async () => {
326+
listFoldersMock.mockResolvedValue([
327+
{ folderId: 'folder-dream', folderName: 'Dream', parentId: null },
328+
{
329+
folderId: 'folder-launch-plans',
330+
folderName: 'Launch Plans',
331+
parentId: 'folder-dream',
332+
},
333+
])
334+
performCreateWorkflowMock.mockResolvedValue({
335+
success: true,
336+
workflow: {
337+
id: 'created-workflow',
338+
name: 'Created Workflow',
339+
workspaceId: 'workspace-1',
340+
folderId: 'folder-launch-plans',
341+
},
342+
})
343+
344+
const result = await executeCreateWorkflow(
345+
{
346+
name: 'Created Workflow',
347+
workspaceId: 'workspace-1',
348+
folderPath: 'workflows/Dream/Launch%20Plans',
349+
},
350+
executionContext
351+
)
352+
353+
expect(result.success).toBe(true)
354+
expect(performCreateWorkflowMock).toHaveBeenCalledWith({
355+
userId: 'user-1',
356+
workspaceId: 'workspace-1',
357+
name: 'Created Workflow',
358+
folderId: 'folder-launch-plans',
359+
})
360+
expect(workflowAuthzMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-launch-plans')
361+
})
362+
363+
it('fails clearly when a workflow-folder VFS path does not exist', async () => {
364+
listFoldersMock.mockResolvedValue([
365+
{ folderId: 'folder-existing', folderName: 'Existing', parentId: null },
366+
])
367+
368+
const result = await executeCreateWorkflow(
369+
{
370+
name: 'Created Workflow',
371+
workspaceId: 'workspace-1',
372+
folderPath: 'workflows/Dream',
373+
},
374+
executionContext
375+
)
376+
377+
expect(result).toEqual({
378+
success: false,
379+
error: 'Folder not found at workflows/Dream',
380+
})
381+
expect(performCreateWorkflowMock).not.toHaveBeenCalled()
382+
})
383+
384+
it('rejects canonically ambiguous workflow-folder VFS paths', async () => {
385+
listFoldersMock.mockResolvedValue([
386+
{ folderId: 'folder-cafe-nfc', folderName: 'Caf\u00e9', parentId: null },
387+
{ folderId: 'folder-cafe-nfd', folderName: 'Cafe\u0301', parentId: null },
388+
])
389+
390+
const result = await executeCreateWorkflow(
391+
{
392+
name: 'Created Workflow',
393+
workspaceId: 'workspace-1',
394+
folderPath: 'workflows/Caf%C3%A9',
395+
},
396+
executionContext
397+
)
398+
399+
expect(result).toEqual({
400+
success: false,
401+
error:
402+
'Folder path is ambiguous after canonicalization: workflows/Caf%C3%A9. Rename one of the conflicting folders and retry.',
403+
})
404+
expect(performCreateWorkflowMock).not.toHaveBeenCalled()
405+
expect(workflowAuthzMockFns.mockAssertFolderMutable).not.toHaveBeenCalled()
406+
})
407+
323408
it('keeps same-workspace creation and subsequent execution on the immutable payer', async () => {
324409
const context: ExecutionContext = { ...executionContext, workflowId: '' }
325410
performCreateWorkflowMock.mockResolvedValue({

apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,23 @@ export async function executeCreateWorkflow(
393393
}
394394
const workspaceId =
395395
params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId))
396-
const folderId = params?.folderId || null
397396

398397
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
398+
399+
const folderPath = typeof params?.folderPath === 'string' ? params.folderPath.trim() : ''
400+
let folderId =
401+
typeof params?.folderId === 'string' && params.folderId.trim() ? params.folderId.trim() : null
402+
if (folderPath) {
403+
const relativePath = workflowFolderRelativePath(folderPath)
404+
if (!relativePath) {
405+
folderId = null
406+
} else {
407+
const target = resolveFolderIdByPath(folderPath, await loadFolderPathIndex(workspaceId))
408+
if ('error' in target) return { success: false, error: target.error }
409+
folderId = target.folderId
410+
}
411+
}
412+
399413
await assertFolderMutable(folderId)
400414
assertWorkflowMutationNotAborted(context)
401415

@@ -1230,40 +1244,57 @@ function workflowFolderRelativePath(rawPath: string): string {
12301244
return trimmed.startsWith('workflows/') ? trimmed.slice('workflows/'.length) : trimmed
12311245
}
12321246

1247+
type FolderPathIndex = Map<string, string | null>
1248+
12331249
/**
1234-
* Load a lookup from each folder's canonical encoded VFS path to its id by
1235-
* inverting the same {@link buildVfsFolderPathMap} the VFS uses to serve folder
1236-
* paths, so a path the agent sees via glob round-trips to the right id. Fetched
1237-
* once per manage_folder call and reused across target + parent resolution.
1250+
* Load an index from each canonical encoded VFS path to its folder id. A null
1251+
* value records that multiple folder ids collapse to the same canonical path,
1252+
* so callers can reject the ambiguous path instead of silently choosing one.
12381253
*/
1239-
async function loadFolderPathToIdMap(workspaceId: string): Promise<Map<string, string>> {
1240-
const byPath = new Map<string, string>()
1254+
async function loadFolderPathIndex(workspaceId: string): Promise<FolderPathIndex> {
1255+
const byPath: FolderPathIndex = new Map()
12411256
for (const [folderId, encodedPath] of buildVfsFolderPathMap(
12421257
await listFolders(workspaceId)
12431258
).entries()) {
1244-
byPath.set(encodedPath, folderId)
1259+
if (!byPath.has(encodedPath)) {
1260+
byPath.set(encodedPath, folderId)
1261+
} else if (byPath.get(encodedPath) !== folderId) {
1262+
byPath.set(encodedPath, null)
1263+
}
12451264
}
12461265
return byPath
12471266
}
12481267

1249-
function lookupFolderIdByPath(rawPath: string, byPath: Map<string, string>): string | null {
1268+
function resolveFolderIdByPath(
1269+
rawPath: string,
1270+
byPath: FolderPathIndex,
1271+
label = 'Folder'
1272+
): { folderId: string } | { error: string } {
12501273
const relative = workflowFolderRelativePath(rawPath)
1251-
if (!relative) return null
1252-
return byPath.get(encodeVfsPathSegments(decodeVfsPathSegments(relative))) ?? null
1274+
if (!relative) return { error: `${label} not found at ${rawPath}` }
1275+
1276+
const canonicalPath = encodeVfsPathSegments(decodeVfsPathSegments(relative))
1277+
if (!byPath.has(canonicalPath)) return { error: `${label} not found at ${rawPath}` }
1278+
1279+
const folderId = byPath.get(canonicalPath)
1280+
if (!folderId) {
1281+
return {
1282+
error: `${label} path is ambiguous after canonicalization: ${rawPath}. Rename one of the conflicting folders and retry.`,
1283+
}
1284+
}
1285+
return { folderId }
12531286
}
12541287

12551288
/** Resolve the folder a manage_folder op targets, preferring folderId over path. */
12561289
async function resolveManageFolderTarget(
12571290
params: ManageFolderParams,
1258-
getFolderPaths: () => Promise<Map<string, string>>
1291+
getFolderPaths: () => Promise<FolderPathIndex>
12591292
): Promise<{ folderId: string } | { error: string }> {
12601293
const directId = typeof params.folderId === 'string' ? params.folderId.trim() : ''
12611294
if (directId) return { folderId: directId }
12621295
const path = typeof params.path === 'string' ? params.path.trim() : ''
12631296
if (!path) return { error: 'Provide the folder path (e.g. "workflows/Marketing") or folderId.' }
1264-
const folderId = lookupFolderIdByPath(path, await getFolderPaths())
1265-
if (!folderId) return { error: `Folder not found at ${path}` }
1266-
return { folderId }
1297+
return resolveFolderIdByPath(path, await getFolderPaths())
12671298
}
12681299

12691300
/**
@@ -1273,16 +1304,16 @@ async function resolveManageFolderTarget(
12731304
*/
12741305
async function resolveManageFolderParent(
12751306
params: ManageFolderParams,
1276-
getFolderPaths: () => Promise<Map<string, string>>
1307+
getFolderPaths: () => Promise<FolderPathIndex>
12771308
): Promise<{ parentId: string | null } | { error: string }> {
12781309
const directId = typeof params.parentId === 'string' ? params.parentId.trim() : ''
12791310
if (directId) return { parentId: directId }
12801311
if (params.parentId === null) return { parentId: null }
12811312
const dest = typeof params.destinationPath === 'string' ? params.destinationPath.trim() : ''
12821313
if (!dest || !workflowFolderRelativePath(dest)) return { parentId: null }
1283-
const parentId = lookupFolderIdByPath(dest, await getFolderPaths())
1284-
if (!parentId) return { error: `Destination folder not found at ${dest}` }
1285-
return { parentId }
1314+
const parent = resolveFolderIdByPath(dest, await getFolderPaths(), 'Destination folder')
1315+
if ('error' in parent) return parent
1316+
return { parentId: parent.folderId }
12861317
}
12871318

12881319
/**
@@ -1302,8 +1333,8 @@ export async function executeManageFolder(
13021333
// Fetch the workspace folder list at most once, lazily — only when a path
13031334
// (vs an explicit id) actually needs resolving, and shared across the
13041335
// target + parent lookups a single move/create performs.
1305-
let folderPathsPromise: Promise<Map<string, string>> | undefined
1306-
const getFolderPaths = () => (folderPathsPromise ??= loadFolderPathToIdMap(workspaceId))
1336+
let folderPathsPromise: Promise<FolderPathIndex> | undefined
1337+
const getFolderPaths = () => (folderPathsPromise ??= loadFolderPathIndex(workspaceId))
13071338

13081339
switch (operation) {
13091340
case 'create': {
@@ -1318,14 +1349,13 @@ export async function executeManageFolder(
13181349
name = segments[segments.length - 1]
13191350
const parentSegments = segments.slice(0, -1)
13201351
if (parentSegments.length > 0) {
1321-
const resolved = lookupFolderIdByPath(
1352+
const parent = resolveFolderIdByPath(
13221353
encodeVfsPathSegments(parentSegments),
1323-
await getFolderPaths()
1354+
await getFolderPaths(),
1355+
'Parent folder'
13241356
)
1325-
if (!resolved) {
1326-
return { success: false, error: `Parent folder not found for ${path}` }
1327-
}
1328-
parentId = resolved
1357+
if ('error' in parent) return { success: false, error: parent.error }
1358+
parentId = parent.folderId
13291359
}
13301360
} else {
13311361
const parent = await resolveManageFolderParent(params, getFolderPaths)

0 commit comments

Comments
 (0)