Files
simstudioai--sim/apps/sim/ee/workspace-forking/lib/create-fork.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

497 lines
20 KiB
TypeScript

import { db } from '@sim/db'
import { permissions, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import type { PermissionType } from '@sim/platform-authz/workspace'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import type { Workspace } from '@/lib/api/contracts/workspaces'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import type { WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
import { WORKSPACE_MODE } from '@/lib/workspaces/policy'
import {
finishBackgroundWork,
startBackgroundWork,
} from '@/ee/workspace-forking/lib/background-work/store'
import {
type ForkContentCopyPayload,
hasForkContentToCopy,
scheduleForkContentCopy,
serializeContentRefMaps,
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
import { copyForkChatDeployments } from '@/ee/workspace-forking/lib/copy/copy-chats'
import { planForkFileCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
import {
copyForkResourceContainers,
type ForkCopiedResourceNames,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import {
copyWorkflowStateIntoTarget,
loadWorkflowNameRegistry,
resolveForkFolderMapping,
} from '@/ee/workspace-forking/lib/copy/copy-workflows'
import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import {
assertForkStorageHeadroom,
sumForkCopyBytes,
} from '@/ee/workspace-forking/lib/copy/storage-quota'
import { buildForkWorkflowIdMap } from '@/ee/workspace-forking/lib/copy/workflow-id-map'
import { copyForkWorkflowMcpAttachments } from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments'
import { setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
import {
type ForkBlockPair,
reconcileForkBlockPairs,
toForkBlockPairs,
} from '@/ee/workspace-forking/lib/mapping/block-map-store'
import {
type ForkMappingUpsert,
type ForkResourceType,
seedEdgeMappings,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import { deriveForkBlockId } from '@/ee/workspace-forking/lib/remap/block-identity'
import { createForkBootstrapTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
import { collectReferencedDocumentIds } from '@/ee/workspace-forking/lib/remap/reference-scan'
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
const logger = createLogger('WorkspaceForkCreate')
/** Source resource ids the user selected to copy into the child, by kind. */
export interface ForkResourceSelection {
files: string[]
tables: string[]
knowledgeBases: string[]
customTools: string[]
skills: string[]
/** External MCP servers, copied as config rows (OAuth tokens never copied - re-auth in child). */
mcpServers: string[]
/** Workflow-publishing MCP servers, copied as config-only shells with no workflows attached. */
workflowMcpServers: string[]
}
const EMPTY_SELECTION: ForkResourceSelection = {
files: [],
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
}
export interface CreateForkParams {
source: WorkspaceWithOwner
policy: WorkspaceCreationPolicy
userId: string
/** Display name of the user forking, recorded on the activity entry. */
actorName?: string
name?: string
selection?: ForkResourceSelection
requestId?: string
}
export interface CreateForkResult {
/** Full child workspace row so callers can merge it into the workspace-list cache. */
workspace: Workspace
workflowsCopied: number
}
// Credentials are intentionally absent: a fork never copies them, so their references
// resolve to null here and are cleared on remap (re-connect in the child).
const FORK_KIND_TO_RESOURCE_TYPE: Partial<Record<ForkRemapKind, ForkResourceType>> = {
'custom-tool': 'custom_tool',
skill: 'skill',
table: 'table',
'knowledge-base': 'knowledge_base',
'knowledge-document': 'knowledge_document',
'mcp-server': 'mcp_server',
}
/**
* Create a fork of `source`: a new child workspace that copies the parent's
* **deployed** workflows (left undeployed in the child), snapshots the parent's
* member list, copies the user-selected resources (files, tables, knowledge bases,
* custom tools, skills, MCP server configs) with fresh ids, and records the
* source→child identity for each. Workflow references to copied resources are
* rewritten to the child ids; references to resources that were not copied (and
* all credential references) are cleared; env-var references are preserved.
*/
export async function createFork(params: CreateForkParams): Promise<CreateForkResult> {
const { source, policy, userId, requestId = 'unknown' } = params
const selection = params.selection ?? EMPTY_SELECTION
const childName = params.name?.trim() || `${source.name} (fork)`
// Copied blob bytes (workspace files + KB document blobs) are charged to the initiating
// user's storage scope exactly as if uploaded to the child workspace, so enforce
// headroom BEFORE any fork work. Sizes come from the metadata rows the fork tx would
// load anyway; over quota fails the fork here with the upload path's error shape.
const copyBytes = await sumForkCopyBytes(db, source.id, {
fileIds: selection.files,
knowledgeBaseIds: selection.knowledgeBases,
})
await assertForkStorageHeadroom({ userId, bytes: copyBytes })
// Read the source's deployed workflows + states BEFORE the transaction so these
// global-pool reads don't check out a second pooled connection from inside the
// fork tx (which can deadlock the pool at saturation).
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(source.id)
// Documents the copied workflows reference (document-selector values + nested documentId
// tool params). Those whose parent KB is being copied get a placeholder + id map inside the
// fork tx so their references remap to the copied document instead of being cleared.
const referencedDocumentIds = collectReferencedDocumentIds(
deployedWorkflows.flatMap((wf) => {
const sourceState = sourceStates.get(wf.id)
return sourceState ? [sourceState] : []
})
)
const forkedWorkflowNames: string[] = []
let forkedResourceNames: ForkCopiedResourceNames = {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
}
const { result, blobTasks, contentPlan, contentRefMaps } = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
const now = new Date()
const childWorkspaceId = generateId()
await tx.insert(workspace).values({
id: childWorkspaceId,
name: childName,
ownerId: userId,
organizationId: policy.organizationId,
workspaceMode: policy.workspaceMode,
billedAccountUserId: policy.billedAccountUserId,
allowPersonalApiKeys: true,
forkedFromWorkspaceId: source.id,
createdAt: now,
updatedAt: now,
})
const sourcePermissions = await tx
.select({ userId: permissions.userId, permissionType: permissions.permissionType })
.from(permissions)
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, source.id)))
const permissionByUser = new Map<string, PermissionType>()
for (const row of sourcePermissions) {
permissionByUser.set(row.userId, row.permissionType)
}
permissionByUser.set(userId, 'admin')
if (
policy.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
policy.billedAccountUserId &&
policy.billedAccountUserId !== userId
) {
permissionByUser.set(policy.billedAccountUserId, 'admin')
}
await tx.insert(permissions).values(
Array.from(permissionByUser.entries()).map(([memberUserId, permissionType]) => ({
id: generateId(),
entityType: 'workspace' as const,
entityId: childWorkspaceId,
userId: memberUserId,
permissionType,
createdAt: now,
updatedAt: now,
}))
)
// The id map (and the identity seed below) covers only the workflows ACTUALLY copied -
// those whose deployed state loaded. A deployed source whose state failed to load is
// skipped by the copy loop, so it must be excluded here too: keeping it would (1) remap a
// copied workflow's reference to a child id that is never created (a dangling ref) instead
// of clearing it, and (2) seed a `workspace_fork_resource_map` workflow row pointing at
// that never-created target, which a later push would treat as an orphan and archive the
// parent's real workflow. Mirrors promote's writtenItems-only identity seed.
const workflowIdMap = buildForkWorkflowIdMap(deployedWorkflows, new Set(sourceStates.keys()))
const fileResult = await planForkFileCopies({
tx,
sourceWorkspaceId: source.id,
childWorkspaceId,
userId,
fileIds: selection.files,
now,
})
// Source -> child folder id map: remaps folder references in the copied workflows below and
// feeds the post-commit content-ref rewrite (`sim:folder/<id>` 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({
tx,
sourceWorkspaceId: source.id,
targetWorkspaceId: childWorkspaceId,
userId,
now,
contentFolderIds: deployedWorkflows
.filter((wf) => workflowIdMap.has(wf.id))
.map((wf) => wf.folderId),
})
const resourceResult = await copyForkResourceContainers({
tx,
sourceWorkspaceId: source.id,
childWorkspaceId,
userId,
now,
selection: {
customTools: selection.customTools,
skills: selection.skills,
mcpServers: selection.mcpServers,
workflowMcpServers: selection.workflowMcpServers,
tables: selection.tables,
knowledgeBases: selection.knowledgeBases,
},
workflowIdMap,
referencedDocumentIds: Array.from(referencedDocumentIds),
})
forkedResourceNames = resourceResult.names
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]
if (!resourceType) return null
return resourceResult.idMap.get(resourceType)?.get(sourceId) ?? null
}
const transform = createForkBootstrapTransform(resolveCopied)
// The child is brand new, so this loads an empty registry; name collisions can only
// arise among the copied workflows themselves, which the in-loop claims resolve.
const nameRegistry = await loadWorkflowNameRegistry(tx, childWorkspaceId)
let workflowsCopied = 0
// Seed the block-identity map (parent block -> derived child block) so a later push of
// this fork resolves each child block back to the parent's ORIGINAL id instead of
// re-deriving and re-keying the parent's webhook URLs.
const blockPairs: ForkBlockPair[] = []
const sourceWorkflowIds: string[] = []
for (const wf of deployedWorkflows) {
const sourceState = sourceStates.get(wf.id)
if (!sourceState) continue
const targetWorkflowId = workflowIdMap.get(wf.id)!
const copyResult = await copyWorkflowStateIntoTarget({
tx,
targetWorkflowId,
targetWorkspaceId: childWorkspaceId,
userId,
mode: 'create',
now,
sourceState,
sourceMeta: {
name: wf.name,
description: wf.description,
folderId: wf.folderId,
sortOrder: wf.sortOrder,
},
workflowIdMap,
folderIdMap,
transformSubBlocks: transform,
nameRegistry,
requestId,
})
// Creation copies parent -> child, so the source side is the parent.
blockPairs.push(...toForkBlockPairs(copyResult.blockIdMapping, true, wf.id, targetWorkflowId))
sourceWorkflowIds.push(wf.id)
workflowsCopied += 1
forkedWorkflowNames.push(wf.name)
}
await reconcileForkBlockPairs(tx, childWorkspaceId, true, sourceWorkflowIds, blockPairs)
// Carry each copied workflow's chat deployment(s): a fresh identifier
// (`{child-workspace}-{workflow}-{randomnum}`) with the config copied verbatim and its
// output block ids remapped onto the derived child blocks. The chat serves at its new URL
// as soon as the child workflow is deployed.
await copyForkChatDeployments({
tx,
pairs: deployedWorkflows.flatMap((wf) => {
const targetWorkflowId = workflowIdMap.get(wf.id)
return targetWorkflowId
? [{ sourceWorkflowId: wf.id, targetWorkflowId, workflowName: wf.name }]
: []
}),
targetWorkspaceName: childName,
userId,
now,
resolveBlockId: deriveForkBlockId,
requestId,
})
// Carry workflow-as-MCP-tool attachments onto the copied server shells: an attachment
// copies only when BOTH its server and its workflow were copied. Runs after the workflow
// rows exist (FK); the child re-derives each tool's parameter schema on first deploy.
await copyForkWorkflowMcpAttachments({
tx,
serverIdMap: resourceResult.idMap.get('workflow_mcp_server') ?? new Map(),
workflowIdMap,
now,
})
// A fork carries only DEPLOYED workflows. When the source has none (e.g. it was
// itself just forked and never redeployed), seed a default workflow so the child
// is a usable workspace rather than a blank one with no workflow at all - the same
// starter "New workspace" creates. Any copied resources still land alongside it.
if (workflowsCopied === 0) {
const defaultWorkflowId = generateId()
await tx.insert(workflow).values({
id: defaultWorkflowId,
userId,
workspaceId: childWorkspaceId,
folderId: null,
name: 'default-agent',
description: 'Your first workflow - start building here!',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
await saveWorkflowToNormalizedTables(defaultWorkflowId, workflowState, tx)
}
const seedEntries: ForkMappingUpsert[] = []
for (const [sourceWorkflowId, childWorkflowId] of workflowIdMap.entries()) {
seedEntries.push({
resourceType: 'workflow',
parentResourceId: sourceWorkflowId,
childResourceId: childWorkflowId,
})
}
seedEntries.push(...resourceResult.mappingEntries)
// Copied files map by STORAGE KEY (matching `file-upload` references), from the file-copy
// plan - files are copied outside `copyForkResourceContainers`, so their identity rows must
// be added here explicitly. Without them a later sync re-offers every fork-copied file as a
// copy candidate (and a push would duplicate a referenced file into the parent instead of
// resolving it back to the parent's original).
for (const [sourceKey, childKey] of fileResult.keyMap) {
seedEntries.push({
resourceType: 'file',
parentResourceId: sourceKey,
childResourceId: childKey,
})
}
await seedEdgeMappings(tx, childWorkspaceId, userId, seedEntries)
logger.info(`[${requestId}] Created fork ${childWorkspaceId} from ${source.id}`, {
workflowsCopied,
mappingsSeeded: seedEntries.length,
})
// Serialized in-content reference maps so the post-commit content copy can rewrite
// `sim:` links + embedded URLs inside copied skill bodies and markdown file blobs. Maps
// become Records to cross the background-job payload boundary.
const contentRefMaps = serializeContentRefMaps({
workspaceId: { from: source.id, to: childWorkspaceId },
fileKeys: fileResult.keyMap,
fileIds: fileResult.idMap,
workflows: workflowIdMap,
folders: folderIdMap,
knowledgeBases: resourceResult.idMap.get('knowledge_base'),
tables: resourceResult.idMap.get('table'),
skills: resourceResult.idMap.get('skill'),
})
return {
result: {
workspace: {
id: childWorkspaceId,
name: childName,
ownerId: userId,
organizationId: policy.organizationId,
workspaceMode: policy.workspaceMode,
billedAccountUserId: policy.billedAccountUserId,
allowPersonalApiKeys: true,
forkedFromWorkspaceId: source.id,
},
workflowsCopied,
},
blobTasks: fileResult.blobTasks,
contentPlan: resourceResult.contentPlan,
contentRefMaps,
}
})
// Bulk content (table rows, KB documents + embeddings) and file blobs are copied
// AFTER the fork commits, in the background, so the fork request returns as soon
// as the workflows exist and is never blocked on (or timed out by) heavy I/O.
// Trigger.dev runs it out-of-process (surviving deploys); without it, runDetached
// runs it inline best-effort. Both are batched/bounded internally.
const hasContent = hasForkContentToCopy(contentPlan, blobTasks)
// Record a durable job for EVERY fork (the fork already committed), scoped to the
// SOURCE workspace - that's where the fork was initiated and where its Activity tab
// lives, so the record survives a reload of the fork modal. When there is heavy
// content to copy in the background the row stays `processing` until the runner
// finishes it (merging in copied/failed); otherwise the fork is already complete.
const forkedName = result.workspace.name
// The fork already committed; failing to record the tracking row must not turn it into
// a 500. Log and continue without a status row - the background content copy below still
// runs (its runner no-ops the status update when statusId is absent).
let statusId: string | undefined
try {
statusId = await startBackgroundWork(db, {
workspaceId: source.id,
kind: 'fork_content_copy',
// Append-only: each fork is a distinct entry in the source workspace's fork history.
supersede: false,
message: hasContent ? `Copying resources to "${forkedName}"` : `Forked into "${forkedName}"`,
metadata: {
childWorkspaceId: result.workspace.id,
childWorkspaceName: forkedName,
actorName: params.actorName,
workflowsCopied: result.workflowsCopied,
tables: contentPlan.tables.length,
knowledgeBases: contentPlan.knowledgeBases.length,
files: blobTasks.length,
workflowNames: forkedWorkflowNames,
tableNames: forkedResourceNames.tables,
knowledgeBaseNames: forkedResourceNames.knowledgeBases,
fileNames: blobTasks.map((task) => task.fileName),
customToolNames: forkedResourceNames.customTools,
skillNames: forkedResourceNames.skills,
mcpServerNames: forkedResourceNames.mcpServers,
workflowMcpServerNames: forkedResourceNames.workflowMcpServers,
},
})
} catch (error) {
logger.error(`[${requestId}] Failed to record fork background-work status`, {
childWorkspaceId: result.workspace.id,
error: getErrorMessage(error),
})
}
if (!hasContent) {
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: 'completed',
message: `Forked into "${forkedName}"`,
metadata: { copied: 0, failed: 0 },
}).catch(() => {})
}
return result
}
const payload: ForkContentCopyPayload = {
contentPlan,
blobTasks,
contentRefMaps,
statusId,
requestId,
}
await scheduleForkContentCopy(payload, { detachedLabel: 'fork-content-copy', requestId })
return result
}