diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts index 88a57454338..622c01cf582 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { folder as folderTable } from '@sim/db/schema' import { dbChainMockFns, resetDbChainMock, @@ -237,4 +238,95 @@ describe('planForkFileCopies', () => { }) expect(tx.insert).not.toHaveBeenCalled() }) + + it('mirrors the source file-folder subtree and places each copy inside it', async () => { + const sourceMeta = { + id: 'wf_src1', + key: 'workspace/src-ws/1-abc-a.txt', + userId: 'uploader-1', + workspaceId: 'src-ws', + folderId: 'child-folder', + context: 'workspace', + chatId: null, + originalName: 'a.txt', + displayName: null, + contentType: 'text/plain', + size: 4321, + deletedAt: null, + uploadedAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + contentUpdatedAt: new Date('2026-01-01'), + } + // A two-level source tree; only the branch holding the copied file is mirrored. + const sourceFolders = [ + { + id: 'root-folder', + name: 'Reports', + parentId: null, + workspaceId: 'src-ws', + resourceType: 'file', + deletedAt: null, + }, + { + id: 'child-folder', + name: 'Q1', + parentId: 'root-folder', + workspaceId: 'src-ws', + resourceType: 'file', + deletedAt: null, + }, + { + id: 'unrelated', + name: 'Archive', + parentId: null, + workspaceId: 'src-ws', + resourceType: 'file', + deletedAt: null, + }, + ] + const insertedFolders: Array> = [] + let folderSelectCall = 0 + const tx = { + select: vi.fn(() => ({ + from: (table: unknown) => ({ + where: () => { + if (table !== folderTable) return Promise.resolve([sourceMeta]) + // First folder read is the source tree; the second is the (empty) target tree. + return Promise.resolve(folderSelectCall++ === 0 ? sourceFolders : []) + }, + }), + })), + insert: vi.fn(() => ({ + values: (rows: Array>) => { + insertedFolders.push(...rows) + return Promise.resolve() + }, + })), + } as unknown as DbOrTx + + const result = await planForkFileCopies({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + fileIds: ['wf_src1'], + now: new Date('2026-02-01'), + }) + + // The file's folder and its ancestor are recreated; the unrelated branch is pruned. + expect(insertedFolders).toHaveLength(2) + const byName = new Map(insertedFolders.map((row) => [row.name, row])) + expect(byName.has('Archive')).toBe(false) + const newRoot = byName.get('Reports')! + const newChild = byName.get('Q1')! + expect(newRoot).toMatchObject({ parentId: null, workspaceId: 'child-ws' }) + // Nesting survives: the copied child points at the copied parent, not the source's. + expect(newChild.parentId).toBe(newRoot.id) + expect(newChild.id).not.toBe('child-folder') + + // The copied file lands in the mirrored folder rather than the target root. + expect(result.blobTasks[0].targetFolderId).toBe(newChild.id) + expect(result.folderIdMap.get('child-folder')).toBe(newChild.id) + expect(result.folderIdMap.get('root-folder')).toBe(newRoot.id) + }) }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts index 1a7dff31853..cfa4888170d 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts @@ -19,6 +19,7 @@ import { } from '@/lib/uploads/core/storage-service' import type { StorageContext } from '@/lib/uploads/shared/types' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows' import { type ForkContentRefMaps, rewriteForkContentRefs, @@ -55,6 +56,13 @@ export interface BlobCopyTask { displayName: string | null userId: string workspaceId: string + /** + * Target file-folder id, already created inside the copy transaction by + * {@link resolveForkFolderMapping}. Optional because tasks queued by an earlier deploy have + * no such field: those replay as `undefined` and finalize at the target root, exactly as + * they did before folder structure transited a fork edge. + */ + targetFolderId?: string | null } export interface PlanForkFileCopiesResult { @@ -74,6 +82,11 @@ export interface PlanForkFileCopiesResult { idMap: Map /** Blob duplications plus deferred metadata to finalize after the fork transaction commits. */ blobTasks: BlobCopyTask[] + /** + * source file-folder id -> target file-folder id for the mirrored subtree. Merged into the + * content-ref maps so `sim:folder/` mentions inside copied bodies resolve to the copy. + */ + folderIdMap: Map } async function getFinalizedFileCopies( @@ -124,7 +137,9 @@ export async function planForkFileCopies(params: { const keyMap = new Map() const idMap = new Map() const blobTasks: BlobCopyTask[] = [] - if (fileIds.length === 0 && fileKeys.length === 0) return { keyMap, idMap, blobTasks } + let folderIdMap = new Map() + if (fileIds.length === 0 && fileKeys.length === 0) + return { keyMap, idMap, blobTasks, folderIdMap } // Match by id and/or storage key (OR'd) so either selection shape resolves to the same // source rows. Batch the metadata read (one query for all selected files): non-deleted, @@ -148,6 +163,19 @@ export async function planForkFileCopies(params: { ) ) + // Mirror the file-folder subtree holding the selected files (plus ancestors) into the target + // and place each copy inside it. Scoped to `resourceType: 'file'`: file folders are a tree of + // their own, disjoint from the workflow folders the workflow copy mirrors. + folderIdMap = await resolveForkFolderMapping({ + tx, + sourceWorkspaceId, + targetWorkspaceId: childWorkspaceId, + userId, + now: params.now, + resourceType: 'file', + contentFolderIds: metas.map((meta) => meta.folderId), + }) + for (const meta of metas) { const childFileId = generateId() // Use the canonical workspace-file key (`workspace/{id}/...`) so the file-serve @@ -168,10 +196,13 @@ export async function planForkFileCopies(params: { displayName: meta.displayName, userId, workspaceId: childWorkspaceId, + // An unmapped folder (pruned, or archived mid-copy) re-roots the file, matching how a + // copied workflow falls back to the target root. + targetFolderId: meta.folderId ? (folderIdMap.get(meta.folderId) ?? null) : null, }) } - return { keyMap, idMap, blobTasks } + return { keyMap, idMap, blobTasks, folderIdMap } } /** @@ -269,7 +300,7 @@ export async function executeForkFileBlobCopies( key: task.targetKey, userId: task.userId, workspaceId: task.workspaceId, - folderId: null, + folderId: task.targetFolderId ?? null, context: task.context, chatId: null, originalName: task.fileName, @@ -312,7 +343,7 @@ export async function executeForkFileBlobCopies( .update(workspaceFiles) .set({ userId: task.userId, - folderId: null, + folderId: task.targetFolderId ?? null, context: task.context, chatId: null, originalName: task.fileName, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index e15e0055fdf..b965aa0654e 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { folder as folderTable } from '@sim/db/schema' import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, @@ -1343,12 +1344,31 @@ describe('copyForkResourceContainers skill copy', () => { describe('copyForkResourceContainers knowledge-base tag definitions', () => { /** Sequential tx mock: each select resolves the next queued row set; inserts are captured per call. */ - function makeKbTx(selects: Array>>) { + /** + * Sequential tx mock over the KB-copy selects, with the folder-mirroring reads served + * separately: the copy resolves the source KB folder subtree before inserting, and dispatching + * on the queried table keeps the queue positional over the KB selects alone instead of + * silently shifting whenever that mapping issues a query. + */ + function makeKbTx( + selects: Array>>, + sourceFolders: Array> = [] + ) { let call = 0 + // The mapper reads the source tree first, then the target's; serving the same rows to both + // would make every source folder look already-present and suppress the mirroring. + let folderCall = 0 const inserts: Array>> = [] const tx = { select: () => ({ - from: () => ({ where: () => Promise.resolve(selects[call++] ?? []) }), + from: (table: unknown) => ({ + where: () => { + if (table === folderTable) { + return Promise.resolve(folderCall++ === 0 ? sourceFolders : []) + } + return Promise.resolve(selects[call++] ?? []) + }, + }), }), insert: () => ({ values: (rows: Array>) => { @@ -1437,6 +1457,45 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => { // Only the KB row itself is inserted - no empty tag-definition insert. expect(inserts).toHaveLength(1) }) + + it('mirrors the source knowledge-base folder and copies the KB into it, not the target root', async () => { + const foldered = { ...sourceBase, folderId: 'kb-folder' } + const { tx, inserts } = makeKbTx( + [[foldered], []], + [ + { + id: 'kb-folder', + name: 'Policies', + parentId: null, + workspaceId: 'src-ws', + resourceType: 'knowledge_base', + deletedAt: null, + }, + ] + ) + + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: kbSelection, + workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + + // insert #0 is the mirrored folder, #1 the KB row placed inside it. + const newFolder = inserts[0][0] + expect(newFolder).toMatchObject({ + name: 'Policies', + workspaceId: 'child-ws', + resourceType: 'knowledge_base', + }) + // A fresh id: reusing the source's would point the child KB at a folder it cannot see. + expect(newFolder.id).not.toBe('kb-folder') + expect(inserts[1][0].folderId).toBe(newFolder.id) + }) }) describe('planForkMappedKbDocumentCopies', () => { diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 991b1fd7a1d..dc3c9dbcbdd 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -71,6 +71,7 @@ import { recordKnowledgeBaseFileOwnership, } from '@/lib/uploads/server/metadata' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows' import { deleteCopiedResourceMappingsByTargets, type ForkMappingUpsert, @@ -333,6 +334,11 @@ export interface CopyResourcesResult { contentPlan: ForkContentPlan /** Names of the copied resources, by kind, for the fork report breakdown. */ names: ForkCopiedResourceNames + /** + * source folder id -> target folder id for every family mirrored here (tables, knowledge + * bases). Merged by the caller with the workflow and file maps for content-ref rewriting. + */ + folderIdMap: Map } function setId(idMap: Map>, type: ForkResourceType) { @@ -371,6 +377,12 @@ export async function copyForkResourceContainers( const resolveEnvName = params.resolveEnvName const idMap = new Map>() const mappingEntries: ForkMappingUpsert[] = [] + /** + * Mirrored folder ids across every family copied here. Table and knowledge-base folders live + * in disjoint trees, and folder ids are globally unique, so merging them into one map is + * unambiguous and lets callers rewrite `sim:folder/` refs in a single pass. + */ + const folderIdMap = new Map() const contentPlan: ForkContentPlan = { sourceWorkspaceId, childWorkspaceId, @@ -618,6 +630,17 @@ export async function copyForkResourceContainers( isNull(userTableDefinitions.archivedAt) ) ) + const tableFolderIdMap = await resolveForkFolderMapping({ + tx, + sourceWorkspaceId, + targetWorkspaceId: childWorkspaceId, + userId, + now, + resourceType: 'table', + contentFolderIds: definitions.map((definition) => definition.folderId), + }) + for (const [source, target] of tableFolderIdMap) folderIdMap.set(source, target) + const inserts: (typeof userTableDefinitions.$inferInsert)[] = [] for (const definition of definitions) { const childTableId = generateId() @@ -631,13 +654,13 @@ export async function copyForkResourceContainers( id: childTableId, workspaceId: childWorkspaceId, /** - * Folders never transit a fork edge. `folder_id` is a global id with no workspace in - * it, so the spread above would leave the child's table pointing at a folder owned by - * the SOURCE workspace — invisible in the fork, and mutated from under it if the - * source later deletes that folder (`ON DELETE SET NULL`). Forked tables land at the - * root, like forked files already do. + * `folder_id` is a global id with no workspace in it, so the spread above would leave + * the child's table pointing at a folder owned by the SOURCE workspace — invisible in + * the fork, and mutated from under it if the source later deletes that folder + * (`ON DELETE SET NULL`). Remap it onto the mirrored target subtree instead; an + * unmapped folder re-roots the table. */ - folderId: null, + folderId: definition.folderId ? (tableFolderIdMap.get(definition.folderId) ?? null) : null, schema: remappedSchema, createdBy: userId, rowsVersion: 0, @@ -674,6 +697,17 @@ export async function copyForkResourceContainers( isNull(knowledgeBase.deletedAt) ) ) + const kbFolderIdMap = await resolveForkFolderMapping({ + tx, + sourceWorkspaceId, + targetWorkspaceId: childWorkspaceId, + userId, + now, + resourceType: 'knowledge_base', + contentFolderIds: bases.map((base) => base.folderId), + }) + for (const [source, target] of kbFolderIdMap) folderIdMap.set(source, target) + const inserts: (typeof knowledgeBase.$inferInsert)[] = [] const kbEntryBySourceId = new Map() for (const base of bases) { @@ -682,8 +716,8 @@ export async function copyForkResourceContainers( ...base, id: childKbId, workspaceId: childWorkspaceId, - /** Same reasoning as the table copy above: folders do not transit a fork edge. */ - folderId: null, + /** Same reasoning as the table copy above: remapped, never carried across verbatim. */ + folderId: base.folderId ? (kbFolderIdMap.get(base.folderId) ?? null) : null, userId, deletedAt: null, createdAt: now, @@ -741,7 +775,7 @@ export async function copyForkResourceContainers( }) } - return { idMap, mappingEntries, contentPlan, names } + return { idMap, mappingEntries, contentPlan, names, folderIdMap } } /** diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index cc8da0e3600..711f6db0215 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { and, eq, inArray, isNull } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' import type { DbOrTx } from '@/lib/db/types' import { assertFolderCollectionHasRoom } from '@/lib/folders/queries' import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' @@ -44,12 +45,17 @@ interface ResolveForkFolderMappingParams { userId: string now: Date /** - * Source folder ids that will directly hold copied content (workflows); null entries + * Which folder tree to mirror. `folder` rows are one table discriminated by this column, and + * the four folder-bearing families (`workflow`, `file`, `knowledge_base`, `table`) each own a + * disjoint tree, so a mapping run is always scoped to exactly one of them - reading across + * types would alias unrelated same-named folders onto each other. + */ + resourceType: FolderResourceType + /** + * Source folder ids that will directly hold copied content of `resourceType`; null entries * (root-placed content) are ignored. A source folder is copied into the target only when * its subtree contains at least one of these, so a fork/sync never creates folders that - * would end up empty. Copied workspace FILES never influence this set: their folders are a - * separate tree (`folder` rows with `resourceType = 'file'`, which this copy only ever reads - * as `'workflow'`) and are flattened to root by the copy. + * would end up empty. */ contentFolderIds: ReadonlyArray } @@ -61,8 +67,11 @@ interface ResolveForkFolderMappingParams { * parent are reused instead of duplicated. Folders whose subtree holds no copied content are * pruned - never created - though a pruned folder still maps onto an existing target folder * when one matches, so previously-synced content refs keep resolving. Returns a map from - * source folder id to target folder id; a copied workflow whose folder is absent from the + * source folder id to target folder id; copied content whose folder is absent from the * map is placed at the target's root (see {@link copyWorkflowStateIntoTarget}). + * + * Call once per folder-bearing family being copied; the returned maps are disjoint (folder ids + * are globally unique) and safe to merge for content-reference rewriting. */ export async function resolveForkFolderMapping({ tx, @@ -70,6 +79,7 @@ export async function resolveForkFolderMapping({ targetWorkspaceId, userId, now, + resourceType, contentFolderIds, }: ResolveForkFolderMappingParams): Promise> { const map = new Map() @@ -80,7 +90,7 @@ export async function resolveForkFolderMapping({ .where( and( eq(folderTable.workspaceId, sourceWorkspaceId), - eq(folderTable.resourceType, 'workflow'), + eq(folderTable.resourceType, resourceType), isNull(folderTable.deletedAt) ) ) @@ -107,7 +117,7 @@ export async function resolveForkFolderMapping({ .where( and( eq(folderTable.workspaceId, targetWorkspaceId), - eq(folderTable.resourceType, 'workflow'), + eq(folderTable.resourceType, resourceType), isNull(folderTable.deletedAt) ) ) @@ -172,7 +182,7 @@ export async function resolveForkFolderMapping({ * transaction's `lock_timeout`, which the fork sets deliberately, so an ordinary * concurrent `createFolder` can still slip a row in between the count and the insert. */ - await assertFolderCollectionHasRoom(targetWorkspaceId, 'workflow', tx, { + await assertFolderCollectionHasRoom(targetWorkspaceId, resourceType, tx, { additionalRows: newFolders.length, }) await tx.insert(folderTable).values(newFolders) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index 56278fa1b86..00ba481cc4c 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -134,10 +134,12 @@ describe('createFork storage headroom gate', () => { keyMap: new Map(), idMap: new Map(), blobTasks: [], + folderIdMap: new Map(), }) mockCopyForkResourceContainers.mockResolvedValue({ idMap: new Map(), mappingEntries: [], + folderIdMap: new Map(), contentPlan: { sourceWorkspaceId: 'src-ws', childWorkspaceId: 'child-ws', @@ -224,6 +226,7 @@ describe('createFork storage headroom gate', () => { keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]), idMap: new Map([['file-1', 'file-1-copy']]), blobTasks: [], + folderIdMap: new Map(), }) await createFork(forkParams({ files: ['file-1'] })) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 09821fafa5d..67755d8f1d9 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -228,14 +228,15 @@ export async function createFork(params: CreateForkParams): Promise` mentions in skill/file bodies). // Scoped to the folders that will actually receive a copied workflow (plus ancestors): a // fork copies only DEPLOYED workflows, so folders holding none would be created empty in - // the child and are pruned instead. Copied files don't extend this set - they use the - // separate workspace-file-folder entity and land at the child's root. - const folderIdMap = await resolveForkFolderMapping({ + // the child and are pruned instead. The file/table/knowledge-base trees are mirrored + // separately by their own copies and merged in below. + const workflowFolderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId: source.id, targetWorkspaceId: childWorkspaceId, userId, now, + resourceType: 'workflow', contentFolderIds: deployedWorkflows .filter((wf) => workflowIdMap.has(wf.id)) .map((wf) => wf.folderId), @@ -264,6 +265,17 @@ export async function createFork(params: CreateForkParams): Promise` ref in copied content + * resolves regardless of which family's folder it names. + */ + const folderIdMap = new Map([ + ...workflowFolderIdMap, + ...fileResult.folderIdMap, + ...resourceResult.folderIdMap, + ]) + const resolveCopied = (kind: ForkRemapKind, sourceId: string): string | null => { if (kind === 'file') return fileResult.keyMap.get(sourceId) ?? null const resourceType = FORK_KIND_TO_RESOURCE_TYPE[kind] diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts index 250c5fc85d6..c177ceee97d 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts @@ -244,6 +244,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { vi.clearAllMocks() mockCopyForkResourceContainers.mockResolvedValue({ idMap: new Map(), + folderIdMap: new Map(), mappingEntries: [], contentPlan: { sourceWorkspaceId: 'src-ws', @@ -313,6 +314,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => { mockPlanForkFileCopies.mockResolvedValue({ keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]), + folderIdMap: new Map(), idMap: new Map([['file-src', 'file-dst']]), blobTasks: [ { @@ -386,6 +388,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { // mapping row is what makes the next sync resolve the copy instead of re-offering it. mockCopyForkResourceContainers.mockResolvedValue({ idMap: new Map([['table', new Map([['tbl-unref', 'tbl-copy']])]]), + folderIdMap: new Map(), mappingEntries: [ { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, ], @@ -409,6 +412,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { }) mockPlanForkFileCopies.mockResolvedValue({ keyMap: new Map(), + folderIdMap: new Map(), idMap: new Map(), blobTasks: [], }) diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts index 4cd8f703258..19269023429 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts @@ -173,7 +173,11 @@ export async function copyPromoteUnmappedResources(params: { now: Date selection: PromoteCopySelection workflowIdMap: Map - /** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/`. */ + /** + * source workflow-folder id -> target folder id, so copied skill/markdown bodies rewrite + * `sim:folder/`. The file / table / knowledge-base trees are mirrored by the copies run + * here and unioned onto this map before the content rewrite. + */ folderIdMap: Map /** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */ resolver: ForkReferenceResolver @@ -251,6 +255,7 @@ export async function copyPromoteUnmappedResources(params: { keyMap: new Map(), idMap: new Map(), blobTasks: [] as BlobCopyTask[], + folderIdMap: new Map(), } // U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc @@ -304,7 +309,9 @@ export async function copyPromoteUnmappedResources(params: { const contentRefMaps = serializeContentRefMaps({ workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId }, workflows: workflowIdMap, - folders: folderIdMap, + // Workflow folders (mapped by the caller) unioned with the file / table / knowledge-base + // folders this copy mirrored, so a `sim:folder/` ref resolves whichever tree it names. + folders: new Map([...folderIdMap, ...fileResult.folderIdMap, ...result.folderIdMap]), fileKeys: fileResult.keyMap, fileIds: fileResult.idMap, skills: result.idMap.get('skill'), diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.ts index 22a25a9e42c..ea92f08c4e8 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.ts @@ -557,6 +557,7 @@ export async function promoteFork(params: PromoteForkParams): Promise item.sourceMeta.folderId), })