chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,314 @@
'use client'
import { useCallback, useMemo } from 'react'
import { Badge, Button } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDateTime } from '@sim/utils/formatting'
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
import {
ActivityLog,
type ActivityLogEntry,
} from '@/app/workspace/[workspaceId]/settings/components/activity-log'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { useWorkspaceBackgroundWork } from '@/ee/workspace-forking/hooks/background-work'
const logger = createLogger('ForkActivityPanel')
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
function countList(pairs: Array<[number | undefined, string]>): string {
return pairs
.filter(([n]) => (n ?? 0) > 0)
.map(([n, verb]) => `${n} ${verb}`)
.join(' · ')
}
/** A named group (one resource kind or change action) of a job's report. */
interface ReportGroup {
label: string
names: string[]
}
/** A job's expanded report: named groups plus plain notes (counts / warnings). */
interface JobReport {
groups: ReportGroup[]
notes: Array<{ value: string; warning?: boolean }>
}
/** The workspace whose activity is being viewed, for phrasing rows recorded on either side of an edge. */
interface ActivityView {
workspaceId: string
/** Lineage partner names by id (the parent + this workspace's forks). */
workspaceNames: ReadonlyMap<string, string>
}
/** Display name of the workspace a partner-recorded row was keyed to (the edge's other side). */
function partnerName(job: BackgroundWorkItem, view: ActivityView): string {
return view.workspaceNames.get(job.workspaceId) ?? 'another workspace'
}
/**
* The activity-row title, derived per kind from the job's metadata. Every event is
* recorded once, keyed to the workspace it was initiated from, so a row keyed to an
* edge partner is phrased from THIS workspace's side (e.g. the parent's "Pushed to X"
* row reads "Received push from <fork>" when X views it).
*/
function jobTitle(job: BackgroundWorkItem, view: ActivityView): string {
const m = job.metadata
const recordedHere = job.workspaceId === view.workspaceId
switch (job.kind) {
case 'fork_content_copy':
// A partner-recorded copy row is either this workspace's own creation (recorded
// on the parent, carrying our id as the child) or a sync's resource fill.
if (!recordedHere && m?.childWorkspaceId === view.workspaceId) {
return `Forked from "${partnerName(job, view)}"`
}
return m?.childWorkspaceName
? `Forked into "${m.childWorkspaceName}"`
: (job.message ?? 'Fork')
case 'fork_sync':
if (!recordedHere) {
return m?.direction === 'pull'
? `Pulled by "${partnerName(job, view)}"`
: `Received push from "${partnerName(job, view)}"`
}
if (!m?.otherWorkspaceName) return job.message ?? 'Sync'
return m.direction === 'pull'
? `Pulled from "${m.otherWorkspaceName}"`
: `Pushed to "${m.otherWorkspaceName}"`
case 'fork_rollback':
if (!recordedHere) return `Sync undone in "${partnerName(job, view)}"`
return m?.otherWorkspaceName
? `Undid sync from "${m.otherWorkspaceName}"`
: (job.message ?? 'Rollback')
default:
return job.message ?? 'Activity'
}
}
/** Short action label for the Event badge, per job kind. */
function jobEventLabel(job: BackgroundWorkItem): string {
switch (job.kind) {
case 'fork_content_copy':
return 'Fork'
case 'fork_sync':
return job.metadata?.direction === 'pull' ? 'Pull' : 'Push'
case 'fork_rollback':
return 'Rollback'
default:
return 'Activity'
}
}
/**
* Badge variant: bad outcomes keep the status colors (red/amber), while successful
* rows are colored by operation so Fork / Push / Pull / Rollback are distinguishable
* at a glance.
*/
function jobBadgeVariant(job: BackgroundWorkItem) {
if (job.status === 'failed') return 'red' as const
if (job.status === 'completed_with_warnings') return 'amber' as const
if (job.status !== 'completed') return 'gray-secondary' as const
switch (job.kind) {
case 'fork_content_copy':
return 'blue' as const
case 'fork_sync':
return job.metadata?.direction === 'pull' ? ('cyan' as const) : ('green' as const)
case 'fork_rollback':
return 'purple' as const
default:
return 'gray-secondary' as const
}
}
/** Build a job's report (named groups + plain notes) from its metadata. */
function jobReport(job: BackgroundWorkItem): JobReport {
const m = job.metadata
const groups: ReportGroup[] = []
const notes: JobReport['notes'] = []
if (!m) return { groups, notes }
const addGroup = (label: string, names: string[] | undefined) => {
if (names && names.length > 0) groups.push({ label, names })
}
if (job.kind === 'fork_sync') {
addGroup('Updated', m.updatedNames)
addGroup('Created', m.createdNames)
addGroup('Archived', m.archivedNames)
// Pre-names entries fall back to the count summary (redeployed mirrors updated).
if (groups.length === 0) {
const counts = countList([
[m.updated, 'updated'],
[m.created, 'created'],
[m.archived, 'archived'],
])
if (counts) notes.push({ value: counts })
}
if (m.needsConfiguration && m.needsConfiguration.length > 0) {
for (const item of m.needsConfiguration) {
notes.push({
value: `${item.workflowName} — re-check ${item.blocks.join(', ')}`,
warning: true,
})
}
}
if (m.clearedOptional && m.clearedOptional.length > 0) {
for (const item of m.clearedOptional) {
notes.push({
value: `${item.workflowName} — optional cleared in ${item.blocks.join(', ')}`,
})
}
}
if (m.deployFailed && m.deployFailed > 0) {
notes.push({ value: `${plural(m.deployFailed, 'workflow')} failed to deploy`, warning: true })
}
return { groups, notes }
}
if (job.kind === 'fork_rollback') {
const counts = countList([
[m.restored, 'restored'],
[m.unarchived, 'unarchived'],
[m.removed, 'removed'],
[m.skipped, 'skipped'],
])
if (counts) notes.push({ value: counts })
return { groups, notes }
}
// fork_content_copy: a named breakdown of everything copied, by kind.
addGroup('Workflows', m.workflowNames)
addGroup('Knowledge bases', m.knowledgeBaseNames)
addGroup('Tables', m.tableNames)
addGroup('Files', m.fileNames)
addGroup('Custom tools', m.customToolNames)
addGroup('Skills', m.skillNames)
addGroup('MCP servers', m.mcpServerNames)
addGroup('Workflow MCP servers', m.workflowMcpServerNames)
// Sync content-copy rows record per-kind COUNTS only (fork rows carry names), so fall back
// to the counts when no named group rendered.
if (groups.length === 0) {
const counts = [
[m.workflowsCopied, 'workflow'],
[m.knowledgeBases, 'knowledge base'],
[m.tables, 'table'],
[m.files, 'file'],
]
.filter(([n]) => ((n as number | undefined) ?? 0) > 0)
.map(([n, noun]) => plural(n as number, noun as string))
.join(' · ')
if (counts) notes.push({ value: counts })
}
if (m.failed && m.failed > 0) {
notes.push({ value: `${plural(m.failed, 'resource')} failed to copy`, warning: true })
}
if (m.clearingFailed) {
notes.push({ value: 'Reference cleanup incomplete', warning: true })
}
return { groups, notes }
}
/** The expanded detail box content for one job: named groups, notes, and any error. */
function jobDetails(job: BackgroundWorkItem, report: JobReport) {
return (
<>
{report.groups.map((group) => (
<div key={group.label} className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>{group.label}</span>
<span className='min-w-0 flex-1 text-[var(--text-primary)]'>
{group.names.join(', ')}
</span>
</div>
))}
{report.notes.map((note, index) => (
<span
key={`${index}:${note.value}`}
className={note.warning ? 'text-[var(--text-error)]' : 'text-[var(--text-secondary)]'}
>
{note.value}
</span>
))}
{job.error ? <span className='text-[var(--text-error)]'>{job.error}</span> : null}
</>
)
}
/** Maps a background job to the shared {@link ActivityLog} row shape. */
function toActivityEntry(job: BackgroundWorkItem, view: ActivityView): ActivityLogEntry {
const report = jobReport(job)
const hasDetails = report.groups.length > 0 || report.notes.length > 0 || Boolean(job.error)
return {
id: job.id,
timestamp: formatDateTime(new Date(job.startedAt)),
event: (
<Badge variant={jobBadgeVariant(job)} size='sm' className='shrink-0'>
{jobEventLabel(job)}
</Badge>
),
description: jobTitle(job, view),
actor: job.metadata?.actorName || 'System',
details: hasDetails ? jobDetails(job, report) : undefined,
}
}
interface ForkActivityPanelProps {
/** Poll the durable fork-job audit trail involving this workspace. */
workspaceId: string
/** Lineage partner names by id (the parent + forks), for phrasing partner-recorded rows. */
workspaceNames: ReadonlyMap<string, string>
}
/**
* A durable audit log of every fork, sync, and rollback involving the workspace
* (both sides of each fork edge), rendered through the shared {@link ActivityLog}
* so it reads identically to the enterprise audit log: each row (timestamp, action
* badge, description, actor) expands to a per-kind breakdown of what changed.
*/
export function ForkActivityPanel({ workspaceId, workspaceNames }: ForkActivityPanelProps) {
const { data, isPending, isError, hasNextPage, fetchNextPage, isFetchingNextPage } =
useWorkspaceBackgroundWork(workspaceId)
const view: ActivityView = { workspaceId, workspaceNames }
const jobs = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) => page.items)
}, [data])
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage().catch((error: unknown) => {
logger.error('Failed to load more fork activity', { error })
})
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<ActivityLog
entries={jobs.map((job) => toActivityEntry(job, view))}
eventColumn='compact'
// A failed or still-loading feed must never claim "nothing here yet".
emptyState={
isError ? (
<SettingsEmptyState variant='inline'>
Couldn't load activity. Try again shortly.
</SettingsEmptyState>
) : isPending ? undefined : (
<SettingsEmptyState variant='inline'>
Nothing here yet. Forks, syncs, and rollbacks will appear here.
</SettingsEmptyState>
)
}
footer={
hasNextPage ? (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
) : undefined
}
/>
)
}
@@ -0,0 +1,28 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { groupForkFilesIntoFolders } from '@/ee/workspace-forking/components/fork-file-tree/fork-file-tree'
describe('groupForkFilesIntoFolders', () => {
it('groups files under their folder and lifts un-foldered files to the root bucket', () => {
const { folders, rootFiles } = groupForkFilesIntoFolders([
{ id: 'f1', label: 'b.png', folderId: 'fld-1', folderName: 'Images' },
{ id: 'f2', label: 'a.png', folderId: 'fld-1', folderName: 'Images' },
{ id: 'f3', label: 'root.txt', folderId: null, folderName: null },
{ id: 'f4', label: 'doc.pdf', folderId: 'fld-2', folderName: 'Docs' },
])
// Folders are sorted by name; each folder's files are sorted by label.
expect(folders.map((folder) => folder.name)).toEqual(['Docs', 'Images'])
expect(folders[1].files.map((file) => file.label)).toEqual(['a.png', 'b.png'])
expect(rootFiles.map((file) => file.label)).toEqual(['root.txt'])
})
it('treats a file whose folder was deleted (id set, name null) as a root file', () => {
const { folders, rootFiles } = groupForkFilesIntoFolders([
{ id: 'f1', label: 'orphan.png', folderId: 'fld-deleted', folderName: null },
])
expect(folders).toEqual([])
expect(rootFiles.map((file) => file.id)).toEqual(['f1'])
})
})
@@ -0,0 +1,197 @@
'use client'
import { useId, useState } from 'react'
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
export interface ForkFileTreeItem {
id: string
label: string
}
export interface ForkFileTreeFolder {
id: string
name: string
files: ForkFileTreeItem[]
}
/** A flat copyable file with its folder grouping, before {@link groupForkFilesIntoFolders}. */
export interface ForkFlatFile {
id: string
label: string
folderId: string | null
folderName: string | null
}
/**
* Group flat files into folders (sorted by name, each file sorted by label) plus a root bucket
* for files with no folder. A file whose folder id is set but whose name is null (its folder was
* deleted) falls into the root bucket, so it stays selectable rather than hiding under a phantom.
*/
export function groupForkFilesIntoFolders(files: ForkFlatFile[]): {
folders: ForkFileTreeFolder[]
rootFiles: ForkFileTreeItem[]
} {
const folderById = new Map<string, ForkFileTreeFolder>()
const rootFiles: ForkFileTreeItem[] = []
for (const file of files) {
const item: ForkFileTreeItem = { id: file.id, label: file.label }
if (file.folderId && file.folderName) {
let folder = folderById.get(file.folderId)
if (!folder) {
folder = { id: file.folderId, name: file.folderName, files: [] }
folderById.set(file.folderId, folder)
}
folder.files.push(item)
} else {
rootFiles.push(item)
}
}
const folders = Array.from(folderById.values()).sort((a, b) => a.name.localeCompare(b.name))
for (const folder of folders) folder.files.sort((a, b) => a.label.localeCompare(b.label))
rootFiles.sort((a, b) => a.label.localeCompare(b.label))
return { folders, rootFiles }
}
interface ForkFileTreeProps {
folders: ForkFileTreeFolder[]
rootFiles: ForkFileTreeItem[]
isSelected: (fileId: string) => boolean
onToggleFile: (fileId: string, checked: boolean) => void
/** Toggle every file in a folder at once (the folder-level select-all). */
onToggleMany: (fileIds: string[], checked: boolean) => void
disabled?: boolean
}
/**
* Folder ▸ file collapsible tree shared by the fork picker and the sync copy selector, so both
* surfaces group files identically. Each folder is a tri-state select-all header that expands to
* its files; files with no folder render at the top level. Files are the only copyable kind that
* nests - tables, knowledge bases, custom tools, and skills stay flat at the top level.
*/
export function ForkFileTree({
folders,
rootFiles,
isSelected,
onToggleFile,
onToggleMany,
disabled = false,
}: ForkFileTreeProps) {
return (
<div className='flex flex-col gap-1'>
{folders.map((folder) => (
<ForkFileFolderRow
key={folder.id}
folder={folder}
isSelected={isSelected}
onToggleFile={onToggleFile}
onToggleMany={onToggleMany}
disabled={disabled}
/>
))}
{rootFiles.map((file) => (
<ForkFileRow
key={file.id}
file={file}
checked={isSelected(file.id)}
onToggle={onToggleFile}
disabled={disabled}
/>
))}
</div>
)
}
interface ForkFileFolderRowProps {
folder: ForkFileTreeFolder
isSelected: (fileId: string) => boolean
onToggleFile: (fileId: string, checked: boolean) => void
onToggleMany: (fileIds: string[], checked: boolean) => void
disabled: boolean
}
function ForkFileFolderRow({
folder,
isSelected,
onToggleFile,
onToggleMany,
disabled,
}: ForkFileFolderRowProps) {
const [expanded, setExpanded] = useState(false)
const fileIds = folder.files.map((file) => file.id)
const total = fileIds.length
const selectedCount = fileIds.filter(isSelected).length
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Checkbox
size='sm'
aria-label={`Copy all in ${folder.name}`}
checked={headerState}
onCheckedChange={() => onToggleMany(fileIds, headerState !== true)}
disabled={disabled}
/>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{folder.name} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
</button>
</div>
{expanded ? (
<div className='ml-6 flex flex-col gap-0.5'>
{folder.files.map((file) => (
<ForkFileRow
key={file.id}
file={file}
checked={isSelected(file.id)}
onToggle={onToggleFile}
disabled={disabled}
/>
))}
</div>
) : null}
</div>
)
}
interface ForkFileRowProps {
file: ForkFileTreeItem
checked: boolean
onToggle: (fileId: string, checked: boolean) => void
disabled: boolean
}
function ForkFileRow({ file, checked, onToggle, disabled }: ForkFileRowProps) {
const itemId = useId()
return (
<label
htmlFor={itemId}
className={cn(
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:text-[var(--text-primary)]'
)}
>
<Checkbox
id={itemId}
size='sm'
checked={checked}
onCheckedChange={(value) => onToggle(file.id, value === true)}
disabled={disabled}
/>
<span className='min-w-0 flex-1 truncate'>{file.label}</span>
</label>
)
}
@@ -0,0 +1,193 @@
'use client'
import { useId, useMemo, useState } from 'react'
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
import {
ForkFileTree,
type ForkFlatFile,
groupForkFilesIntoFolders,
} from '@/ee/workspace-forking/components/fork-file-tree/fork-file-tree'
/** A flat copyable resource (table / KB / custom tool / skill / MCP server) in the picker. */
export interface ForkResourcePickerItem {
id: string
label: string
}
interface ResourceKindRowProps {
label: string
items: ForkResourcePickerItem[]
selected: Set<string>
/** Toggle the given ids on/off. Used by the select-all header checkbox. */
onToggleMany: (ids: string[], checked: boolean) => void
onToggleItem: (id: string, checked: boolean) => void
disabled?: boolean
}
/**
* One expandable resource kind in the fork / sync copy picker: a tri-state "select all" header
* (count of selected / total) plus, when expanded, a scrollable list of individual resources so
* the user can copy a specific subset. Shared by the fork modal's "Copy resources" and the sync
* modal's "Copy resources" so the two surfaces stay identical. Files nest in a folder tree
* instead - use {@link FileKindRow}.
*
* Expanded body uses the settings MCP expand pattern (`border-t` + `--surface-2`) so items
* read as contained in the kind rather than indented siblings of other kinds.
*/
export function ResourceKindRow({
label,
items,
selected,
onToggleMany,
onToggleItem,
disabled = false,
}: ResourceKindRowProps) {
const [expanded, setExpanded] = useState(false)
const fieldId = useId()
const total = items.length
const selectedCount = items.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0)
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
return (
<div className='flex flex-col'>
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Checkbox
size='sm'
aria-label={`Copy all ${label}`}
checked={headerState}
onCheckedChange={() =>
onToggleMany(
items.map((item) => item.id),
headerState !== true
)
}
disabled={disabled}
/>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
</button>
</div>
{expanded ? (
<div className='mt-1 max-h-44 overflow-y-auto border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
<div className='flex flex-col gap-0.5'>
{items.map((item) => {
const isChecked = selected.has(item.id)
const itemId = `${fieldId}-${item.id}`
return (
<label
key={item.id}
htmlFor={itemId}
className={cn(
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:text-[var(--text-primary)]'
)}
>
<Checkbox
id={itemId}
size='sm'
checked={isChecked}
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
disabled={disabled}
/>
<span className='truncate'>{item.label}</span>
</label>
)
})}
</div>
</div>
) : null}
</div>
)
}
interface FileKindRowProps {
label: string
files: ForkFlatFile[]
selected: Set<string>
onToggleAll: (selectAll: boolean) => void
onToggleItem: (id: string, checked: boolean) => void
onToggleMany: (ids: string[], checked: boolean) => void
disabled?: boolean
}
/**
* The Files kind: a tri-state "select all" header (count selected / total) that expands to a
* folder ▸ file tree, so the user can copy a whole folder or a specific file. Files are the only
* copyable kind that nests; every other kind uses the flat {@link ResourceKindRow}. Shared by the
* fork and sync copy pickers so both group files identically.
*/
export function FileKindRow({
label,
files,
selected,
onToggleAll,
onToggleItem,
onToggleMany,
disabled = false,
}: FileKindRowProps) {
const [expanded, setExpanded] = useState(false)
const total = files.length
const selectedCount = files.filter((file) => selected.has(file.id)).length
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
const { folders, rootFiles } = useMemo(() => groupForkFilesIntoFolders(files), [files])
return (
<div className='flex flex-col'>
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Checkbox
size='sm'
aria-label={`Copy all ${label}`}
checked={headerState}
onCheckedChange={() => onToggleAll(headerState !== true)}
disabled={disabled}
/>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
</button>
</div>
{expanded ? (
<div className='mt-1 border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
<ForkFileTree
folders={folders}
rootFiles={rootFiles}
isSelected={(id) => selected.has(id)}
onToggleFile={onToggleItem}
onToggleMany={onToggleMany}
disabled={disabled}
/>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,169 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import {
forkBlockerResolution,
selectVisibleClearedRefs,
splitForkClearedRefs,
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
type ReferenceRef = Extract<ForkClearedRef, { cause: 'reference' }>
type WorkflowRef = Extract<ForkClearedRef, { cause: 'workflow' }>
type DependentRef = Extract<ForkClearedRef, { cause: 'dependent' }>
const base = {
targetWorkflowId: 'wf-tgt',
workflowName: 'Workflow',
blockId: 'block-1',
blockLabel: 'Block',
sourceLabel: 'Source',
}
const referenceRef = (
kind: ReferenceRef['kind'],
sourceId: string,
fieldLabel = 'Field',
sourceDeleted = false
): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId, sourceDeleted })
const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({
...base,
fieldLabel,
cause: 'workflow',
kind: 'workflow',
sourceId,
})
const dependentRef = (
parentKind: DependentRef['parentKind'],
parentSourceId: string,
fieldLabel = 'Field'
): DependentRef => ({
...base,
fieldLabel,
cause: 'dependent',
kind: parentKind,
sourceId: parentSourceId,
parentKind,
parentSourceId,
})
// The page's predicate is `mapped || copied`; here we model each disposition as a resolved key so
// the document-under-KB case is exercised for both a copied parent and a mapped parent.
const resolvedKeys = (...keys: string[]) => {
const set = new Set(keys)
return (kind: string, sourceId: string) => set.has(`${kind}:${sourceId}`)
}
const documentDependent = dependentRef('knowledge-base', 'kb-1', 'Document')
describe('selectVisibleClearedRefs', () => {
it('drops a document dependent when its parent KB is selected for copy', () => {
expect(
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
).toEqual([])
})
it('drops a document dependent when its parent KB is mapped', () => {
// Map vs copy both resolve the parent through the same predicate; modeled identically here.
expect(
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
).toEqual([])
})
it('keeps a document dependent while its parent KB is neither mapped nor copied', () => {
expect(selectVisibleClearedRefs([documentDependent], resolvedKeys())).toEqual([
documentDependent,
])
})
it('keeps a credential-anchored dependent even when the credential is mapped (label still clears)', () => {
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
// A mapped credential remaps to a different account, so the account-scoped label is cleared
// regardless - the entry must stay even though the parent is "resolved".
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys('credential:cred-1'))).toEqual([
labelDependent,
])
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys())).toEqual([labelDependent])
})
it('keeps a table-anchored dependent even when the table is copied/mapped (column still clears)', () => {
const columnDependent = dependentRef('table', 'tbl-1', 'Column')
expect(selectVisibleClearedRefs([columnDependent], resolvedKeys('table:tbl-1'))).toEqual([
columnDependent,
])
})
it('drops the parent KB reference AND its child document together when the KB is resolved', () => {
const kbReference = referenceRef('knowledge-base', 'kb-1', 'Knowledge Base')
expect(
selectVisibleClearedRefs(
[kbReference, documentDependent],
resolvedKeys('knowledge-base:kb-1')
)
).toEqual([])
})
it('applies the same predicate to a reference entry (drops resolved, keeps unresolved)', () => {
const credentialReference = referenceRef('credential', 'cred-1')
expect(
selectVisibleClearedRefs([credentialReference], resolvedKeys('credential:cred-1'))
).toEqual([])
expect(selectVisibleClearedRefs([credentialReference], resolvedKeys())).toEqual([
credentialReference,
])
})
it('always keeps a workflow reference (it cannot be resolved on the page)', () => {
const workflowReference = workflowRef('wf-other')
expect(
selectVisibleClearedRefs([workflowReference], resolvedKeys('workflow:wf-other'))
).toEqual([workflowReference])
})
})
describe('splitForkClearedRefs', () => {
it('splits reference/workflow causes into blockers and dependents into informational', () => {
const tableReference = referenceRef('table', 'tbl-1')
const workflowReference = workflowRef('wf-other')
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
const { blockers, informational } = splitForkClearedRefs([
tableReference,
workflowReference,
labelDependent,
])
expect(blockers).toEqual([tableReference, workflowReference])
expect(informational).toEqual([labelDependent])
})
it('treats an unmapped MCP server and a source-deleted reference as blockers', () => {
const mcpReference = referenceRef('mcp-server', 'srv-1')
const deletedReference = referenceRef('skill', 'sk-gone', 'Skill', true)
const { blockers, informational } = splitForkClearedRefs([mcpReference, deletedReference])
expect(blockers).toEqual([mcpReference, deletedReference])
expect(informational).toEqual([])
})
})
describe('forkBlockerResolution', () => {
it('phrases each blocker reason with its actionable resolution', () => {
expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe(
'map it to a target or select it for copy'
)
expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe(
'map it to a target or select it for copy'
)
expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe(
'deleted in the source — map it to an existing knowledge base in the target'
)
expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe(
'deploy "Source" in the source or remove the reference'
)
})
it('returns null for non-blocking dependent entries', () => {
expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull()
})
})
@@ -0,0 +1,86 @@
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers'
/** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */
export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean
/**
* Parent kinds whose dependent child is itself a resource carried alongside the parent, so
* resolving the parent (mapping or copying it) PRESERVES the child rather than clearing it -
* currently just knowledge bases: a referenced document is copied with its KB, or auto-copied into
* a mapped KB, and remapped in place. Any other parent's child (a credential's label, a table's
* column) is account/table-scoped and cleared whenever the parent is remapped (the engine's
* `clearDependentsOnRemap`), so those entries stay regardless of the parent's disposition.
*/
const PARENT_KINDS_THAT_PRESERVE_CHILD: ReadonlySet<string> = new Set(['knowledge-base'])
/**
* Narrow the diff's cleared-ref candidates to those still cleared under the live selection:
* - `reference`: drops off once its own resource is resolved (mapped or copied).
* - `dependent`: drops off once its PARENT resource (`parentKind`/`parentSourceId`) is resolved -
* using the SAME predicate as `reference` - but ONLY when the child follows that parent (a
* document under a KB). A credential- or table-anchored dependent is cleared on any parent remap,
* so it stays even after the parent is mapped.
* - `workflow`: always stays - a cross-workflow reference cannot be resolved here.
*
* Pure so the reactive list is unit-testable independent of the page's selection state.
*/
export function selectVisibleClearedRefs(
clearedRefs: ForkClearedRef[],
isResolved: ClearedRefResolvedPredicate
): ForkClearedRef[] {
return clearedRefs.filter((ref) => {
if (ref.cause === 'reference') return !isResolved(ref.kind, ref.sourceId)
// The discriminated union guarantees `parentKind`/`parentSourceId` on a `dependent` variant.
if (ref.cause === 'dependent' && PARENT_KINDS_THAT_PRESERVE_CHILD.has(ref.parentKind)) {
return !isResolved(ref.parentKind, ref.parentSourceId)
}
return true
})
}
/**
* Split the visible would-clear entries into sync BLOCKERS (cause `reference`/`workflow` - the
* sync is disabled while any remain) and the informational remainder (`dependent` entries, owned
* by the reconfigure flow - they clear but never block). Pure, so the page's gate and the two
* sections stay one testable rule.
*/
export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
blockers: ForkClearedRef[]
informational: ForkClearedRef[]
} {
const blockers: ForkClearedRef[] = []
const informational: ForkClearedRef[] = []
for (const ref of visibleRefs) {
if (forkSyncBlockerReasonFor(ref)) blockers.push(ref)
else informational.push(ref)
}
return { blockers, informational }
}
/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */
const BLOCKER_KIND_LABEL: Record<string, string> = {
table: 'table',
'knowledge-base': 'knowledge base',
file: 'file',
'custom-tool': 'custom tool',
skill: 'skill',
'mcp-server': 'MCP server',
}
/**
* The actionable resolution line for a blocking entry, phrased for "{block} would lose {field}
* in {workflow} - {resolution}". Null for non-blocking (dependent) entries.
*/
export function forkBlockerResolution(ref: ForkClearedRef): string | null {
const reason = forkSyncBlockerReasonFor(ref)
if (!reason) return null
switch (reason) {
case 'unmapped-copyable':
return 'map it to a target or select it for copy'
case 'source-deleted':
return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
case 'workflow-missing':
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
}
}
@@ -0,0 +1,225 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import {
effectiveForkTarget,
forkCopyingKeys,
forkDefaultCopySelection,
forkMappedCopyableKeys,
forkParentResolution,
forkRefKey,
forkRequiredKindsLabel,
forkRequiredPending,
forkVisibleCopyables,
isForkRequiredComplete,
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
const entry = (overrides: Partial<ForkMappingEntry>): ForkMappingEntry => ({
kind: 'credential',
resourceType: 'oauth_credential',
sourceId: 'src',
sourceLabel: 'Src',
targetId: null,
suggested: false,
required: false,
candidates: [],
candidatesTruncated: false,
...overrides,
})
const copyable = (overrides: Partial<ForkCopyableUnmapped>): ForkCopyableUnmapped => ({
kind: 'knowledge-base',
sourceId: 'kb',
label: 'KB',
parentId: null,
parentLabel: null,
referenced: true,
...overrides,
})
describe('forkRefKey / effectiveForkTarget', () => {
it('shares the kind:sourceId keyspace for entries and copyables', () => {
expect(forkRefKey({ kind: 'knowledge-base', sourceId: 'kb-1' })).toBe('knowledge-base:kb-1')
expect(forkRefKey(entry({ kind: 'table', sourceId: 't-1' }))).toBe('table:t-1')
})
it('prefers an in-session override, else the persisted target, else empty', () => {
const e = entry({ kind: 'table', sourceId: 't-1', targetId: 'persisted' })
expect(effectiveForkTarget(e, { 'table:t-1': 'in-session' })).toBe('in-session')
expect(effectiveForkTarget(e, {})).toBe('persisted')
expect(effectiveForkTarget(entry({ kind: 'table', sourceId: 't-1' }), {})).toBe('')
})
})
describe('copy-vs-map reconciliation', () => {
it('drops a mapped copyable from the visible copy list (maps win over copy)', () => {
const entries = [
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
entry({ kind: 'table', sourceId: 'tbl-1' }),
]
const candidates = [
copyable({ kind: 'knowledge-base', sourceId: 'kb-1' }),
copyable({ kind: 'table', sourceId: 'tbl-1' }),
]
const mapped = forkMappedCopyableKeys(entries, {})
expect(mapped.has('knowledge-base:kb-1')).toBe(true)
expect(mapped.has('table:tbl-1')).toBe(false)
expect(forkVisibleCopyables(candidates, mapped).map(forkRefKey)).toEqual(['table:tbl-1'])
})
it('an in-session mapping target also drops the copyable', () => {
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1' })]
const mapped = forkMappedCopyableKeys(entries, { 'knowledge-base:kb-1': 'kb-tgt' })
expect(
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mapped)
).toEqual([])
})
it('copyingKeys = the visible candidates that are selected for copy', () => {
const visible = [
copyable({ kind: 'table', sourceId: 'tbl-1' }),
copyable({ kind: 'skill', sourceId: 'sk-1' }),
]
expect([...forkCopyingKeys(visible, new Set(['table:tbl-1']))]).toEqual(['table:tbl-1'])
})
})
describe('forkDefaultCopySelection', () => {
it('seeds every referenced candidate and leaves unreferenced ones unselected', () => {
const selection = forkDefaultCopySelection([
copyable({ kind: 'knowledge-base', sourceId: 'kb-1', referenced: true }),
copyable({ kind: 'table', sourceId: 'tbl-new', referenced: false }),
copyable({ kind: 'file', sourceId: 'workspace/SRC/new.png', referenced: false }),
])
expect(selection).toEqual(new Set(['knowledge-base:kb-1']))
})
it('seeds nothing when every candidate is unreferenced', () => {
expect(
forkDefaultCopySelection([copyable({ kind: 'skill', sourceId: 'sk-new', referenced: false })])
).toEqual(new Set())
})
})
describe('isForkRequiredComplete', () => {
it('a required ref is satisfied by a mapping target', () => {
const entries = [
entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' }),
]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
})
it('a required ref is satisfied by a copy selection (server accepts copy as resolving it)', () => {
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
expect(isForkRequiredComplete(entries, {}, new Set(['knowledge-base:kb-1']))).toBe(true)
})
it('a required ref neither mapped nor copied blocks', () => {
const entries = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
})
it('a referenced MCP server (map-only, required) blocks until mapped - copy cannot satisfy it', () => {
const entries = [
entry({ kind: 'mcp-server', resourceType: 'mcp_server', sourceId: 'srv-1', required: true }),
]
// MCP servers are never copy candidates, so the copy set can't contain them; only a
// mapping target resolves the entry.
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
expect(isForkRequiredComplete(entries, { 'mcp-server:srv-1': 'srv-tgt' }, new Set())).toBe(true)
})
it('a source-deleted referenced resource (required, no copy candidate) blocks until mapped', () => {
// A deleted source is dropped from the copy candidates (its label lookup fails), so the
// only resolution is mapping the dead id to a live target resource.
const entries = [
entry({ kind: 'table', resourceType: 'table', sourceId: 'tbl-gone', required: true }),
]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
expect(isForkRequiredComplete(entries, { 'table:tbl-gone': 'tbl-live' }, new Set())).toBe(true)
})
it('optional refs never block', () => {
const entries = [entry({ kind: 'table', sourceId: 't1', required: false })]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
})
})
describe('forkRequiredKindsLabel', () => {
it('names credentials and secrets by kind, together or alone', () => {
expect(forkRequiredKindsLabel(new Set(['credential', 'env-var']))).toBe(
'credentials and secrets'
)
expect(forkRequiredKindsLabel(new Set(['credential']))).toBe('credentials')
expect(forkRequiredKindsLabel(new Set(['env-var']))).toBe('secrets')
})
it('falls back to "references" for any other (or empty) kind set', () => {
expect(forkRequiredKindsLabel(new Set(['table']))).toBe('references')
expect(forkRequiredKindsLabel(new Set())).toBe('references')
})
})
describe('forkParentResolution', () => {
const kb = entry({ kind: 'knowledge-base', sourceId: 'kb-1' })
it('is copied when the entry is selected for copy', () => {
expect(forkParentResolution(kb, {}, new Set(['knowledge-base:kb-1']))).toBe('copied')
})
it('is mapped with a persisted or in-session target, unresolved with neither', () => {
expect(
forkParentResolution(
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
{},
new Set()
)
).toBe('mapped')
expect(forkParentResolution(kb, { 'knowledge-base:kb-1': 'kb-tgt' }, new Set())).toBe('mapped')
expect(forkParentResolution(kb, {}, new Set())).toBe('unresolved')
})
it('toggling copy⇄map flips the resolution (the selector scope + seed follow it)', () => {
// Copy-selected: the dependent selectors browse the SOURCE parent.
const copying = new Set(['knowledge-base:kb-1'])
expect(forkParentResolution(kb, {}, copying)).toBe('copied')
// The user maps a target instead: the mapped entry drops out of the visible copyables
// (copy-vs-map reconciliation), so its copying key disappears and the resolution flips.
const targets = { 'knowledge-base:kb-1': 'kb-tgt' }
const mappedKeys = forkMappedCopyableKeys([kb], targets)
const copyingAfterMap = forkCopyingKeys(
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mappedKeys),
copying
)
expect(forkParentResolution(kb, targets, copyingAfterMap)).toBe('mapped')
// Back to copy ('' target override): the copyable is visible + still selected again.
const cleared = { 'knowledge-base:kb-1': '' }
const copyingAfterClear = forkCopyingKeys(
forkVisibleCopyables(
[copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })],
forkMappedCopyableKeys([kb], cleared)
),
copying
)
expect(forkParentResolution(kb, cleared, copyingAfterClear)).toBe('copied')
})
})
describe('forkRequiredPending', () => {
it('is true when a required ref is neither mapped nor selected for copy', () => {
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
expect(forkRequiredPending(items, {}, new Set())).toBe(true)
})
it('is false when the required ref is selected for copy', () => {
const items = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
expect(forkRequiredPending(items, {}, new Set(['knowledge-base:kb-1']))).toBe(false)
})
it('is false when the required ref is mapped', () => {
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' })]
expect(forkRequiredPending(items, {}, new Set())).toBe(false)
})
})
@@ -0,0 +1,136 @@
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
/** `${kind}:${sourceId}` - the shared key for a mapping entry and its copy candidate. */
export const forkRefKey = (ref: { kind: string; sourceId: string }): string =>
`${ref.kind}:${ref.sourceId}`
/** Effective mapping target: the in-session override, else the persisted target, else ''. */
export function effectiveForkTarget(
entry: ForkMappingEntry,
targets: Record<string, string>
): string {
return targets[forkRefKey(entry)] ?? entry.targetId ?? ''
}
/**
* Keys of copyable resources that already have a mapping target (in-session or persisted). Maps win
* over copy, so these drop out of the copy list - the copy-vs-map reconciliation.
*/
export function forkMappedCopyableKeys(
entries: ForkMappingEntry[],
targets: Record<string, string>
): Set<string> {
const keys = new Set<string>()
for (const entry of entries) {
if (effectiveForkTarget(entry, targets) !== '') keys.add(forkRefKey(entry))
}
return keys
}
/** Copy candidates the user has not mapped (a mapped copyable is excluded - copy-vs-map reconcile). */
export function forkVisibleCopyables(
copyableUnmapped: ForkCopyableUnmapped[],
mappedKeys: ReadonlySet<string>
): ForkCopyableUnmapped[] {
return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate)))
}
/**
* The copy selection seeded once the diff settles: every REFERENCED candidate (deselecting one
* clears its references, so the common case needs no clicks). Unreferenced candidates - used by
* no synced workflow - start unselected: copying them is opt-in, so scratch data created in the
* source is never pushed by surprise.
*/
export function forkDefaultCopySelection(copyableUnmapped: ForkCopyableUnmapped[]): Set<string> {
const keys = new Set<string>()
for (const candidate of copyableUnmapped) {
if (candidate.referenced) keys.add(forkRefKey(candidate))
}
return keys
}
/** Keys of the visible copy candidates actually selected for copy. */
export function forkCopyingKeys(
visibleCopyables: ForkCopyableUnmapped[],
copySelected: ReadonlySet<string>
): Set<string> {
const keys = new Set<string>()
for (const candidate of visibleCopyables) {
const key = forkRefKey(candidate)
if (copySelected.has(key)) keys.add(key)
}
return keys
}
/**
* How a mapping entry is resolved under the live selection, for its dependents' behavior:
* - `copied`: selected for copy - dependents stay editable against the SOURCE parent (the copy
* will contain the source's children), seeded from the source reference.
* - `mapped`: has an effective target - dependents re-pick against the TARGET parent.
* - `unresolved`: neither - dependents are disabled; the parent's own gate owns the block.
* Mapped and copied are mutually exclusive by construction (a mapped copyable is excluded from
* the copy candidates), so the branch order is not load-bearing.
*/
export type ForkParentResolution = 'mapped' | 'copied' | 'unresolved'
export function forkParentResolution(
entry: ForkMappingEntry,
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
): ForkParentResolution {
if (copyingKeys.has(forkRefKey(entry))) return 'copied'
return effectiveForkTarget(entry, targets) !== '' ? 'mapped' : 'unresolved'
}
/**
* Whether every required reference is satisfied - it has a mapping target OR is selected for copy.
* The server accepts a copy as resolving a required ref (promote.ts `willResolve`), so the client
* gate must too. No double-count: a mapped copyable is excluded from the copy candidates, so the two
* branches are mutually exclusive.
*/
export function isForkRequiredComplete(
entries: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
): boolean {
return entries.every(
(entry) =>
!entry.required ||
effectiveForkTarget(entry, targets) !== '' ||
copyingKeys.has(forkRefKey(entry))
)
}
/**
* Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives
* the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule.
*/
export function forkRequiredPending(
items: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
): boolean {
return items.some(
(entry) =>
entry.required &&
effectiveForkTarget(entry, targets) === '' &&
!copyingKeys.has(forkRefKey(entry))
)
}
/**
* Human label for the kinds still failing the required gate, for "Map all required {label} first"
* messaging - shared by the Sync button's disabled tooltip (client gate) and the server gate's
* failure toast so both name the obstacle identically. Credentials and secrets are named
* explicitly: they are the map-only kinds that fail the required gate WITHOUT also appearing in
* the cleared-ref blockers (the collector excludes them), so they need their own wording. Any
* other kind falls back to "references".
*/
export function forkRequiredKindsLabel(kinds: ReadonlySet<string>): string {
const credentials = kinds.has('credential')
const secrets = kinds.has('env-var')
if (credentials && secrets) return 'credentials and secrets'
if (credentials) return 'credentials'
if (secrets) return 'secrets'
return 'references'
}
@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
interface DependentFieldSelectorProps {
selectorKey: SelectorKey
/** Full selector context, including the newly-chosen parent value. */
context: Record<string, string>
/** False until the parent (credential/KB) target is chosen. */
enabled: boolean
value: string
onChange: (value: string) => void
title: string
}
/**
* A controlled, standalone selector for the sync page's pre-sync reconfigure: fetches
* options via the shared selector data layer (the same `useSelectorOptions` registry the
* canvas selectors use) without the canvas store/blockId coupling. Mirrors
* {@link ConnectorSelectorField}.
*/
export function DependentFieldSelector({
selectorKey,
context,
enabled,
value,
onChange,
title,
}: DependentFieldSelectorProps) {
const selectorContext = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
Object.assign(ctx, context)
return ctx
}, [context])
const { data: options = [], isLoading } = useSelectorOptions(selectorKey, {
context: selectorContext,
enabled,
})
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((option) => ({ label: option.label, value: option.id })),
[options]
)
if (isLoading && enabled) {
return (
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-medium text-[var(--text-muted)] text-small dark:bg-[var(--surface-4)]'>
<Loader className='size-3.5' animate />
Loading
</div>
)
}
return (
<ChipCombobox
className='w-full'
options={comboboxOptions}
value={value || undefined}
onChange={(next) => onChange(next)}
searchable
searchPlaceholder={`Search ${title.toLowerCase()}...`}
placeholder={`Select ${title.toLowerCase()}`}
disabled={!enabled}
emptyMessage={`No ${title.toLowerCase()} found`}
/>
)
}
@@ -0,0 +1,99 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
import {
dependentKey,
effectiveCopyDependentValue,
effectiveDependentValue,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
const field = (overrides: Partial<ForkDependentReconfig> = {}): ForkDependentReconfig => ({
parentKind: 'credential',
parentSourceId: 'cred-1',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-1',
targetBlockId: 'blk-1',
blockName: 'Gmail',
subBlockKey: 'folder',
selectorKey: 'gmail.labels',
title: 'Label',
currentValue: 'INBOX',
required: false,
consumesContextKeys: [],
context: {},
sourceValue: '',
...overrides,
})
describe('dependentKey', () => {
it('keys by target workflow + block + subblock', () => {
expect(
dependentKey(field({ targetWorkflowId: 'w', targetBlockId: 'b', subBlockKey: 's' }))
).toBe('w:b:s')
})
})
describe('effectiveDependentValue', () => {
it('returns the in-session re-pick when present', () => {
const f = field()
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_42' }, false)).toBe('Label_42')
})
it('returns the stored currentValue when no re-pick and the parent is unchanged', () => {
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, false)).toBe('INBOX')
})
it('returns blank when the parent changed (the stored value no longer resolves)', () => {
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, true)).toBe('')
})
it('an in-session re-pick wins even when the parent changed', () => {
const f = field({ currentValue: 'INBOX' })
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_99' }, true)).toBe('Label_99')
})
it('an explicit empty re-pick is respected (not treated as absent)', () => {
const f = field({ currentValue: 'INBOX' })
expect(effectiveDependentValue(f, { [dependentKey(f)]: '' }, false)).toBe('')
})
})
describe('effectiveCopyDependentValue', () => {
const copyField = (overrides: Partial<ForkDependentReconfig> = {}) =>
field({
parentKind: 'knowledge-base',
parentSourceId: 'kb-src',
parentContextKey: 'knowledgeBaseId',
subBlockKey: 'documentSelector',
selectorKey: 'knowledge.documents',
title: 'Document',
...overrides,
})
it('seeds from the raw source reference when nothing is stored (the copy will contain it)', () => {
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, {})).toBe('doc-src')
})
it('prefers the stored value over the source reference (a saved re-pick survives reload)', () => {
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, {})).toBe('doc-saved')
})
it('an in-session re-pick wins over both', () => {
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: 'doc-picked' })).toBe('doc-picked')
})
it('an explicit empty re-pick is respected (a required field then gates as usual)', () => {
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: '' })).toBe('')
})
it('is blank when the source never referenced anything and nothing was stored', () => {
const f = copyField({ currentValue: '', sourceValue: '' })
expect(effectiveCopyDependentValue(f, {})).toBe('')
})
})
@@ -0,0 +1,39 @@
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
export function dependentKey(dependent: ForkDependentReconfig): string {
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
}
/**
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
* stored value was for the previous parent and won't resolve against the new one. Shared by the
* sync gate + payload build and the per-block selector so the rule can't drift between them.
*/
export function effectiveDependentValue(
field: ForkDependentReconfig,
reconfig: Record<string, string>,
parentChanged: boolean
): string {
const repicked = reconfig[dependentKey(field)]
if (repicked !== undefined) return repicked
return parentChanged ? '' : field.currentValue
}
/**
* The value sent + displayed for a dependent whose parent is resolved by COPY: the user's
* in-session re-pick, else the stored value, else the field's raw SOURCE reference. The copy
* brings the source parent's children along (a copied KB carries its referenced documents), so
* the source reference is exactly what the copied parent will contain - the selector browses the
* SOURCE parent and this seed resolves there. An explicit empty re-pick is respected (it gates a
* required field as usual).
*/
export function effectiveCopyDependentValue(
field: ForkDependentReconfig,
reconfig: Record<string, string>
): string {
const repicked = reconfig[dependentKey(field)]
if (repicked !== undefined) return repicked
return field.currentValue || field.sourceValue
}
@@ -0,0 +1,747 @@
'use client'
import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from 'react'
import {
Badge,
ChevronDown,
ChipCombobox,
ChipSwitch,
CollapsibleCard,
cn,
FieldDivider,
Label,
} from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import type {
ForkCopyableUnmapped,
ForkDependentReconfig,
ForkMappingEntry,
ForkResourceUsage,
} from '@/lib/api/contracts/workspace-fork'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
FileKindRow,
ResourceKindRow,
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
import { forkBlockerResolution } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
import {
dependentKey,
effectiveCopyDependentValue,
effectiveDependentValue,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
import type {
ForkKindSummary,
ForkMappingGroup,
ForkSyncController,
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork'
import type { SelectorKey } from '@/hooks/selectors/types'
/**
* Copyable kinds as expandable rows in the "Copy resources" section, ordered + labeled to match
* the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other kind
* is a flat list.
*/
const COPYABLE_KIND_SECTIONS: ReadonlyArray<{
kind: ForkCopyableUnmapped['kind']
label: string
}> = [
{ kind: 'file', label: 'Files' },
{ kind: 'table', label: 'Tables' },
{ kind: 'knowledge-base', label: 'Knowledge bases' },
{ kind: 'custom-tool', label: 'Custom tools' },
{ kind: 'skill', label: 'Skills' },
{ kind: 'mcp-server', label: 'MCP servers' },
]
/**
* Sentinel option value for the "New copy" entry - the displayed resolution while a copyable
* is copy-selected, and the way back to the copy flow after mapping. Handled via onSelect,
* never sent.
*/
const NEW_COPY_VALUE = '__new_copy__'
/** Fixed target-picker width so every mapping row's control lines up as one column (mirrors General). */
const MAPPING_TARGET_TRIGGER_CLASS = 'w-[240px] flex-shrink-0'
interface DependentBlock {
targetBlockId: string
blockName: string
fields: ForkDependentReconfig[]
}
interface WorkflowDependents {
workflowId: string
workflowName: string
blocks: DependentBlock[]
}
/**
* Bucket an entry's dependents per workflow, then per block within it - the
* workflow → block hierarchy the workflow cards render from.
*/
function groupDependentsByWorkflow(
workflows: ForkResourceUsage['workflows'],
dependents: ForkDependentReconfig[]
): WorkflowDependents[] {
const byWorkflow = new Map<string, ForkDependentReconfig[]>()
for (const dependent of dependents) {
const list = byWorkflow.get(dependent.targetWorkflowId)
if (list) list.push(dependent)
else byWorkflow.set(dependent.targetWorkflowId, [dependent])
}
return workflows.map((workflow) => {
const byBlock = new Map<string, DependentBlock>()
for (const field of byWorkflow.get(workflow.workflowId) ?? []) {
let block = byBlock.get(field.targetBlockId)
if (!block) {
block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
byBlock.set(field.targetBlockId, block)
}
block.fields.push(field)
}
return {
workflowId: workflow.workflowId,
workflowName: workflow.workflowName,
blocks: Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName)),
}
})
}
/** Chain state for one block: the SelectorContext values its parent fields provide. */
function blockChainState(
block: DependentBlock,
effectiveValue: (field: ForkDependentReconfig) => string
) {
const providedValues: Record<string, string> = {}
const providedContextKeys = new Set<string>()
for (const field of block.fields) {
if (field.providesContextKey) {
providedContextKeys.add(field.providesContextKey)
const value = effectiveValue(field)
if (value) providedValues[field.providesContextKey] = value
}
}
return { providedValues, providedContextKeys }
}
/** Store a re-pick and invalidate in-block children chained off the changed field. */
function applyDependentRepick(
setReconfig: Dispatch<SetStateAction<Record<string, string>>>,
field: ForkDependentReconfig,
blockFields: ForkDependentReconfig[],
value: string
) {
setReconfig((prev) => {
const nextState = { ...prev, [dependentKey(field)]: value }
// A changed parent invalidates its children's stale re-picks.
const providedKey = field.providesContextKey
if (providedKey) {
for (const sibling of blockFields) {
if (sibling.consumesContextKeys.includes(providedKey)) {
delete nextState[dependentKey(sibling)]
}
}
}
return nextState
})
}
interface DependentSelectorProps {
field: ForkDependentReconfig
block: DependentBlock
target: string
parentChanged: boolean
/** True when the parent is resolved by COPY: browse the SOURCE parent, seeded from the source. */
copying: boolean
workspaceId: string
sourceWorkspaceId: string
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
}
/**
* One depends-on field's selector. Under a MAPPED parent it browses the TARGET parent
* (pre-filled from the stored value, blank after a parent change) and is disabled until the
* parent target is set. Under a COPY-resolved parent it browses the SOURCE parent (the copy
* will contain exactly those children), pre-filled with the source reference. Either way it
* stays disabled until every chained in-block parent has a value, and a re-pick invalidates
* chained children.
*/
function DependentSelector({
field,
block,
target,
parentChanged,
copying,
workspaceId,
sourceWorkspaceId,
reconfig,
setReconfig,
}: DependentSelectorProps) {
const effectiveValue = (f: ForkDependentReconfig) =>
copying
? effectiveCopyDependentValue(f, reconfig)
: effectiveDependentValue(f, reconfig, parentChanged)
const { providedValues, providedContextKeys } = blockChainState(block, effectiveValue)
// Disabled until every in-block parent it depends on has a value, so a child never queries
// a stale upstream value.
const ready = field.consumesContextKeys.every(
(key) => !providedContextKeys.has(key) || providedValues[key] !== undefined
)
// A copy-resolved parent has no target id until the sync runs - scope to the SOURCE parent
// instead (its children are what the copy brings), keeping the selector fully editable.
const parentValue = copying ? field.parentSourceId : target
return (
<DependentFieldSelector
selectorKey={field.selectorKey as SelectorKey}
context={{
...field.context,
...providedValues,
// Owning workspace, for workspace-scoped selectors like table.columns.
workspaceId: copying ? sourceWorkspaceId : workspaceId,
[field.parentContextKey]: parentValue,
}}
enabled={parentValue !== '' && ready}
value={effectiveValue(field)}
onChange={(value) => applyDependentRepick(setReconfig, field, block.fields, value)}
title={field.title}
/>
)
}
interface DependentWorkflowCardProps {
workflow: WorkflowDependents
target: string
parentChanged: boolean
/** True when the parent is resolved by COPY - the selectors browse the SOURCE parent. */
copying: boolean
workspaceId: string
sourceWorkspaceId: string
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
}
/**
* One workflow's dependent fields as a collapsible card (the same `CollapsibleCard` the table
* workflow sidebar's input mapping and the enrichment config use): the header names the
* workflow; the body groups fields under block → optional tool → plain field label.
* Cards holding a required field start expanded - a required field is what gates Sync.
*/
function DependentWorkflowCard({
workflow,
target,
parentChanged,
copying,
workspaceId,
sourceWorkspaceId,
reconfig,
setReconfig,
}: DependentWorkflowCardProps) {
const [collapsed, setCollapsed] = useState(
() => !workflow.blocks.some((block) => block.fields.some((field) => field.required))
)
return (
<CollapsibleCard
title={workflow.workflowName}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed((value) => !value)}
>
<div className='flex flex-col gap-3'>
{workflow.blocks.map((block) => {
const topLevel = block.fields.filter((field) => !field.toolName)
const byTool = new Map<string, ForkDependentReconfig[]>()
for (const field of block.fields) {
if (!field.toolName) continue
const list = byTool.get(field.toolName)
if (list) list.push(field)
else byTool.set(field.toolName, [field])
}
const toolGroups = Array.from(byTool.entries()).sort(([a], [b]) => a.localeCompare(b))
return (
<div key={block.targetBlockId} className='flex flex-col gap-2'>
<Label className='text-small'>{block.blockName}</Label>
{topLevel.map((field) => (
<div key={dependentKey(field)} className='flex flex-col gap-1'>
<Label className='text-[var(--text-muted)] text-caption'>
{field.title}
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
</Label>
<DependentSelector
field={field}
block={block}
target={target}
parentChanged={parentChanged}
copying={copying}
workspaceId={workspaceId}
sourceWorkspaceId={sourceWorkspaceId}
reconfig={reconfig}
setReconfig={setReconfig}
/>
</div>
))}
{toolGroups.map(([toolName, fields]) => (
<div key={toolName} className='flex flex-col gap-1.5 pl-2'>
<span className='text-[var(--text-muted)] text-small'>{toolName}</span>
{fields.map((field) => (
<div key={dependentKey(field)} className='flex flex-col gap-1'>
<Label className='text-[var(--text-muted)] text-caption'>
{field.title}
{field.required ? (
<span className='text-[var(--text-error)]'> *</span>
) : null}
</Label>
<DependentSelector
field={field}
block={block}
target={target}
parentChanged={parentChanged}
copying={copying}
workspaceId={workspaceId}
sourceWorkspaceId={sourceWorkspaceId}
reconfig={reconfig}
setReconfig={setReconfig}
/>
</div>
))}
</div>
))}
</div>
)
})}
</div>
</CollapsibleCard>
)
}
interface MappingEntryProps {
controller: ForkSyncController
group: ForkMappingGroup
entry: ForkMappingEntry
}
/**
* One mapping entry: the source ↔ target picker row (with a "Copy instead" entry for copy
* candidates and per-source taken-target disabling on push), then one collapsible card per
* workflow the resource is used in, holding that workflow's dependent field selectors.
* Workflows with nothing to configure are named in a muted note so the usage stays visible.
*/
function MappingEntry({ controller, group, entry }: MappingEntryProps) {
const target = controller.targetFor(entry)
const takenOwners = controller.takenOwnersFor(entry, group.items)
const parentChanged = controller.parentChangedFor(entry)
const entryRefKey = forkRefKey(entry)
const copying = controller.copyingKeys.has(entryRefKey)
const usages = controller.usagesForEntry(entry)
const dependents = controller.dependentsForEntry(entry)
// Group once per (usages, dependents) change - both keep stable references from the
// controller's memoized maps, so this skips recompute across the page's frequent re-renders.
const workflows = useMemo(
() => groupDependentsByWorkflow(usages, dependents),
[usages, dependents]
)
const configurable = workflows.filter((workflow) => workflow.blocks.length > 0)
const usedOnly = workflows.filter((workflow) => workflow.blocks.length === 0)
return (
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<div className='flex items-center justify-between gap-4'>
<Label className='min-w-0 truncate'>{entry.sourceLabel}</Label>
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
<ChipCombobox
className='w-full'
align='start'
options={[
// While copy-resolved, the closed control shows the copy by NAME (the copy
// keeps the source's name) via a hidden display-only option; the list itself
// stays unambiguous.
...(controller.copyableKeys.has(entryRefKey) && copying
? [{ label: entry.sourceLabel, value: NEW_COPY_VALUE, hidden: true }]
: []),
// The way back to the copy flow after mapping - clears the target via onSelect.
...(controller.copyableKeys.has(entryRefKey) && target !== ''
? [
{
label: 'New copy',
value: NEW_COPY_VALUE,
onSelect: () => controller.setTarget(entry, ''),
},
]
: []),
...entry.candidates.map((candidate) => {
const owner = takenOwners.get(candidate.id)
return {
label: owner ? `${candidate.label} · mapped to ${owner}` : candidate.label,
value: candidate.id,
disabled: owner !== undefined,
}
}),
]}
value={copying ? NEW_COPY_VALUE : target || undefined}
onChange={(value) => controller.setTarget(entry, value)}
placeholder='Select target'
/>
</div>
</div>
{entry.candidatesTruncated ? (
<p className='text-[var(--text-muted)] text-small'>
More options than shown search by name.
</p>
) : null}
</div>
{configurable.map((workflow) => (
<DependentWorkflowCard
key={workflow.workflowId}
workflow={workflow}
target={target}
parentChanged={parentChanged}
copying={copying}
workspaceId={controller.targetWorkspaceId}
sourceWorkspaceId={controller.sourceWorkspaceId}
reconfig={controller.reconfig}
setReconfig={controller.setReconfig}
/>
))}
{usedOnly.length > 0 ? (
<p className='text-[var(--text-tertiary)] text-caption'>
Also used in {usedOnly.map((workflow) => workflow.workflowName).join(', ')} nothing to
configure there.
</p>
) : null}
</div>
)
}
/** Badge copy + color for one kind's mapping status (shared badge rules with the old summary). */
function kindStatusBadge(summary: ForkKindSummary): {
label: string
variant: 'green' | 'amber' | 'gray-secondary'
} {
const { total, mapped, copied, requiredPending, reconfigPending } = summary
const resolved = mapped + copied
const complete = resolved === total && !reconfigPending
const label = complete
? mapped === total
? 'Fully mapped'
: copied === total
? 'Copied'
: 'Mapped & copied'
: reconfigPending && resolved === total
? 'Needs setup'
: copied > 0
? `${resolved}/${total} ready`
: `${mapped}/${total} mapped`
const variant = complete
? 'green'
: requiredPending || reconfigPending
? 'amber'
: 'gray-secondary'
return { label, variant }
}
interface MappingKindRowProps {
controller: ForkSyncController
group: ForkMappingGroup
summary: ForkKindSummary
}
/**
* One resource kind in the Mappings section: a chevron header row with the kind's status badge
* (the summary IS the entry), expanding to that kind's mapping entries. Mirrors the expandable
* kind rows of the Copy resources section so the two sections share one interaction rhythm.
*/
function MappingKindRow({ controller, group, summary }: MappingKindRowProps) {
const [open, setOpen] = useState(false)
const badge = kindStatusBadge(summary)
return (
<div className='flex flex-col'>
<button
type='button'
onClick={() => setOpen((value) => !value)}
className='flex w-full items-center gap-2 text-left text-[var(--text-body)] text-sm transition-colors hover:text-[var(--text-primary)]'
>
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
<Badge variant={badge.variant} size='sm' dot>
{badge.label}
</Badge>
<ChevronDown
className={cn(
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
open && 'rotate-180'
)}
/>
</button>
{open ? (
<div className='flex flex-col pt-3 pb-1'>
{group.items.map((entry, index) => (
<Fragment key={forkRefKey(entry)}>
{index > 0 ? <FieldDivider /> : null}
<MappingEntry controller={controller} group={group} entry={entry} />
</Fragment>
))}
</div>
) : null}
</div>
)
}
interface CopyKindSectionsProps {
controller: ForkSyncController
byKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
}
/**
* One expandable row per copyable kind present in `byKind` - shared by the referenced group
* and the unreferenced "Not used by any workflow" group so both render exactly like the fork
* picker (files as a folder tree, every other kind flat).
*/
function CopyKindSections({ controller, byKind }: CopyKindSectionsProps) {
return (
<>
{COPYABLE_KIND_SECTIONS.map((section) => {
const candidates = byKind.get(section.kind)
if (!candidates || candidates.length === 0) return null
// The picker rows track item ids; copy selection is keyed `${kind}:${id}`
// (matching `forkRefKey`), so derive the per-kind selected-id subset and
// re-prefix on toggle.
const selectedIds = new Set(
candidates
.filter((candidate) => controller.copySelected.has(forkRefKey(candidate)))
.map((candidate) => candidate.sourceId)
)
const toggleMany = (ids: string[], checked: boolean) =>
controller.toggleCopyKeys(
ids.map((id) => `${section.kind}:${id}`),
checked
)
const toggleAll = (selectAll: boolean) =>
toggleMany(
candidates.map((candidate) => candidate.sourceId),
selectAll
)
return section.kind === 'file' ? (
<FileKindRow
key={section.kind}
label={section.label}
files={candidates.map((candidate) => ({
id: candidate.sourceId,
label: candidate.label,
folderId: candidate.parentId,
folderName: candidate.parentLabel,
}))}
selected={selectedIds}
onToggleAll={toggleAll}
onToggleItem={(id, checked) => toggleMany([id], checked)}
onToggleMany={toggleMany}
disabled={controller.submitting}
/>
) : (
<ResourceKindRow
key={section.kind}
label={section.label}
items={candidates.map((candidate) => ({
id: candidate.sourceId,
label: candidate.label,
}))}
selected={selectedIds}
onToggleMany={toggleMany}
onToggleItem={(id, checked) => toggleMany([id], checked)}
disabled={controller.submitting}
/>
)
})}
</>
)
}
interface ForkSyncViewProps {
controller: ForkSyncController
onDirectionChange: (direction: ForkDirection) => void
}
/**
* The parent fork edge's sync experience as page sections: pick a direction, review the
* deployed-workflow changes, resolve the per-kind mappings (each kind an expandable row whose
* status badge doubles as the summary), choose which unmapped resources to copy, and clear any
* blocking references. The page header's Sync action commits it (after the overwrite confirm).
*/
export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProps) {
const detailsError = controller.errorMessage ?? controller.diffErrorMessage
const headsUp = controller.mcpReauthCount > 0 || controller.inlineSecretCount > 0
return (
<div className='flex flex-col gap-7'>
<SettingsSection label='Sync direction'>
<div className='flex flex-col gap-2'>
<ChipSwitch
value={controller.direction}
onChange={onDirectionChange}
aria-label='Sync direction'
options={[
{ value: 'push', label: 'Push' },
{ value: 'pull', label: 'Pull' },
]}
/>
<p className='text-[var(--text-muted)] text-caption'>
{controller.direction === 'push'
? `Push this workspace's deployed workflows to "${controller.otherWorkspaceName}", overwriting it.`
: `Pull deployed workflows from "${controller.otherWorkspaceName}", overwriting this workspace.`}
</p>
</div>
</SettingsSection>
{/* Surface a failed/pending fetch so the page never renders blank below the direction. */}
{detailsError ? (
<SettingsSection label='Sync details'>
<div className='text-[var(--text-error)] text-small'>{detailsError}</div>
</SettingsSection>
) : !controller.hasDiff ? (
<div className='text-[var(--text-muted)] text-small'>Loading sync details</div>
) : null}
{/* Always shown once the diff loads so the user sees the section even with nothing
deployed - an empty change list means the source has no deployed workflows (every
deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
{controller.hasDiff ? (
<SettingsSection label='Deployed workflows'>
{controller.workflowChanges.length > 0 ? (
<div className='flex flex-col gap-1'>
{controller.workflowChanges.map((change, index) => {
const renamed = change.currentName !== change.otherName
return (
<div
key={`${change.action}:${change.currentName}:${index}`}
className='flex min-w-0 items-center gap-1.5'
>
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
{change.currentName}
</span>
{renamed ? (
<>
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
{change.otherName}
</span>
</>
) : null}
</div>
)
})}
</div>
) : (
<div className='text-[var(--text-muted)] text-small'>
{controller.direction === 'push'
? `No deployed workflows. Deploy workflows to push changes to ${controller.otherWorkspaceName}.`
: `No deployed workflows in ${controller.otherWorkspaceName} to pull.`}
</div>
)}
</SettingsSection>
) : null}
{headsUp ? (
<SettingsSection label='Heads up'>
{controller.mcpReauthCount > 0 ? (
<div className='text-[var(--text-muted)] text-small'>
{controller.mcpReauthCount} MCP server(s) use OAuth and must be re-authorized in the
target workspace.
</div>
) : null}
{controller.inlineSecretCount > 0 ? (
<div className='mt-1 text-[var(--text-muted)] text-small'>
{controller.inlineSecretCount} inline secret(s) can't be auto-mapped — set them in the
target workspace.
</div>
) : null}
</SettingsSection>
) : null}
{controller.hasMapping ? (
<SettingsSection label='Mappings'>
{controller.groups.length > 0 ? (
<div className='flex flex-col gap-2'>
{controller.groups.map((group) => {
const summary = controller.kindSummaries.find((item) => item.kind === group.kind)
if (!summary) return null
return (
<MappingKindRow
key={group.kind}
controller={controller}
group={group}
summary={summary}
/>
)
})}
</div>
) : (
<SettingsEmptyState variant='inline'>
This workspace's deployed workflows have no mappable references.
</SettingsEmptyState>
)}
</SettingsSection>
) : null}
{controller.hasVisibleCopyables ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
{controller.referencedByKind.size > 0 ? (
<CopyKindSections controller={controller} byKind={controller.referencedByKind} />
) : null}
{controller.unreferencedByKind.size > 0 ? (
<>
{controller.referencedByKind.size > 0 ? (
<div className='mt-2 text-[var(--text-muted)] text-caption'>
Not used by any workflow
</div>
) : null}
<CopyKindSections controller={controller} byKind={controller.unreferencedByKind} />
</>
) : null}
</div>
</SettingsSection>
) : null}
{controller.blockingRefs.length > 0 ? (
<SettingsSection label='Blocking sync'>
<div className='flex flex-col gap-1'>
{controller.blockingRefs.map((ref, index) => (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='min-w-0 text-[var(--text-secondary)] text-small'
>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> would lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName} {forkBlockerResolution(ref)}
</div>
))}
</div>
</SettingsSection>
) : null}
{controller.dependentClears.length > 0 ? (
<SettingsSection label='Will be cleared'>
<div className='flex flex-col gap-1'>
{controller.dependentClears.map((ref, index) => (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='min-w-0 text-[var(--text-secondary)] text-small'
>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> will lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName}
</div>
))}
</div>
<p className='text-[var(--text-muted)] text-caption'>
Re-pick these in the target after the sync.
</p>
</SettingsSection>
) : null}
</div>
)
}
@@ -0,0 +1,810 @@
'use client'
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react'
import { toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import type {
ForkClearedRef,
ForkCopyableUnmapped,
ForkDependentReconfig,
ForkMappingEntry,
ForkResourceUsage,
ForkWorkflowChange,
} from '@/lib/api/contracts/workspace-fork'
import {
selectVisibleClearedRefs,
splitForkClearedRefs,
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import {
effectiveForkTarget,
type ForkParentResolution,
forkCopyingKeys,
forkDefaultCopySelection,
forkMappedCopyableKeys,
forkParentResolution,
forkRefKey,
forkRequiredKindsLabel,
forkRequiredPending,
forkVisibleCopyables,
isForkRequiredComplete,
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
import {
dependentKey,
effectiveCopyDependentValue,
effectiveDependentValue,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
import {
type ForkDirection,
useForkDiff,
useForkMapping,
usePromoteFork,
useUpdateForkMapping,
} from '@/ee/workspace-forking/hooks/workspace-fork'
/**
* The mapping kinds that can be a standalone mapping entry. `knowledge-document` is excluded:
* the mapping view (`getForkMappingView`) skips documents — they ride their parent KB via the
* reconfigure flow — so a `knowledge-document` mapping section is never reachable.
*/
export type MappableMappingKind = Exclude<ForkMappingEntry['kind'], 'knowledge-document'>
/** Section label + display order per mapping kind (one mapping group per kind). */
const MAPPING_SECTION: Record<MappableMappingKind, { label: string; order: number }> = {
credential: { label: 'Credentials', order: 0 },
'env-var': { label: 'Secrets', order: 1 },
table: { label: 'Tables', order: 2 },
'knowledge-base': { label: 'Knowledge bases', order: 3 },
file: { label: 'Files', order: 4 },
'mcp-server': { label: 'MCP servers', order: 5 },
'custom-tool': { label: 'Custom tools', order: 6 },
skill: { label: 'Skills', order: 7 },
}
/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
const EMPTY_TARGET_OWNERS: ReadonlyMap<string, string> = new Map()
/**
* Stable empty arrays so an entry with no usages/dependents keeps a constant prop reference,
* letting the workflow-card grouping memos skip recompute across the page's frequent re-renders.
*/
const EMPTY_USAGES: ForkResourceUsage['workflows'] = []
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
/** Target workflows this sync archives, previewed in the confirm before "and X more". */
export const ARCHIVED_PREVIEW_LIMIT = 5
export interface ForkMappingGroup {
kind: MappableMappingKind
label: string
items: ForkMappingEntry[]
}
/** Per-kind mapping status for the Mappings section's summary badges. */
export interface ForkKindSummary {
kind: MappableMappingKind
total: number
mapped: number
copied: number
requiredPending: boolean
reconfigPending: boolean
}
export interface ForkSyncController {
direction: ForkDirection
otherWorkspaceName: string
isLoading: boolean
isError: boolean
errorMessage: string | null
/** Diff fetch failure, surfaced inline (the mapping may still have loaded). */
diffErrorMessage: string | null
/** True once the diff payload for ANY direction is present (placeholder included). */
hasDiff: boolean
/** True once the mapping payload is present (placeholder included), gating the Mappings section. */
hasMapping: boolean
groups: ForkMappingGroup[]
/** Per-kind mapping status, aligned with `groups` (same kinds, same order). */
kindSummaries: ForkKindSummary[]
/** Effective (in-session override, else persisted/suggested) target for an entry. */
targetFor: (entry: ForkMappingEntry) => string
/** Set an entry's target ('' clears it) and drop its dependents' stale re-picks. */
setTarget: (entry: ForkMappingEntry, value: string) => void
/** Targets already claimed by another source in the same kind (push targets are unique; pull never disables). */
takenOwnersFor: (
entry: ForkMappingEntry,
items: ForkMappingEntry[]
) => ReadonlyMap<string, string>
/** Every workflow an entry's resource is used in (diff-fed; empty until the diff loads). */
usagesForEntry: (entry: ForkMappingEntry) => ForkResourceUsage['workflows']
/** An entry's dependent fields (its credential/KB/table's selectors), from the diff. */
dependentsForEntry: (entry: ForkMappingEntry) => ForkDependentReconfig[]
/**
* Whether an entry needs an in-place reconfigure: its effective target changed in-session,
* or it's an unconfirmed suggestion (accepting it as-is still remaps + clears the dependents).
*/
parentChangedFor: (entry: ForkMappingEntry) => boolean
/** The workspace a MAPPED parent's dependent selectors query against (direction-aware from the diff). */
targetWorkspaceId: string
/**
* The sync's source workspace (push: this one; pull: the other), which a COPY-resolved
* parent's dependent selectors browse - the copy will contain the source parent's children,
* so the source is the truthful catalog to pick from.
*/
sourceWorkspaceId: string
/** In-session dependent re-picks, keyed by `dependentKey`. */
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
/** Keys the backend offers as copy candidates, for the entry rows' "Copy instead" affordance. */
copyableKeys: ReadonlySet<string>
/** Copyables actually selected for copy (visible + checked), keyed `${kind}:${sourceId}`. */
copyingKeys: ReadonlySet<string>
/** The raw copy selection (visible-ness not applied), for per-kind selected-id derivation. */
copySelected: ReadonlySet<string>
toggleCopyKeys: (keys: string[], checked: boolean) => void
/** Visible copy candidates split by referenced-ness, grouped per kind for the section rows. */
referencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
unreferencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
hasVisibleCopyables: boolean
/** Would-clear references that BLOCK sync (mirrors the server's zero-cleared-refs gate). */
blockingRefs: ForkClearedRef[]
/** Informational would-clear dependents (owned by the reconfigure flow; never block). */
dependentClears: ForkClearedRef[]
/** Deployed-workflow change list (update → create → archive, then by name). */
workflowChanges: ForkWorkflowChange[]
/** Names of target workflows this sync archives, for the confirm modal. */
archivedWorkflowNames: string[]
mcpReauthCount: number
inlineSecretCount: number
dirty: boolean
saving: boolean
save: () => void
discard: () => void
submitting: boolean
syncDisabled: boolean
/** Names the failing gate for the disabled Sync chip's tooltip; undefined when enabled. */
syncDisabledReason: string | undefined
/** Persist the effective mapping + dependents + copy selection, then promote. */
sync: () => Promise<void>
}
const entryKey = (entry: ForkMappingEntry) => forkRefKey(entry)
/**
* Whether a mapping entry needs an in-place reconfigure: its effective target was changed
* in-session, or it's an unconfirmed suggestion (accepting it as-is still remaps + clears
* the dependents). Pure over (entry, in-session targets) so the inline render, the Sync
* gate, and the payload build share one predicate instead of drifting copies.
*/
function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record<string, string>): boolean {
const next = targets[entryKey(entry)] ?? entry.targetId ?? ''
if (next === '') return false
return entry.suggested || next !== (entry.targetId ?? '')
}
/**
* Targets already taken by OTHER sources in the same kind, each mapped to the owning
* source's label (for a hint). Used to disable those targets on PUSH: a push row is unique
* on the parent (target) side, so a parent target can back only one source - a second source
* picking it would be silently dropped on save. Pull is the inverse (many parent sources may
* share one fork target, which resolves correctly), so pull passes the empty map and never
* disables. Excludes `exclude` so a source never disables its own current selection.
*/
function takenTargetOwners(
items: ForkMappingEntry[],
targets: Record<string, string>,
exclude: ForkMappingEntry
): Map<string, string> {
const owners = new Map<string, string>()
for (const item of items) {
if (entryKey(item) === entryKey(exclude)) continue
const target = targets[entryKey(item)] ?? item.targetId ?? ''
if (target !== '') owners.set(target, item.sourceLabel)
}
return owners
}
/**
* The full sync surface's state for one fork edge, in the chosen direction: the editable
* resource mapping (in-session target overrides + dependent re-picks, persisted via Save or
* as part of Sync), the copy-resources selection (seeded once the diff settles), the reactive
* would-clear blockers, the per-kind status summaries, the Sync gate, and the promote run
* itself. A direction switch drops every in-session choice — the mapping set, copy candidates,
* and blockers all depend on the direction — and the copy selection re-seeds only from a
* settled (non-placeholder) diff so a stale payload can't latch wrong keys.
*/
export function useForkSync(params: {
workspaceId: string
otherWorkspaceId?: string
otherWorkspaceName: string
direction: ForkDirection
enabled: boolean
}): ForkSyncController {
const { workspaceId, otherWorkspaceId, otherWorkspaceName, direction, enabled } = params
// User's IN-SESSION mapping overrides only - NOT the source of truth. The displayed/persisted
// target falls back to each entry's stored `targetId` (see `targetFor`), so a reopened edge
// shows its remembered mappings even though React Query's structural sharing keeps `entries`
// referentially stable (a target-seeding effect gated on `entries` would never re-run there).
const [targets, setTargets] = useState<Record<string, string>>({})
// In-session re-picks for dependent fields whose parent the user swapped, keyed by
// `dependentKey`. Folded into the full effective set sent on save/sync, which the server
// persists as the stored mapping - so the selection survives every future sync without
// re-picking.
const [reconfig, setReconfig] = useState<Record<string, string>>({})
// Referenced-but-unmapped resources the user chose to copy into the target (keyed by
// `${kind}:${sourceId}`); default-selected once the diff loads. Selected ones are copied on
// sync so their references resolve to the copy instead of being cleared.
const [copySelected, setCopySelected] = useState<Set<string>>(new Set())
const [copyDefaulted, setCopyDefaulted] = useState(false)
const [submitting, setSubmitting] = useState(false)
// Drop every in-session choice when the direction (or edge) changes - the mapping set,
// copy candidates, and blockers all depend on it.
useEffect(() => {
setTargets({})
setReconfig({})
setCopySelected(new Set())
setCopyDefaulted(false)
}, [direction, otherWorkspaceId])
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled })
const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled })
const updateMapping = useUpdateForkMapping()
const promote = usePromoteFork()
const entries = useMemo<ForkMappingEntry[]>(() => mapping.data?.entries ?? [], [mapping.data])
const dependentReconfigs = useMemo(
() => diff.data?.dependentReconfigs ?? [],
[diff.data?.dependentReconfigs]
)
const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages])
const copyableUnmapped = useMemo(
() => diff.data?.copyableUnmapped ?? [],
[diff.data?.copyableUnmapped]
)
const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs])
// Keys the backend offers as copy candidates, so the entry rows show a "Copy instead"
// affordance only for those - clearing a name-match suggestion returns the ref to the copy
// list (it re-enters `visibleCopyables` once its effective target is '').
const copyableKeys = useMemo(
() => new Set(copyableUnmapped.map((candidate) => forkRefKey(candidate))),
[copyableUnmapped]
)
// Copy-vs-map reconciliation: a copyable resource the user has given an effective (in-session
// or persisted) mapping target must NOT also appear in the copy list - the user picked map, not
// copy. `forkRefKey` shares the `${kind}:${sourceId}` keyspace across entries and candidates,
// so a mapped entry's key directly excludes its copy candidate. The server enforces the same
// precedence: a mapped resource resolves != null, so it never reaches the plan's
// `copyableUnmapped`, and a copy request for it is dropped by `buildPromoteCopySelection`.
const mappedCopyableKeys = useMemo(
() => forkMappedCopyableKeys(entries, targets),
[entries, targets]
)
const visibleCopyables = useMemo(
() => forkVisibleCopyables(copyableUnmapped, mappedCopyableKeys),
[copyableUnmapped, mappedCopyableKeys]
)
// Copyables actually selected for copy (visible + checked), keyed for an O(1) lookup so a
// copyable mapping entry can show a "will be copied" note.
const copyingKeys = useMemo(
() => forkCopyingKeys(visibleCopyables, copySelected),
[visibleCopyables, copySelected]
)
// Group the visible copy candidates by kind so each renders as its own expandable section
// (chevron + tri-state select-all + count), matching the fork picker. Referenced and
// unreferenced candidates group separately: unreferenced ones (used by no synced workflow)
// render under a muted "Not used by any workflow" grouping and default to unselected.
const { referencedByKind, unreferencedByKind } = useMemo(() => {
const referenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
const unreferenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
for (const candidate of visibleCopyables) {
const groups = candidate.referenced ? referenced : unreferenced
const list = groups.get(candidate.kind)
if (list) list.push(candidate)
else groups.set(candidate.kind, [candidate])
}
return { referencedByKind: referenced, unreferencedByKind: unreferenced }
}, [visibleCopyables])
// Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case
// (bring the referenced resources along) needs no clicks; the user can deselect to clear
// instead. Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying
// them is opt-in since nothing references them. Seed ONLY from a settled diff for the current
// direction: on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps
// the previous direction's payload (placeholderData) until the new fetch resolves - seeding
// from it would latch against stale keys and leave the real copyables unchecked, clearing
// their references on Sync.
useEffect(() => {
if (!enabled || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return
setCopyDefaulted(true)
setCopySelected(forkDefaultCopySelection(copyableUnmapped))
}, [enabled, diff.isPlaceholderData, copyableUnmapped, copyDefaulted])
// Group dependents by their parent (kind:sourceId) once, so each mapping entry gets a
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat the
// workflow-card grouping memos.
const dependentsByParent = useMemo(() => {
const map = new Map<string, ForkDependentReconfig[]>()
for (const dependent of dependentReconfigs) {
const key = `${dependent.parentKind}:${dependent.parentSourceId}`
const list = map.get(key)
if (list) list.push(dependent)
else map.set(key, [dependent])
}
return map
}, [dependentReconfigs])
// Effective target for an entry: the user's in-session override if present, else the
// persisted mapping from the server. Read directly from `entries` so a reopened edge
// reflects stored mappings without a seeding effect.
const targetFor = (entry: ForkMappingEntry) => effectiveForkTarget(entry, targets)
const usagesForEntry = (entry: ForkMappingEntry): ForkResourceUsage['workflows'] =>
resourceUsages.find(
(usage) => usage.parentKind === entry.kind && usage.parentSourceId === entry.sourceId
)?.workflows ?? EMPTY_USAGES
const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
const parentChangedFor = (entry: ForkMappingEntry): boolean =>
shouldReconfigureEntry(entry, targets)
// Set an entry's in-session mapping target. A `value` of '' explicitly clears it, overriding
// any name-match suggestion (effectiveForkTarget's `??` treats '' as present, so the suggestion
// no longer wins) - so the resource re-enters `visibleCopyables` and is copy-selectable again.
// Changing the parent invalidates its dependents' in-session re-picks (chosen against the old
// account), so drop them.
const setTarget = (entry: ForkMappingEntry, value: string) => {
setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
setReconfig((prev) => {
let changed = false
const next = { ...prev }
for (const dependent of dependentsForEntry(entry)) {
const key = dependentKey(dependent)
if (key in next) {
delete next[key]
changed = true
}
}
return changed ? next : prev
})
}
const takenOwnersFor = (
entry: ForkMappingEntry,
items: ForkMappingEntry[]
): ReadonlyMap<string, string> =>
direction === 'push' ? takenTargetOwners(items, targets, entry) : EMPTY_TARGET_OWNERS
const toggleCopyKeys = (keys: string[], checked: boolean) =>
setCopySelected((prev) => {
const next = new Set(prev)
for (const key of keys) {
if (checked) next.add(key)
else next.delete(key)
}
return next
})
// Group mappings by resource type - one accordion row per kind, required types first.
const groups = useMemo<ForkMappingGroup[]>(() => {
const byKind = new Map<MappableMappingKind, ForkMappingEntry[]>()
for (const entry of entries) {
// The mapping view never emits a document entry (it rides its KB), so the group is
// unreachable - skip defensively so the narrowed `MAPPING_SECTION` lookup stays sound.
if (entry.kind === 'knowledge-document') continue
const list = byKind.get(entry.kind)
if (list) list.push(entry)
else byKind.set(entry.kind, [entry])
}
return Array.from(byKind, ([kind, items]) => ({
kind,
label: MAPPING_SECTION[kind].label,
items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)),
})).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order)
}, [entries])
// The mapping entry each dependent hangs off, indexed by `kind:sourceId` (matching `entryKey`)
// so the per-field lookups below are O(1) instead of rescanning `entries` for every dependent -
// and several times per field across the Sync gate, the value helper, and the payload build.
const entriesByParent = useMemo(() => {
const map = new Map<string, ForkMappingEntry>()
for (const entry of entries) map.set(entryKey(entry), entry)
return map
}, [entries])
const entryForDependent = (field: ForkDependentReconfig) =>
entriesByParent.get(`${field.parentKind}:${field.parentSourceId}`)
const resolutionFor = (entry: ForkMappingEntry): ForkParentResolution =>
forkParentResolution(entry, targets, copyingKeys)
// The value sent + displayed for a dependent (delegates to the shared per-resolution rule):
// the user's re-pick, else - under a MAPPED parent - the stored value (blank when the parent
// target changed in-session), or - under a COPY-resolved parent - the stored value falling
// back to the raw source reference (which the copied parent will contain). Callers that
// already resolved the parent pass both in to skip repeat lookups.
const dependentValueFor = (
field: ForkDependentReconfig,
parent = entryForDependent(field),
resolution: ForkParentResolution = parent ? resolutionFor(parent) : 'unresolved'
): string => {
if (resolution === 'copied') return effectiveCopyDependentValue(field, reconfig)
return effectiveDependentValue(
field,
reconfig,
parent ? shouldReconfigureEntry(parent, targets) : false
)
}
// A required reference is satisfied when it has a mapping target OR the user selected it for
// copy (the server accepts a copy as resolving a required ref). See `isForkRequiredComplete`.
const requiredComplete = isForkRequiredComplete(entries, targets, copyingKeys)
// Every required dependent whose parent is RESOLVED must have a value before sync. Under a
// mapped parent the user re-picks against the target; under a copy-resolved parent the field
// pre-fills with the source reference (the copy carries it), so it's satisfied out of the box
// and gates only when explicitly emptied. A dependent whose parent is unresolved can't be
// picked yet (its selector is disabled) and is gated by `requiredComplete` on the parent
// instead, so it's skipped here.
const reconfigComplete = dependentReconfigs.every((field) => {
if (!field.required) return true
const parent = entryForDependent(field)
if (!parent) return true
const resolution = resolutionFor(parent)
if (resolution === 'unresolved') return true
return dependentValueFor(field, parent, resolution) !== ''
})
// Kinds with a required DEPENDENT that still has no value (its parent is resolved): these
// block Sync via `reconfigComplete`, so the summary badge for that kind must not read
// "Fully mapped".
const reconfigPendingByKind = new Set<MappableMappingKind>()
for (const field of dependentReconfigs) {
if (!field.required) continue
const parent = entryForDependent(field)
if (!parent) continue
const resolution = resolutionFor(parent)
if (resolution === 'unresolved') continue
if (dependentValueFor(field, parent, resolution) === '') {
reconfigPendingByKind.add(parent.kind as MappableMappingKind)
}
}
// The references this sync would blank, reactively narrowed to the current selection. A
// resource is "resolved" once it has a mapping target OR is selected for copy - the same
// predicate drives a `reference` (its own resource) and a `dependent` (its PARENT resource),
// so mapping or copying a parent KB makes its child document drop off. Then split:
// `reference`/`workflow` entries are BLOCKERS (Sync stays disabled while any remain -
// mirroring the server's zero-cleared-refs gate); `dependent` entries stay informational
// (the reconfigure flow owns them).
const { blockers: blockingRefs, informational: dependentClears } = useMemo(() => {
const isResolved = (kind: string, sourceId: string) => {
const key = `${kind}:${sourceId}`
const entry = entriesByParent.get(key)
const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false
return mapped || copyingKeys.has(key)
}
return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved))
}, [clearedRefs, entriesByParent, targets, copyingKeys])
// Per-kind status for the Mappings summary: "Fully mapped" or "n/total mapped", flagged when
// a REQUIRED target is still missing (which blocks Sync). Reads the effective
// (override-or-persisted) target so it reflects both remembered mappings and in-session edits.
const kindSummaries: ForkKindSummary[] = groups.map((group) => {
const total = group.items.length
const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
// Copy-selected items are resolved too (their refs are kept), so they count toward
// completion and render as "copied" rather than unconfigured. mapped/copied are disjoint:
// a mapped copyable is excluded from the copy candidates, so copyingKeys never overlaps a
// mapped entry.
const copied = group.items.filter((entry) => copyingKeys.has(entryKey(entry))).length
// Mirror the Sync gate: a required ref selected for copy is satisfied, so it is not
// "pending".
const requiredPending = forkRequiredPending(group.items, targets, copyingKeys)
const reconfigPending = reconfigPendingByKind.has(group.kind)
return { kind: group.kind, total, mapped, copied, requiredPending, reconfigPending }
})
// Kinds whose required gate is still failing, so the Sync tooltip can name the actual
// obstacle. An unmapped credential/secret is NEVER a cleared-ref blocker (the collector
// excludes required kinds), so the required gate must not borrow the blocker message -
// it would point at a "Blocking sync" section that isn't rendered.
const pendingRequiredKinds = new Set<string>(
kindSummaries.filter((summary) => summary.requiredPending).map((summary) => summary.kind)
)
// Sync details still settling for the current direction: loading, a failed/empty mapping
// (`!mapping.data` must not read as "nothing required"), or the PREVIOUS direction's
// placeholder after a switch (syncing on it would send stale mappings/copies and clear
// references). Until `diff.data` arrives `dependentReconfigs` is empty, so `reconfigComplete`
// is vacuously true.
const dataPending =
mapping.isLoading ||
!mapping.data ||
mapping.isPlaceholderData ||
!diff.data ||
diff.isPlaceholderData
// A failed fetch also gates Sync: a failed REFETCH keeps the last successful payload in
// `data` (so `dataPending` stays false), and every gate below would be judging that stale
// snapshot while the page shows the load error.
const dataError = mapping.isError || diff.isError
// Zero-blockers invariant (mirrors the server gate): Sync stays disabled while ANY reference
// would clear in a synced target workflow. `requiredComplete` covers the mapping entries
// (credentials/secrets and unresolved resource refs); `blockingRefs` additionally covers
// workflow-to-workflow references, which have no mapping entry to resolve.
const syncBlocked = blockingRefs.length > 0
const syncDisabled =
submitting ||
!otherWorkspaceId ||
!requiredComplete ||
!reconfigComplete ||
syncBlocked ||
dataPending ||
dataError
// A load failure outranks the gates - they're computed from the stale (or absent) payload,
// so naming one would mislead. Then priority mirrors the resolution flow: clear the
// blockers, map the required resources, reconfigure their dependents - each failing gate
// names ITS obstacle (an unmapped credential/secret is a required-mapping failure, not a
// cleared-ref blocker; see `pendingRequiredKinds`).
const syncDisabledReason = dataError
? "Couldn't load sync details — reload the page to retry"
: syncBlocked
? 'Resolve every blocking reference first — map it, copy it, or fix it in the source'
: !requiredComplete
? `Map all required ${forkRequiredKindsLabel(pendingRequiredKinds)} first`
: !reconfigComplete
? 'Reconfigure all required fields first'
: dataPending
? 'Loading sync details…'
: undefined
const workflowChanges = useMemo<ForkWorkflowChange[]>(() => {
const order: Record<ForkWorkflowChange['action'], number> = { update: 0, create: 1, archive: 2 }
return [...(diff.data?.workflows ?? [])].sort(
(a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName)
)
}, [diff.data?.workflows])
// Target workflows this sync archives (their source was deleted), named in the confirm modal
// so the overwrite warning is concrete.
const archivedWorkflowNames = useMemo(
() =>
workflowChanges
.filter((change) => change.action === 'archive')
.map((change) => change.currentName),
[workflowChanges]
)
// Send the full stored mapping for every dependent whose parent is RESOLVED - mapped (its
// effective value: re-pick, stored, or blank-after-change) or copy-selected (re-pick, stored,
// or the source reference; promote translates a source document id to its copied counterpart
// at write time). The server persists this verbatim as the stored mapping; fields whose
// parent is unresolved are omitted (they can't be configured). This is the whole "what's in
// the mapping goes in" contract, shared by Save and Sync so the two persist identically.
const buildDependentValues = () =>
dependentReconfigs.flatMap((field) => {
const parent = entryForDependent(field)
if (!parent) return []
const resolution = resolutionFor(parent)
if (resolution === 'unresolved') return []
return [
{
workflowId: field.targetWorkflowId,
blockId: field.targetBlockId,
subBlockKey: field.subBlockKey,
value: dependentValueFor(field, parent, resolution),
},
]
})
const buildMappingEntries = () =>
entries.map((entry) => ({
resourceType: entry.resourceType,
sourceId: entry.sourceId,
targetId: targetFor(entry) || null,
}))
// Dirty only on a real change from the stored/suggested target - so a freshly loaded
// mapping (even with name-match suggestions shown) isn't dirty until the user edits.
const targetsDirty = useMemo(
() =>
entries.some((entry) => {
const key = entryKey(entry)
return key in targets && targets[key] !== (entry.targetId ?? '')
}),
[entries, targets]
)
// A dependent re-pick that differs from its stored value also dirties the editor. A re-pick
// under a changed parent is covered by `targetsDirty` (the parent override is the change).
const reconfigDirty = useMemo(
() =>
dependentReconfigs.some((field) => {
const repicked = reconfig[dependentKey(field)]
return repicked !== undefined && repicked !== field.currentValue
}),
[dependentReconfigs, reconfig]
)
const dirty = targetsDirty || reconfigDirty
const save = () => {
if (!otherWorkspaceId || !dirty || updateMapping.isPending) return
updateMapping.mutate(
{
workspaceId,
body: {
otherWorkspaceId,
direction,
// Persist the full effective set (WYSIWYG). Only include dependentValues once the
// diff has loaded; omitted before that so an early save can't wipe the store from
// an unknown set.
entries: buildMappingEntries(),
...(diff.data ? { dependentValues: buildDependentValues() } : {}),
},
},
{
onSuccess: () => {
setTargets({})
setReconfig({})
toast.success('Mapping saved')
},
onError: (error) => toast.error(getErrorMessage(error, 'Failed to save mapping')),
}
)
}
const discard = () => {
setTargets({})
setReconfig({})
}
const sync = async () => {
if (!otherWorkspaceId) return
setSubmitting(true)
// Capture every payload from the state at confirm time, before any await - the page's
// controls stay mounted during the run (unlike the old modal, which blocked its UI), so a
// mid-flight edit must not leak into the promote body.
const mappingEntries = buildMappingEntries()
const dependentValues = diff.data ? buildDependentValues() : null
// Copy the referenced-but-unmapped resources the user kept selected, excluding any the
// user mapped in-session (reconciliation: maps win). The backend validates each id
// against the plan's copy candidates too, so a mapped/stale id is dropped server-side
// regardless.
const selectedCopyables = visibleCopyables.filter((candidate) =>
copySelected.has(forkRefKey(candidate))
)
try {
await updateMapping.mutateAsync({
workspaceId,
body: { otherWorkspaceId, direction, entries: mappingEntries },
})
const copyResources = {
knowledgeBases: selectedCopyables
.filter((c) => c.kind === 'knowledge-base')
.map((c) => c.sourceId),
tables: selectedCopyables.filter((c) => c.kind === 'table').map((c) => c.sourceId),
customTools: selectedCopyables
.filter((c) => c.kind === 'custom-tool')
.map((c) => c.sourceId),
skills: selectedCopyables.filter((c) => c.kind === 'skill').map((c) => c.sourceId),
// Files are identified by storage key (the copyable candidate's sourceId is the key).
files: selectedCopyables.filter((c) => c.kind === 'file').map((c) => c.sourceId),
mcpServers: selectedCopyables.filter((c) => c.kind === 'mcp-server').map((c) => c.sourceId),
}
const result = await promote.mutateAsync({
workspaceId,
body: {
otherWorkspaceId,
direction,
// Once the diff has loaded, ALWAYS send the full effective set - including `[]`,
// which means "every dependent went away" and must reconcile/clear the live replace
// targets' stored rows. Collapsing `[]` into omission would make the backend
// PRESERVE stale rows. Only omit before the diff loads (set unknown), so the
// existing store is left untouched.
...(dependentValues !== null ? { dependentValues } : {}),
...(selectedCopyables.length > 0 ? { copyResources } : {}),
},
})
if (!result.promoteRunId) {
if (result.blockers.length > 0) {
// The server's authoritative gate re-found would-clear references (something changed
// between the preview and Sync). The mutation's settled invalidation refetches the
// diff, so the refreshed blocker list is already on its way in.
const count = result.blockers.length
toast.error(
`Sync blocked: ${count} reference${count === 1 ? '' : 's'} would break in the target. Review the updated list and try again.`
)
return
}
if (result.unmappedRequired.length > 0) {
// Name the actual blocking kinds rather than always blaming credentials: the server
// blocks on required REFERENCES (credentials and/or secrets); required dependents are
// gated client-side before this runs (see the Sync chip's disabled tooltip).
const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind))
toast.error(`Map all required ${forkRequiredKindsLabel(kinds)} first`)
return
}
toast.error('Sync did not complete')
return
}
const target = otherWorkspaceName || 'the workspace'
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
// A sync only commits once every reference is mapped/copied and every required dependent
// has a value (the zero-cleared-refs invariant + `reconfigComplete`), so the old
// "re-check X block - something may have been cleared" warnings only fired on
// preview-vs-commit races and read as false alarms. Those rare cases still land in the
// Activity entry (needsConfiguration/clearedOptional are recorded there) and a
// needs-config workflow visibly stays undeployed. Deploy FAILURES remain a real,
// actionable outcome, so they keep a warning.
if (result.deployFailed > 0) {
const n = result.deployFailed
toast.warning(
`${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.`
)
} else {
toast.success(label)
}
} catch (error) {
toast.error(getErrorMessage(error, 'Sync failed'))
} finally {
setSubmitting(false)
}
}
return {
direction,
otherWorkspaceName,
isLoading: enabled && mapping.isLoading,
isError: mapping.isError,
errorMessage: mapping.isError ? getErrorMessage(mapping.error, 'Failed to load mapping') : null,
diffErrorMessage: diff.isError
? getErrorMessage(diff.error, "Couldn't load sync details. Reload the page to retry.")
: null,
hasDiff: Boolean(diff.data),
hasMapping: Boolean(mapping.data),
groups,
kindSummaries,
targetFor,
setTarget,
takenOwnersFor,
usagesForEntry,
dependentsForEntry,
parentChangedFor,
targetWorkspaceId: diff.data?.targetWorkspaceId ?? '',
sourceWorkspaceId: diff.data?.sourceWorkspaceId ?? '',
reconfig,
setReconfig,
copyableKeys,
copyingKeys,
copySelected,
toggleCopyKeys,
referencedByKind,
unreferencedByKind,
hasVisibleCopyables: visibleCopyables.length > 0,
blockingRefs,
dependentClears,
workflowChanges,
archivedWorkflowNames,
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0,
dirty,
saving: updateMapping.isPending,
save,
discard,
submitting,
syncDisabled,
syncDisabledReason,
sync,
}
}
@@ -0,0 +1,299 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
ChipCopyInput,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalFooter,
ChipModalHeader,
toast,
} from '@sim/emcn'
import { AlertTriangle } from 'lucide-react'
import { useRouter } from 'next/navigation'
import type { GetForkResourcesResponse } from '@/lib/api/contracts/workspace-fork'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
FileKindRow,
ResourceKindRow,
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
import { useForkResources, useForkWorkspace } from '@/ee/workspace-forking/hooks/workspace-fork'
interface ForkWorkspaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
sourceWorkspaceId: string
sourceWorkspaceName: string
/** Whether the user is under their workspace cap; creating a fork is gated on this. */
canFork: boolean
/** Sends the user to upgrade (billing) when they try to fork at the cap. */
onUpgrade: () => void
}
type ResourceKey = Exclude<keyof GetForkResourcesResponse, 'deployedWorkflowCount'>
type ResourceSelection = Record<ResourceKey, Set<string>>
const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [
{ key: 'files', label: 'Files' },
{ key: 'tables', label: 'Tables' },
{ key: 'knowledgeBases', label: 'Knowledge bases' },
{ key: 'customTools', label: 'Custom tools' },
{ key: 'skills', label: 'Skills' },
{ key: 'mcpServers', label: 'MCP servers' },
{ key: 'workflowMcpServers', label: 'Workflow MCP servers' },
]
const emptySelection = (): ResourceSelection => ({
files: new Set(),
tables: new Set(),
knowledgeBases: new Set(),
customTools: new Set(),
skills: new Set(),
mcpServers: new Set(),
workflowMcpServers: new Set(),
})
const fullSelection = (data: GetForkResourcesResponse): ResourceSelection => {
const selection = emptySelection()
for (const kind of RESOURCE_KINDS) {
selection[kind.key] = new Set((data[kind.key] ?? []).map((item) => item.id))
}
return selection
}
/**
* Names and creates a fork of the current workspace, letting the user pick which
* resources to copy (whole kinds or a specific subset). Unselected resources leave
* the corresponding workflow subblocks empty. On success the modal closes - the
* Forks settings page's Activity log tracks the copy job, and the toast offers a
* one-click jump into the new fork.
*/
export function ForkWorkspaceModal({
open,
onOpenChange,
sourceWorkspaceId,
sourceWorkspaceName,
canFork,
onUpgrade,
}: ForkWorkspaceModalProps) {
const router = useRouter()
const forkWorkspace = useForkWorkspace()
const resources = useForkResources(sourceWorkspaceId, open)
const [name, setName] = useState('')
const [selected, setSelected] = useState<ResourceSelection>(emptySelection)
const [defaulted, setDefaulted] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
setName(`${sourceWorkspaceName} (fork)`)
setSelected(emptySelection())
setDefaulted(false)
setError(null)
}
}, [open, sourceWorkspaceName])
useEffect(() => {
if (!open || !resources.data || defaulted) return
setDefaulted(true)
setSelected(fullSelection(resources.data))
}, [open, resources.data, defaulted])
const isForking = forkWorkspace.isPending
const availableKinds = useMemo(
() => RESOURCE_KINDS.filter((kind) => (resources.data?.[kind.key].length ?? 0) > 0),
[resources.data]
)
const hasDeselection = useMemo(
() =>
defaulted &&
availableKinds.some(
(kind) => selected[kind.key].size < (resources.data?.[kind.key]?.length ?? 0)
),
[defaulted, availableKinds, selected, resources.data]
)
// A fork always produces a usable workspace: deployed workflows are copied, and
// when the source has none, create-fork seeds a blank starter workflow (plus any
// selected resources). So forking is never blocked - we just set expectations when
// there are no deployed workflows to carry over.
const noDeployedWorkflows =
Boolean(resources.data) && (resources.data?.deployedWorkflowCount ?? 0) === 0
const handleSubmit = () => {
// At a workspace cap, creating a fork is the only gated action - send the user to
// upgrade rather than blocking the whole modal.
if (!canFork) {
onUpgrade()
return
}
const trimmed = name.trim()
// Block until the resources query resolves: building `copy` from an unloaded `resources.data`
// would send an empty selection and silently clear every reference in the fork. The Fork
// action is disabled in this state too; this is the defense-in-depth guard.
if (!trimmed || isForking || !resources.data) return
setError(null)
const copy = Object.fromEntries(
RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])])
)
forkWorkspace.mutate(
{ workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } },
{
onSuccess: (result) => {
// The copy job's progress lands in the page's Activity log; the toast action
// preserves the old modal's one-click "Open fork".
toast.success(`Forked into "${result.workspace.name}"`, {
action: {
label: 'Open fork',
onClick: () => router.push(`/workspace/${result.workspace.id}/w`),
},
})
onOpenChange(false)
},
onError: (err) => setError(err.message || 'Failed to fork workspace'),
}
)
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Fork workspace'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Fork workspace</ChipModalHeader>
<ChipModalBody>
<div className='flex flex-col gap-7 px-2'>
<SettingsSection label='Forking from'>
<ChipCopyInput value={sourceWorkspaceName} aria-label='Forking from' />
</SettingsSection>
<SettingsSection
label='Name'
headerAccessory={
<span className='text-[var(--text-error)]' title='Required'>
*
</span>
}
>
<ChipInput
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
handleSubmit()
}
}}
placeholder='Workspace name'
maxLength={100}
autoComplete='off'
disabled={isForking}
aria-label='Workspace name'
/>
</SettingsSection>
{availableKinds.length > 0 ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
{availableKinds.map((kind) =>
kind.key === 'files' ? (
<FileKindRow
key={kind.key}
label={kind.label}
files={resources.data?.files ?? []}
selected={selected.files}
onToggleAll={(selectAll) =>
setSelected((prev) => ({
...prev,
files: selectAll
? new Set((resources.data?.files ?? []).map((item) => item.id))
: new Set<string>(),
}))
}
onToggleItem={(id, checked) =>
setSelected((prev) => {
const next = new Set(prev.files)
if (checked) next.add(id)
else next.delete(id)
return { ...prev, files: next }
})
}
onToggleMany={(ids, checked) =>
setSelected((prev) => {
const next = new Set(prev.files)
for (const id of ids) {
if (checked) next.add(id)
else next.delete(id)
}
return { ...prev, files: next }
})
}
disabled={isForking}
/>
) : (
<ResourceKindRow
key={kind.key}
label={kind.label}
items={resources.data?.[kind.key] ?? []}
selected={selected[kind.key]}
onToggleMany={(ids, checked) =>
setSelected((prev) => {
const next = new Set(prev[kind.key])
for (const id of ids) {
if (checked) next.add(id)
else next.delete(id)
}
return { ...prev, [kind.key]: next }
})
}
onToggleItem={(id, checked) =>
setSelected((prev) => {
const next = new Set(prev[kind.key])
if (checked) next.add(id)
else next.delete(id)
return { ...prev, [kind.key]: next }
})
}
disabled={isForking}
/>
)
)}
{hasDeselection ? (
<div className='flex items-start gap-1.5 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
Some resources are not selected references to them in your workflows will be
cleared in the fork.
</span>
</div>
) : null}
</div>
</SettingsSection>
) : null}
{noDeployedWorkflows ? (
<p className='text-[var(--text-muted)] text-caption'>
No deployed workflows to copy your fork will start with a blank workflow.
</p>
) : null}
</div>
<ChipModalError>{error ?? undefined}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isForking}
primaryAction={{
label: isForking ? 'Forking...' : 'Fork',
onClick: handleSubmit,
// At the cap the button stays clickable (no name needed) so it can route to
// upgrade. Otherwise it needs a name AND the resources query loaded - forking
// before `resources.data` arrives would clear every reference (P1-C).
disabled: isForking || (canFork && (!name.trim() || !resources.data)),
disabledTooltip:
canFork && name.trim() && !resources.data ? 'Loading workspace resources…' : undefined,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,580 @@
'use client'
import { useState } from 'react'
import { ChipConfirmModal, toast } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { AlertTriangle, Plus } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
forkIdParam,
forkIdUrlKeys,
forkSyncDirectionParam,
forkSyncDirectionUrlKeys,
forkViewParam,
forkViewUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import {
type RowAction,
RowActionsMenu,
} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel'
import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view'
import {
ARCHIVED_PREVIEW_LIMIT,
useForkSync,
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal'
import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available'
import {
useForkLineage,
useRollbackFork,
useUnlinkFork,
} from '@/ee/workspace-forking/hooks/workspace-fork'
import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
/** Explains a disabled lineage action whose target workspace the viewer cannot open. */
const NO_ACCESS_TOOLTIP = "You don't have access to this workspace"
/** Lineage partner names by id (the parent + this workspace's forks), for the Activity view. */
function lineagePartnerNames(
parent: ForkLineageNodeApi | null,
forks: ForkLineageChildApi[]
): ReadonlyMap<string, string> {
const names = new Map<string, string>()
if (parent) names.set(parent.id, parent.name)
for (const fork of forks) names.set(fork.id, fork.name)
return names
}
interface ForkListRowProps {
name: string
/** Entries for the row's `...` menu (Edit mappings / Open workspace / Disconnect). */
actions: RowAction[]
}
function ForkListRow({ name, actions }: ForkListRowProps) {
return (
<div className='flex items-center justify-between gap-3'>
<FloatingOverflowText
label={name}
className='block min-w-0 truncate text-[var(--text-body)] text-sm'
/>
<div className='flex flex-shrink-0 items-center gap-1'>
<RowActionsMenu label='Fork actions' actions={actions} />
</div>
</div>
)
}
interface ForkSyncDetailViewProps {
title: string
workspaceId: string
/** The other side of the edge being synced (this workspace's parent). */
otherWorkspaceId: string
otherWorkspaceName: string
onBack: () => void
/** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */
actions: SettingsAction[]
}
/**
* The parent edge's sync page (reached from the parent row): direction, deployed-workflow
* changes, per-kind mappings (each an expandable row whose status badge is the summary),
* copy resources, and blocking references, all as page sections.
* The header's Sync chip is gated until zero blockers + required mappings + reconfigure are
* complete, and always confirms the overwrite first — that confirm is the flow's one modal.
* While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded;
* Sync itself persists the effective mapping as part of the run.
*/
function ForkSyncDetailView({
title,
workspaceId,
otherWorkspaceId,
otherWorkspaceName,
onBack,
actions,
}: ForkSyncDetailViewProps) {
// Sync direction is shareable view state: a copied link opens the same side of the sync.
const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, {
...forkSyncDirectionParam.parser,
...forkSyncDirectionUrlKeys,
})
const controller = useForkSync({
workspaceId,
otherWorkspaceId,
otherWorkspaceName,
direction,
enabled: true,
})
// Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed
// the shared settings dirty store so a sidebar section switch confirms too.
const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty })
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
// Sync is the edge's primary action, so it's the rightmost/black chip; the caller's
// Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster
// for Discard/Save until they're saved or discarded.
const panelActions: SettingsAction[] = controller.dirty
? saveDiscardActions({
dirty: controller.dirty,
saving: controller.saving,
onSave: controller.save,
onDiscard: controller.discard,
})
: [
...actions,
{
text: controller.submitting ? 'Working...' : 'Sync',
variant: 'primary' as const,
onSelect: () => setConfirmSyncOpen(true),
disabled: controller.syncDisabled,
tooltip: controller.syncDisabled
? controller.syncDisabledReason
: `Push to or pull from ${otherWorkspaceName}`,
},
]
const targetWorkspaceName = direction === 'push' ? otherWorkspaceName : 'this workspace'
return (
<>
<SettingsPanel
back={{
text: 'Workspace Forks',
icon: ArrowLeft,
onSelect: () =>
guard.guardBack(() => {
void setDirection(null)
onBack()
}),
}}
title={title}
actions={panelActions}
>
<ForkSyncView
controller={controller}
onDirectionChange={(next) => void setDirection(next)}
/>
</SettingsPanel>
<UnsavedChangesModal
open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal}
onDiscard={guard.confirmDiscard}
/>
<ChipConfirmModal
open={confirmSyncOpen}
onOpenChange={setConfirmSyncOpen}
srTitle='Sync workspace'
title='Overwrite target workspace'
text={[
'The target may have been modified since the last sync. Syncing will ',
{ text: 'overwrite any changes', bold: true },
' there. Continue?',
]}
confirm={{
label: 'Sync',
onClick: () => {
setConfirmSyncOpen(false)
void controller.sync()
},
pending: controller.submitting,
pendingLabel: 'Syncing...',
}}
>
{controller.archivedWorkflowNames.length > 0 ? (
<div className='flex flex-col gap-1 px-2'>
<p className='break-words text-[var(--text-primary)] text-sm'>
Will be archived in <span className='font-medium'>{targetWorkspaceName}</span>{' '}
(deleted in the source):
</p>
{controller.archivedWorkflowNames
.slice(0, ARCHIVED_PREVIEW_LIMIT)
.map((name, index) => (
<div
key={`${name}:${index}`}
className='min-w-0 truncate text-[var(--text-muted)] text-small'
>
{name}
</div>
))}
{controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? (
<div className='text-[var(--text-muted)] text-small'>
and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more
</div>
) : null}
</div>
) : null}
</ChipConfirmModal>
</>
)
}
interface ForkActivityDetailViewProps {
workspaceId: string
/** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */
workspaceNames: ReadonlyMap<string, string>
onBack: () => void
/** Header actions (e.g. the destructive Rollback chip while the last sync is undoable). */
actions?: SettingsAction[]
}
/**
* Workspace-scoped activity: every fork, sync, and rollback involving this workspace
* (both sides of each edge), reached from the page header's "See activity" action.
*/
function ForkActivityDetailView({
workspaceId,
workspaceNames,
onBack,
actions,
}: ForkActivityDetailViewProps) {
return (
<SettingsPanel
back={{ text: 'Workspace Forks', icon: ArrowLeft, onSelect: onBack }}
title='Activity'
actions={actions}
>
<ForkActivityPanel workspaceId={workspaceId} workspaceNames={workspaceNames} />
</SettingsPanel>
)
}
/**
* Forks settings page. The workspace's single parent (if it's a fork) sits in its own
* "Parent" section, above the "Forks" list of child forks. The parent row's `...` menu
* has Edit mappings (the child owns its edge's re-picks), Open workspace, and
* Disconnect; fork rows offer Open workspace and Disconnect only. Activity is
* workspace-scoped and lives behind the header's "See activity" action (including
* Rollback when the last sync into this workspace is undoable). Sync lives on the
* parent's sync detail page.
* Forking and sync rewrite workflow state and deployments en masse, so the page is
* workspace-admin only and gated on the workspace's fork entitlement - every fork route
* re-checks both; the server remains the boundary.
*/
export function Forks() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { canAdmin, isLoading: permissionsLoading } = useUserPermissionsContext()
const { available: forkingAvailable, isLoading: availabilityLoading } =
useForkingAvailability(workspaceId)
const canUseForking = forkingAvailable && canAdmin
const { data: workspaces } = useWorkspacesQuery()
const { data: creationPolicy } = useWorkspaceCreationPolicy()
const { navigateToSettings } = useSettingsNavigation()
const lineage = useForkLineage(workspaceId, canUseForking)
const rollback = useRollbackFork()
const unlink = useUnlinkFork()
const [searchTerm, setSearchTerm] = useState('')
const [isForkModalOpen, setIsForkModalOpen] = useState(false)
const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false)
const [confirmUnlink, setConfirmUnlink] = useState<{ id: string; name: string } | null>(null)
const [selectedForkId, setSelectedForkId] = useQueryState(forkIdParam.key, {
...forkIdParam.parser,
...forkIdUrlKeys,
})
const [forkView, setForkView] = useQueryState(forkViewParam.key, {
...forkViewParam.parser,
...forkViewUrlKeys,
})
const workspaceName = workspaces?.find((workspace) => workspace.id === workspaceId)?.name
const canFork = creationPolicy?.canCreate ?? true
const parent = lineage.data?.parent ?? null
const forks = lineage.data?.children ?? []
const undoableRun = lineage.data?.undoableRun ?? null
const gateLoading = availabilityLoading || permissionsLoading
// Rollback undoes the last sync INTO this workspace, restoring each affected
// workflow to its prior deployed version.
const runRollback = async () => {
if (!undoableRun) return
try {
await rollback.mutateAsync({
workspaceId,
body: { otherWorkspaceId: undoableRun.otherWorkspaceId },
})
toast.success(`Undid sync from "${undoableRun.otherName}"`)
setConfirmRollbackOpen(false)
} catch (err) {
toast.error(getErrorMessage(err, 'Undo failed'))
}
}
const openForkWorkspace = (forkId: string) => {
router.push(`/workspace/${forkId}/w`)
}
const openForkMappings = (forkId: string) => {
void setSelectedForkId(forkId)
}
/** Permanently dissolve the edge with the confirmed workspace; both workspaces remain. */
const runUnlink = async () => {
if (!confirmUnlink) return
try {
await unlink.mutateAsync({
workspaceId,
body: { otherWorkspaceId: confirmUnlink.id },
})
toast.success(`Disconnected "${confirmUnlink.name}"`)
setConfirmUnlink(null)
} catch (err) {
toast.error(getErrorMessage(err, 'Disconnect failed'))
}
}
if (gateLoading) {
return <SettingsPanel />
}
if (!canUseForking) {
return (
<SettingsPanel>
<SettingsEmptyState>
{canAdmin
? 'Forking is not available for this workspace.'
: 'Only workspace admins can manage forks.'}
</SettingsEmptyState>
</SettingsPanel>
)
}
const searchLower = searchTerm.trim().toLowerCase()
const parentVisible =
parent !== null && (!searchLower || parent.name.toLowerCase().includes(searchLower))
const filteredForks = forks.filter((fork) => fork.name.toLowerCase().includes(searchLower))
const hasRows = parent !== null || forks.length > 0
// The sync detail exists only for the PARENT edge: sync (and the mapping re-picks it
// persists) belongs to the child workspace configuring how it maps its parent's
// resources, so a parent browsing its forks gets no detail for them (a stale fork-id
// deep link falls back to the list). Fork rows offer Open workspace / Disconnect only.
const showParentDetail = Boolean(selectedForkId && parent && parent.id === selectedForkId)
// Open workspace sits left of the detail view's primary Sync chip, which the sync
// page owns (it carries the gating). Rollback lives on the Activity view only.
const parentHeaderActions: SettingsAction[] = parent
? [
{
text: 'Open workspace',
onSelect: () => openForkWorkspace(parent.id),
disabled: !parent.viewerAccessible,
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
]
: []
return (
<>
{showParentDetail && parent ? (
<ForkSyncDetailView
key={parent.id}
title={parent.name}
workspaceId={workspaceId}
otherWorkspaceId={parent.id}
otherWorkspaceName={parent.name}
onBack={() => setSelectedForkId(null)}
actions={parentHeaderActions}
/>
) : forkView === 'activity' ? (
<ForkActivityDetailView
workspaceId={workspaceId}
workspaceNames={lineagePartnerNames(parent, forks)}
onBack={() => setForkView(null)}
actions={
undoableRun
? [
{
text: 'Rollback',
variant: 'destructive',
onSelect: () => setConfirmRollbackOpen(true),
disabled: rollback.isPending,
tooltip: `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`,
},
]
: undefined
}
/>
) : (
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search forks...',
}}
actions={[
{ text: 'See activity', onSelect: () => void setForkView('activity') },
{
text: 'Create fork',
icon: Plus,
variant: 'primary',
onSelect: () => setIsForkModalOpen(true),
},
]}
>
{lineage.isError ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{getErrorMessage(lineage.error, 'Failed to load forks')}
</p>
</div>
) : lineage.isLoading ? null : !hasRows ? (
<SettingsEmptyState>Click "Create fork" above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-7'>
{parentVisible && parent !== null && (
<SettingsSection label='Parent'>
<ForkListRow
name={parent.name}
actions={[
{
label: 'Edit mappings',
onSelect: () => openForkMappings(parent.id),
disabled: !parent.viewerAccessible,
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
{
label: 'Open workspace',
onSelect: () => openForkWorkspace(parent.id),
disabled: !parent.viewerAccessible,
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
// Disconnect stays enabled regardless of access: severing the edge is a
// current-workspace operation (admin on the acting side only), and must
// remain reachable exactly when the other side is inaccessible.
{
label: 'Disconnect',
destructive: true,
onSelect: () => setConfirmUnlink({ id: parent.id, name: parent.name }),
},
]}
/>
</SettingsSection>
)}
<SettingsSection label='Forks'>
{filteredForks.length > 0 ? (
<div className='flex flex-col gap-2'>
{filteredForks.map((fork) => (
<ForkListRow
key={fork.id}
name={fork.name}
actions={[
{
label: 'Open workspace',
onSelect: () => openForkWorkspace(fork.id),
disabled: !fork.viewerAccessible,
tooltip: fork.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
{
label: 'Disconnect',
destructive: true,
onSelect: () => setConfirmUnlink({ id: fork.id, name: fork.name }),
},
]}
/>
))}
</div>
) : (
<SettingsEmptyState variant='inline'>
{searchTerm.trim()
? `No forks found matching "${searchTerm}"`
: 'No forks yet — click "Create fork" above to get started'}
</SettingsEmptyState>
)}
</SettingsSection>
</div>
)}
</SettingsPanel>
)}
<ForkWorkspaceModal
open={isForkModalOpen}
onOpenChange={setIsForkModalOpen}
sourceWorkspaceId={workspaceId}
sourceWorkspaceName={workspaceName || 'Workspace'}
canFork={canFork}
onUpgrade={() => {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
}}
/>
<ChipConfirmModal
open={confirmUnlink !== null}
onOpenChange={(open) => {
if (!open) setConfirmUnlink(null)
}}
srTitle='Disconnect fork'
title='Disconnect fork'
text={[
'This permanently removes the fork relationship with ',
{ text: confirmUnlink?.name ?? '', bold: true },
". Both workspaces stay exactly as they are, but they will no longer appear in each other's fork lists, and syncing between them stops.",
]}
confirm={{
label: 'Disconnect',
onClick: () => void runUnlink(),
pending: unlink.isPending,
pendingLabel: 'Disconnecting...',
}}
>
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
This cannot be undone the saved mappings and sync history for this pair are deleted,
and forking again creates a brand-new workspace.
</span>
</div>
</ChipConfirmModal>
<ChipConfirmModal
open={confirmRollbackOpen}
onOpenChange={setConfirmRollbackOpen}
srTitle='Undo last sync'
title='Undo last sync'
text={[
'This restores each affected workflow to its ',
{ text: 'prior deployed version', bold: true },
' and removes workflows the sync created. Continue?',
]}
confirm={{
label: 'Rollback',
onClick: () => void runRollback(),
pending: rollback.isPending,
pendingLabel: 'Rolling back...',
}}
>
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
Resources copied into this workspace during syncs may remain afterward rollback
restores workflows to their prior versions but does not remove copied resources.
</span>
</div>
</ChipConfirmModal>
</>
)
}
@@ -0,0 +1,60 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type BackgroundWorkItem,
type GetWorkspaceBackgroundWorkResponse,
getWorkspaceBackgroundWorkContract,
} from '@/lib/api/contracts/workspace-fork'
export const backgroundWorkKeys = {
all: ['backgroundWork'] as const,
// 'infinite' segments the key from the pre-pagination plain-query era: the data shape
// under the old key was an array, and an infinite query reading such a cache entry
// renders as empty. A shape change must always re-key.
lists: () => [...backgroundWorkKeys.all, 'list', 'infinite'] as const,
list: (workspaceId?: string) => [...backgroundWorkKeys.lists(), workspaceId ?? ''] as const,
}
export const BACKGROUND_WORK_STALE_TIME = 5_000
/** Page size for the fork Activity feed, matching the audit log's. */
const BACKGROUND_WORK_PAGE_SIZE = '50'
async function fetchWorkspaceBackgroundWork(
workspaceId: string,
cursor?: string,
signal?: AbortSignal
): Promise<GetWorkspaceBackgroundWorkResponse> {
return requestJson(getWorkspaceBackgroundWorkContract, {
params: { id: workspaceId },
query: { cursor, limit: BACKGROUND_WORK_PAGE_SIZE },
signal,
})
}
const isActive = (item: BackgroundWorkItem) =>
item.status === 'pending' || item.status === 'processing'
/**
* Durable "background work in progress" status for a workspace (fork content copy +
* any deployment side-effects), keyset-paginated like the enterprise audit log
* (`getNextPageParam` from the page's `nextCursor`). Poll-first per the best-practice
* for long jobs: the status survives a reload (it's a DB row), and we only keep
* polling while something is still running, then stop - the poll refetches every
* loaded page sequentially with fresh cursors, so pagination stays consistent.
* Refetch on focus catches changes after the tab was away.
*/
export function useWorkspaceBackgroundWork(workspaceId?: string) {
return useInfiniteQuery({
queryKey: backgroundWorkKeys.list(workspaceId),
queryFn: ({ pageParam, signal }) =>
fetchWorkspaceBackgroundWork(workspaceId as string, pageParam, signal),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
enabled: Boolean(workspaceId),
staleTime: BACKGROUND_WORK_STALE_TIME,
refetchInterval: (query) =>
(query.state.data?.pages ?? []).some((page) => page.items.some(isActive)) ? 5_000 : false,
refetchOnWindowFocus: true,
})
}
@@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { getForkAvailabilityContract } from '@/lib/api/contracts/workspace-fork'
export const forkAvailabilityKeys = {
all: ['fork-availability'] as const,
details: () => [...forkAvailabilityKeys.all, 'detail'] as const,
detail: (workspaceId?: string) => [...forkAvailabilityKeys.details(), workspaceId ?? ''] as const,
}
/** Availability flips only on plan changes or flag rollouts - cache generously. */
const FORK_AVAILABILITY_STALE_TIME = 5 * 60 * 1000
interface ForkingAvailability {
available: boolean
/** The lookup is still in flight - callers that gate a whole page wait on this. */
isLoading: boolean
}
/**
* Server-evaluated fork availability for the workspace: the verdict of the exact gate
* every fork route enforces (env/plan + the `workspace-forking` AppConfig rollout
* flag), served by the availability route. Used to hide the Forks settings tab and
* the fork context-menu entries; the server gate remains the security boundary.
*/
export function useForkingAvailability(workspaceId?: string): ForkingAvailability {
const { data, isLoading } = useQuery({
queryKey: forkAvailabilityKeys.detail(workspaceId),
queryFn: ({ signal }) =>
requestJson(getForkAvailabilityContract, { params: { id: workspaceId as string }, signal }),
enabled: Boolean(workspaceId),
staleTime: FORK_AVAILABILITY_STALE_TIME,
})
return { available: data?.available ?? false, isLoading }
}
/** Boolean shorthand for surfaces that only show/hide fork entry points. */
export function useForkingAvailable(workspaceId?: string): boolean {
return useForkingAvailability(workspaceId).available
}
@@ -0,0 +1,203 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type ForkWorkspaceBody,
forkWorkspaceContract,
getForkDiffContract,
getForkLineageContract,
getForkMappingContract,
getForkResourcesContract,
type PromoteForkBody,
promoteForkContract,
type RollbackForkBody,
rollbackForkContract,
type UnlinkForkBody,
type UpdateForkMappingBody,
unlinkForkContract,
updateForkMappingContract,
} from '@/lib/api/contracts/workspace-fork'
import type { WorkspacesResponse } from '@/lib/api/contracts/workspaces'
import { backgroundWorkKeys } from '@/ee/workspace-forking/hooks/background-work'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { workspaceKeys } from '@/hooks/queries/workspace'
export type ForkDirection = 'push' | 'pull'
export const forkKeys = {
all: ['workspace-fork'] as const,
lineages: () => [...forkKeys.all, 'lineage'] as const,
lineage: (workspaceId?: string) => [...forkKeys.lineages(), workspaceId ?? ''] as const,
mappings: () => [...forkKeys.all, 'mapping'] as const,
mapping: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
[...forkKeys.mappings(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
diffs: () => [...forkKeys.all, 'diff'] as const,
diff: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
[...forkKeys.diffs(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
resourcesAll: () => [...forkKeys.all, 'resources'] as const,
resources: (workspaceId?: string) => [...forkKeys.resourcesAll(), workspaceId ?? ''] as const,
}
export const WORKSPACE_FORK_RESOURCES_STALE_TIME = 30 * 1000
export const WORKSPACE_FORK_LINEAGE_STALE_TIME = 30 * 1000
export const WORKSPACE_FORK_MAPPING_STALE_TIME = 15 * 1000
export const WORKSPACE_FORK_DIFF_STALE_TIME = 10 * 1000
export function useForkResources(workspaceId?: string, enabled = true) {
return useQuery({
queryKey: forkKeys.resources(workspaceId),
queryFn: ({ signal }) =>
requestJson(getForkResourcesContract, { params: { id: workspaceId as string }, signal }),
enabled: Boolean(workspaceId) && enabled,
staleTime: WORKSPACE_FORK_RESOURCES_STALE_TIME,
})
}
export function useForkLineage(workspaceId?: string, enabled = true) {
return useQuery({
queryKey: forkKeys.lineage(workspaceId),
queryFn: ({ signal }) =>
requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }),
enabled: Boolean(workspaceId) && enabled,
staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME,
placeholderData: keepPreviousData,
})
}
export function useForkWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: ForkWorkspaceBody }) =>
requestJson(forkWorkspaceContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSuccess: (data) => {
// Merge the new fork into the active list cache before invalidation so the
// immediate navigation into it can't race a stale list and trip the
// not-in-workspaces redirect (mirrors useCreateWorkspace).
const newWorkspace = data.workspace
queryClient.setQueryData<WorkspacesResponse>(workspaceKeys.list('active'), (previous) => {
if (!previous) {
return { workspaces: [newWorkspace], lastActiveWorkspaceId: null, creationPolicy: null }
}
if (previous.workspaces.some((w) => w.id === newWorkspace.id)) {
return previous
}
return { ...previous, workspaces: [newWorkspace, ...previous.workspaces] }
})
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
},
})
}
export function useForkMapping(args: {
workspaceId?: string
otherWorkspaceId?: string
direction: ForkDirection
enabled?: boolean
}) {
return useQuery({
queryKey: forkKeys.mapping(args.workspaceId, args.otherWorkspaceId, args.direction),
queryFn: ({ signal }) =>
requestJson(getForkMappingContract, {
params: { id: args.workspaceId as string },
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
signal,
}),
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
staleTime: WORKSPACE_FORK_MAPPING_STALE_TIME,
placeholderData: keepPreviousData,
})
}
export function useUpdateForkMapping() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: UpdateForkMappingBody }) =>
requestJson(updateForkMappingContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
},
})
}
export function useForkDiff(args: {
workspaceId?: string
otherWorkspaceId?: string
direction: ForkDirection
enabled?: boolean
}) {
return useQuery({
queryKey: forkKeys.diff(args.workspaceId, args.otherWorkspaceId, args.direction),
queryFn: ({ signal }) =>
requestJson(getForkDiffContract, {
params: { id: args.workspaceId as string },
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
signal,
}),
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
staleTime: WORKSPACE_FORK_DIFF_STALE_TIME,
placeholderData: keepPreviousData,
})
}
export function usePromoteFork() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: PromoteForkBody }) =>
requestJson(promoteForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSettled: () => {
// A sync changes lineage (undoable run), mappings, and the diff - not the
// workspace's copyable resource inventory, so leave `resources` cached.
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
// A sync rewrites the target workflows' drafts AND redeploys them. The promote
// result doesn't expose the affected ids, so invalidate all deployment caches:
// otherwise a target workflow whose deployed state was already cached compares its
// fresh draft against the stale (pre-sync) deployed snapshot and falsely shows
// "Update" instead of "Live".
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
},
})
}
export function useUnlinkFork() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: UnlinkForkBody }) =>
requestJson(unlinkForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSettled: () => {
// Unlink dissolves the edge: lineage loses the row, and the edge's mappings/diff
// no longer exist. Workflows and deployments are untouched.
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
},
})
}
export function useRollbackFork() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { workspaceId: string; body: RollbackForkBody }) =>
requestJson(rollbackForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
onSettled: () => {
// Rollback changes lineage, mappings, and the diff - not the copyable resource
// inventory, so leave `resources` cached (mirrors usePromoteFork).
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
// Rollback restores the target workflows' drafts + reactivates a prior deployment,
// so the cached deployed snapshots are stale - refresh them so change detection
// doesn't falsely show "Update" (mirrors usePromoteFork).
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
},
})
}
@@ -0,0 +1,265 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import { listSurfacedBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
const executor = dbChainMock.db as unknown as DbOrTx
/** Shape produced by the drizzle-orm mock's `sql` tagged template. */
interface MockSqlFragment {
strings: readonly string[]
values: unknown[]
}
interface MockCondition {
type: string
conditions?: MockCondition[]
left?: unknown
right?: unknown
column?: unknown
values?: unknown[]
}
/** Resolves the first `.where(...)` (the children lookup) to the given fork ids. */
function mockChildrenLookup(childIds: string[]) {
dbChainMockFns.where.mockImplementationOnce(
() => Promise.resolve(childIds.map((id) => ({ id }))) as never
)
}
/** Builds an opaque cursor the way the store encodes one (base64 JSON). */
function encodeCursor(data: { updatedAt: string; id: string }): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): { updatedAt: string; id: string } {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
}
describe('listSurfacedBackgroundWork', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('returns the surfaced rows ordered by recency with the id tiebreaker', async () => {
mockChildrenLookup([])
const rows = [
{ id: 'job-1', updatedAt: new Date('2026-07-01T10:00:00.000Z') },
{ id: 'job-2', updatedAt: new Date('2026-07-01T09:00:00.000Z') },
]
dbChainMockFns.limit.mockResolvedValueOnce(rows as never)
const result = await listSurfacedBackgroundWork(executor, 'ws-1')
expect(result.rows).toEqual(rows)
expect(dbChainMockFns.orderBy).toHaveBeenCalledWith(
{ type: 'desc', column: 'updatedAt' },
{ type: 'desc', column: 'id' }
)
// Over-fetches one row past the default page size to detect another page.
expect(dbChainMockFns.limit).toHaveBeenCalledWith(51)
})
it('returns a null cursor when the page is not full', async () => {
mockChildrenLookup([])
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'job-1', updatedAt: new Date('2026-07-01T10:00:00.000Z') },
] as never)
const result = await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 2 })
expect(result.rows).toHaveLength(1)
expect(result.nextCursor).toBeNull()
})
it('returns a null cursor for an empty page', async () => {
mockChildrenLookup([])
dbChainMockFns.limit.mockResolvedValueOnce([] as never)
const result = await listSurfacedBackgroundWork(executor, 'ws-1')
expect(result).toEqual({ rows: [], nextCursor: null })
})
it('trims the over-fetched row and encodes the next cursor from the last returned row', async () => {
mockChildrenLookup([])
const rows = [
{ id: 'job-1', updatedAt: new Date('2026-07-01T10:00:00.000Z') },
{ id: 'job-2', updatedAt: new Date('2026-07-01T09:00:00.000Z') },
{ id: 'job-3', updatedAt: new Date('2026-07-01T08:00:00.000Z') },
]
dbChainMockFns.limit.mockResolvedValueOnce(rows as never)
const result = await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 2 })
expect(dbChainMockFns.limit).toHaveBeenCalledWith(3)
expect(result.rows).toEqual(rows.slice(0, 2))
expect(result.nextCursor).not.toBeNull()
expect(decodeCursor(result.nextCursor as string)).toEqual({
updatedAt: '2026-07-01T09:00:00.000Z',
id: 'job-2',
})
})
it('applies the cursor as a keyset condition with the id tiebreaker', async () => {
mockChildrenLookup([])
const cursor = encodeCursor({ updatedAt: '2026-07-01T09:00:00.000Z', id: 'job-2' })
await listSurfacedBackgroundWork(executor, 'ws-1', { cursor })
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
expect(rowsWhere.type).toBe('and')
expect(rowsWhere.conditions).toHaveLength(3)
const cursorDate = new Date('2026-07-01T09:00:00.000Z')
const keyset = (rowsWhere.conditions as MockCondition[])[2]
expect(keyset).toEqual({
type: 'or',
conditions: [
{ type: 'lt', left: 'updatedAt', right: cursorDate },
{
type: 'and',
conditions: [
{ type: 'eq', left: 'updatedAt', right: cursorDate },
{ type: 'lt', left: 'id', right: 'job-2' },
],
},
],
})
})
it('pages through rows with identical updatedAt via the id tiebreaker', async () => {
// Two rows share one timestamp; page 1 ends on the higher id. The next
// cursor must carry that id so the second page matches the remaining row
// through the eq(updatedAt) + lt(id) arm instead of skipping it.
const sharedAt = new Date('2026-07-01T09:00:00.000Z')
mockChildrenLookup([])
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'job-b', updatedAt: sharedAt },
{ id: 'job-a', updatedAt: sharedAt },
] as never)
const firstPage = await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 1 })
expect(firstPage.rows).toEqual([{ id: 'job-b', updatedAt: sharedAt }])
expect(decodeCursor(firstPage.nextCursor as string)).toEqual({
updatedAt: sharedAt.toISOString(),
id: 'job-b',
})
mockChildrenLookup([])
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'job-a', updatedAt: sharedAt }] as never)
const secondPage = await listSurfacedBackgroundWork(executor, 'ws-1', {
cursor: firstPage.nextCursor as string,
limit: 1,
})
const rowsWhere = dbChainMockFns.where.mock.calls[3][0] as MockCondition
const keyset = (rowsWhere.conditions as MockCondition[])[2]
expect(keyset.conditions?.[1]).toEqual({
type: 'and',
conditions: [
{ type: 'eq', left: 'updatedAt', right: sharedAt },
{ type: 'lt', left: 'id', right: 'job-b' },
],
})
expect(secondPage.rows).toEqual([{ id: 'job-a', updatedAt: sharedAt }])
expect(secondPage.nextCursor).toBeNull()
})
it('ignores an undecodable cursor and serves the first page', async () => {
mockChildrenLookup([])
await listSurfacedBackgroundWork(executor, 'ws-1', { cursor: 'not-base64-json' })
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
expect(rowsWhere.conditions).toHaveLength(2)
})
it('clamps the requested limit to the server-side cap', async () => {
mockChildrenLookup([])
await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 5000 })
expect(dbChainMockFns.limit).toHaveBeenCalledWith(101)
})
it('looks up live forks of the workspace for the child-keyed clause', async () => {
mockChildrenLookup([])
await listSurfacedBackgroundWork(executor, 'ws-1')
const childrenWhere = dbChainMockFns.where.mock.calls[0][0] as MockCondition
expect(childrenWhere).toEqual({
type: 'and',
conditions: [
{ type: 'eq', left: 'forkedFromWorkspaceId', right: 'ws-1' },
{ type: 'isNull', column: 'archivedAt' },
],
})
})
it('matches rows keyed to the workspace, to it as fork child, and to it as edge partner', async () => {
mockChildrenLookup([])
await listSurfacedBackgroundWork(executor, 'ws-1')
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
expect(rowsWhere.type).toBe('and')
expect(rowsWhere.conditions).toHaveLength(2)
const [involves, statuses] = rowsWhere.conditions as [MockCondition, MockCondition]
expect(involves.type).toBe('or')
const orConditions = involves.conditions as unknown as [
MockCondition,
MockSqlFragment,
MockSqlFragment,
]
expect(orConditions).toHaveLength(3)
expect(orConditions[0]).toEqual({ type: 'eq', left: 'workspaceId', right: 'ws-1' })
const childIdClause = orConditions[1]
expect(childIdClause.strings.join('?')).toContain("->> 'childWorkspaceId' =")
expect(childIdClause.values).toEqual(['metadata', 'ws-1'])
const otherIdClause = orConditions[2]
expect(otherIdClause.strings.join('?')).toContain("->> 'otherWorkspaceId' =")
expect(otherIdClause.values).toEqual(['metadata', 'ws-1'])
expect(statuses).toEqual({
type: 'inArray',
column: 'status',
values: ['pending', 'processing', 'completed', 'completed_with_warnings', 'failed'],
})
})
it('also matches sync/rollback rows keyed to the forks of the workspace (pre-otherWorkspaceId history)', async () => {
mockChildrenLookup(['fork-1', 'fork-2'])
await listSurfacedBackgroundWork(executor, 'ws-1')
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
const involves = (rowsWhere.conditions as MockCondition[])[0]
expect(involves.conditions).toHaveLength(4)
const childKeyedClause = (involves.conditions as MockCondition[])[3]
expect(childKeyedClause).toEqual({
type: 'and',
conditions: [
{ type: 'inArray', column: 'workspaceId', values: ['fork-1', 'fork-2'] },
{ type: 'inArray', column: 'kind', values: ['fork_sync', 'fork_rollback'] },
],
})
})
it('omits the child-keyed clause when the workspace has no forks', async () => {
mockChildrenLookup([])
await listSurfacedBackgroundWork(executor, 'ws-1')
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
const involves = (rowsWhere.conditions as MockCondition[])[0]
expect(involves.conditions).toHaveLength(3)
})
})
@@ -0,0 +1,348 @@
import { backgroundWorkStatus, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, desc, eq, inArray, isNull, lt, lte, or, type SQL, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('ForkBackgroundWork')
export type BackgroundWorkKind =
| 'deployment_side_effects'
| 'fork_content_copy'
| 'fork_sync'
| 'fork_rollback'
export type BackgroundWorkStatusValue =
| 'pending'
| 'processing'
| 'completed'
| 'completed_with_warnings'
| 'failed'
/** Statuses surfaced as recent jobs - active, completed, or a recent issue. */
const SURFACED_STATUSES: BackgroundWorkStatusValue[] = [
'pending',
'processing',
'completed',
'completed_with_warnings',
'failed',
]
/** Default page size for the workspace's Activity tab (mirrors the audit log's). */
const BACKGROUND_WORK_PAGE_SIZE = 50
/** Server-side cap on the requested page size (mirrors the audit log's). */
const BACKGROUND_WORK_PAGE_SIZE_MAX = 100
/**
* An active (pending/processing) row older than this is treated as abandoned: the
* worker crashed or restarted before {@link finishBackgroundWork}, and a hard crash
* (Trigger CRASHED / process death) fires no in-task hook. The outbox-processor cron
* sweeps these via {@link reapStaleBackgroundWork}, marking them `failed` so the UI
* surfaces the failure instead of spinning forever.
*/
const STALE_ACTIVE_MS = 30 * 60 * 1000
/** Terminal rows older than this are pruned by the cron so the audit trail stays bounded. */
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
export interface BackgroundWorkRow {
id: string
workspaceId: string
workflowId: string | null
kind: BackgroundWorkKind
status: BackgroundWorkStatusValue
message: string | null
error: string | null
metadata: unknown
startedAt: Date
completedAt: Date | null
updatedAt: Date
}
/**
* Begin tracking a unit of background work, returning its id. Any prior row for the
* same scope (workspace + workflow + kind) is removed first so exactly one status per
* scope is live - a fresh run supersedes a stale completed/failed one.
*/
export async function startBackgroundWork(
executor: DbOrTx,
params: {
workspaceId: string
workflowId?: string | null
kind: BackgroundWorkKind
message?: string
metadata?: unknown
/**
* Replace any prior row for the same scope (workspace + workflow + kind) so exactly
* one status per scope is live (default). Pass `false` to keep an append-only audit
* trail - e.g. fork jobs, where each fork is a distinct historical entry.
*/
supersede?: boolean
}
): Promise<string> {
const { workspaceId, workflowId = null, kind, message, metadata, supersede = true } = params
if (supersede) {
await executor
.delete(backgroundWorkStatus)
.where(
and(
eq(backgroundWorkStatus.workspaceId, workspaceId),
eq(backgroundWorkStatus.kind, kind),
workflowId == null
? isNull(backgroundWorkStatus.workflowId)
: eq(backgroundWorkStatus.workflowId, workflowId)
)
)
}
const id = generateId()
const now = new Date()
await executor.insert(backgroundWorkStatus).values({
id,
workspaceId,
workflowId,
kind,
status: 'processing',
message: message ?? null,
metadata: metadata ?? null,
startedAt: now,
updatedAt: now,
})
return id
}
/**
* Record a synchronous operation directly as a terminal audit entry (no `processing`
* phase). Append-only - used by sync/rollback, which complete in-request, so they show
* up in the same workspace audit log as fork jobs.
*/
export async function recordBackgroundWork(
executor: DbOrTx,
params: {
workspaceId: string
kind: BackgroundWorkKind
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
message?: string
error?: string
metadata?: unknown
}
): Promise<void> {
const now = new Date()
await executor.insert(backgroundWorkStatus).values({
id: generateId(),
workspaceId: params.workspaceId,
workflowId: null,
kind: params.kind,
status: params.status,
message: params.message ?? null,
error: params.error ?? null,
metadata: params.metadata ?? null,
startedAt: now,
completedAt: now,
updatedAt: now,
})
}
/** Mark a tracked unit of work terminal (completed / completed_with_warnings / failed). */
export async function finishBackgroundWork(
executor: DbOrTx,
id: string,
params: {
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
message?: string
error?: string
metadata?: unknown
}
): Promise<void> {
const now = new Date()
// Merge metadata so terminal counts (copied/failed) augment what start recorded
// (child name, per-kind plan) instead of replacing it.
let metadata: unknown
if (params.metadata !== undefined) {
const [existing] = await executor
.select({ metadata: backgroundWorkStatus.metadata })
.from(backgroundWorkStatus)
.where(eq(backgroundWorkStatus.id, id))
.limit(1)
metadata = { ...toMetadataRecord(existing?.metadata), ...toMetadataRecord(params.metadata) }
}
await executor
.update(backgroundWorkStatus)
.set({
status: params.status,
message: params.message ?? null,
error: params.error ?? null,
...(params.metadata !== undefined ? { metadata } : {}),
completedAt: now,
updatedAt: now,
})
.where(eq(backgroundWorkStatus.id, id))
}
/** Coerce an unknown jsonb metadata value to a plain record for safe merging. */
function toMetadataRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
/** Keyset position of the last row of a page: `updatedAt` plus the `id` tiebreaker. */
interface BackgroundWorkCursorData {
updatedAt: string
id: string
}
/** Encodes the keyset position as an opaque base64 cursor (mirrors the audit log's). */
function encodeCursor(data: BackgroundWorkCursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): BackgroundWorkCursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
/**
* Keyset condition for rows strictly after the cursor position in
* `updatedAt DESC, id DESC` order: older `updatedAt`, or the same `updatedAt`
* (which is not unique) with a smaller `id`. Null for an invalid cursor, which
* degrades to the first page rather than erroring.
*/
function buildCursorCondition(cursor: string): SQL<unknown> | null {
const cursorData = decodeCursor(cursor)
if (!cursorData?.updatedAt || !cursorData.id) return null
const cursorDate = new Date(cursorData.updatedAt)
if (Number.isNaN(cursorDate.getTime())) return null
return or(
lt(backgroundWorkStatus.updatedAt, cursorDate),
and(eq(backgroundWorkStatus.updatedAt, cursorDate), lt(backgroundWorkStatus.id, cursorData.id))
)!
}
export interface BackgroundWorkPage {
rows: BackgroundWorkRow[]
/** Cursor for the next page; null when this page is the last. */
nextCursor: string | null
}
/**
* Recent background-work jobs involving a workspace - the durable audit record the
* Activity view renders, most-recent first and keyset-paginated (`updatedAt DESC,
* id DESC`, cursor + limit mirroring the audit log's `queryAuditLogs`). Fork jobs
* are append-only (one row per fork), so this is the workspace's fork history;
* older rows are pruned by the cron.
*
* Every fork event is recorded ONCE, keyed to the workspace it was initiated from
* (fork-create → the parent; sync/rollback/sync-copy → the workspace whose page ran
* it), so "involving" matches both sides of each edge without double-writing rows:
*
* - rows keyed to this workspace (its own forks, syncs it ran, its rollbacks);
* - rows whose `metadata.childWorkspaceId` is this workspace (its own creation,
* recorded on the parent);
* - rows whose `metadata.otherWorkspaceId` is this workspace (the other side of a
* sync/rollback/sync-copy edge);
* - sync/rollback rows keyed to one of this workspace's forks - a fork's only sync
* edge is its parent, so these are guaranteed edge events (covers rows written
* before `metadata.otherWorkspaceId` existed).
*/
export async function listSurfacedBackgroundWork(
executor: DbOrTx,
workspaceId: string,
options?: { cursor?: string; limit?: number }
): Promise<BackgroundWorkPage> {
const limit = Math.min(
Math.max(options?.limit ?? BACKGROUND_WORK_PAGE_SIZE, 1),
BACKGROUND_WORK_PAGE_SIZE_MAX
)
const childRows = await executor
.select({ id: workspace.id })
.from(workspace)
.where(and(eq(workspace.forkedFromWorkspaceId, workspaceId), isNull(workspace.archivedAt)))
const childWorkspaceIds = childRows.map((row) => row.id)
const involvesWorkspace = or(
eq(backgroundWorkStatus.workspaceId, workspaceId),
sql`${backgroundWorkStatus.metadata} ->> 'childWorkspaceId' = ${workspaceId}`,
sql`${backgroundWorkStatus.metadata} ->> 'otherWorkspaceId' = ${workspaceId}`,
...(childWorkspaceIds.length > 0
? [
and(
inArray(backgroundWorkStatus.workspaceId, childWorkspaceIds),
inArray(backgroundWorkStatus.kind, ['fork_sync', 'fork_rollback'])
),
]
: [])
)
const conditions = [involvesWorkspace, inArray(backgroundWorkStatus.status, SURFACED_STATUSES)]
if (options?.cursor) {
const cursorCondition = buildCursorCondition(options.cursor)
if (cursorCondition) conditions.push(cursorCondition)
}
// Over-fetch by one row to learn whether another page exists (audit-log pattern).
const rows = (await executor
.select()
.from(backgroundWorkStatus)
.where(and(...conditions))
.orderBy(desc(backgroundWorkStatus.updatedAt), desc(backgroundWorkStatus.id))
.limit(limit + 1)) as BackgroundWorkRow[]
const hasMore = rows.length > limit
const page = rows.slice(0, limit)
const last = page[page.length - 1]
const nextCursor =
hasMore && last ? encodeCursor({ updatedAt: last.updatedAt.toISOString(), id: last.id }) : null
return { rows: page, nextCursor }
}
/**
* Fail background-work rows stuck in an active state past {@link STALE_ACTIVE_MS}: the
* worker crashed or restarted before writing a terminal status, and a hard crash has
* no in-task hook to recover from. Marks them `failed` so the UI shows the failure
* rather than an indefinitely-spinning banner. Touches ONLY `background_work_status`.
* Returns the count reaped; invoked from the outbox-processor cron.
*/
export async function reapStaleBackgroundWork(executor: DbOrTx): Promise<number> {
const now = new Date()
const cutoff = new Date(now.getTime() - STALE_ACTIVE_MS)
const reaped = await executor
.update(backgroundWorkStatus)
.set({
status: 'failed',
error: 'Background work did not finish in time (the worker may have restarted).',
completedAt: now,
updatedAt: now,
})
.where(
and(
inArray(backgroundWorkStatus.status, ['pending', 'processing']),
lte(backgroundWorkStatus.startedAt, cutoff)
)
)
.returning({ id: backgroundWorkStatus.id })
// Retention: the append-only fork audit trail would otherwise grow forever, so drop
// terminal rows past the retention window. The Activity tab paginates separately.
await executor
.delete(backgroundWorkStatus)
.where(
and(
inArray(backgroundWorkStatus.status, ['completed', 'completed_with_warnings', 'failed']),
lte(backgroundWorkStatus.updatedAt, new Date(now.getTime() - RETENTION_MS))
)
)
if (reaped.length > 0) {
logger.warn('Reaped stale background-work rows', {
count: reaped.length,
thresholdMs: STALE_ACTIVE_MS,
})
}
return reaped.length
}
@@ -0,0 +1,433 @@
/**
* @vitest-environment node
*/
import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const dbMock = vi.hoisted(() => {
const reads = new Map<unknown, unknown[][]>()
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = []
const deletes: Array<{ table: unknown }> = []
const nextPage = (table: unknown): unknown[] => {
const pages = reads.get(table)
return pages && pages.length > 0 ? (pages.shift() as unknown[]) : []
}
// A drizzle-style read builder bound to one table: `.where`/`.orderBy`/`.limit` chain back to
// the same builder, and awaiting it (at `.where()` or `.limit()`) shifts that table's next page.
const makeReadBuilder = (table: unknown) => {
const builder = {
where: () => builder,
orderBy: () => builder,
limit: () => builder,
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (error: unknown) => unknown) =>
Promise.resolve(nextPage(table)).then(onFulfilled, onRejected),
}
return builder
}
const db = {
select: () => ({ from: (table: unknown) => makeReadBuilder(table) }),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => {
updates.push({ table, values })
return Promise.resolve([])
},
}),
}),
delete: (table: unknown) => ({
where: () => {
deletes.push({ table })
return Promise.resolve([])
},
}),
}
return {
db,
updates,
deletes,
queueRead: (table: unknown, ...pages: unknown[][]) => reads.set(table, pages),
reset: () => {
reads.clear()
updates.length = 0
deletes.length = 0
},
}
})
const { mockInvalidateDeployedStateCache } = vi.hoisted(() => ({
mockInvalidateDeployedStateCache: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: dbMock.db,
dbReplica: dbMock.db,
runOutsideTransactionContext: <T>(fn: () => T): T => fn(),
instrumentPoolClient: <T>(client: T): T => client,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
invalidateDeployedStateCache: mockInvalidateDeployedStateCache,
CREDENTIAL_SUBBLOCK_IDS: new Set(['credential', 'manualCredential', 'triggerCredentials']),
}))
// The reference indexer resolves a tool's params via the tool registry; stub it so loading the
// remap module never pulls the full registry (this file only exercises top-level selectors).
vi.mock('@/tools/params', () => ({
getToolIdForOperation: () => undefined,
getToolParametersConfig: () => null,
getSubBlocksForToolInput: (
_toolId: string,
_type: string,
_values: unknown,
_modes: unknown,
provided?: { subBlocks?: SubBlockConfig[] }
) => ({ subBlocks: provided?.subBlocks ?? [] }),
formatParameterLabel: (label: string) => label,
}))
import { getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import {
clearFailedForkResourceReferences,
clearFailedReferencesInDeploymentVersions,
clearFailedReferencesInWorkflows,
rewriteDeploymentVersionState,
} from '@/ee/workspace-forking/lib/copy/cleanup-failed'
import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Knowledge', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
/** A KB block whose `documentId` (document-selector) hangs off `knowledgeBaseId` (kb-selector). */
const kbBlockConfig = () =>
blockWith([
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
{ id: 'documentId', title: 'Doc', type: 'document-selector', dependsOn: ['knowledgeBaseId'] },
])
/** An agent block whose `tools` tool-input holds a KB tool with a nested `knowledgeBaseId` param. */
const agentToolConfig = (type: string): BlockConfig => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'kbtool')
return blockWith([{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }])
return undefined as unknown as BlockConfig
}
/** A deployment-version state whose agent block references `kbId` inside a tool-input tool param. */
const agentVersionState = (kbId: string) => ({
blocks: {
'agent-1': {
id: 'agent-1',
type: 'agent',
name: 'Agent',
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [{ type: 'kbtool', toolId: 'kbtool_search', params: { knowledgeBaseId: kbId } }],
},
},
},
},
edges: [],
loops: {},
parallels: {},
})
type AgentStateBlocks = {
blocks: {
'agent-1': { subBlocks: { tools: { value: Array<{ params: { knowledgeBaseId: string } }> } } }
}
}
const nestedKbValue = (state: unknown) =>
(state as AgentStateBlocks).blocks['agent-1'].subBlocks.tools.value[0].params.knowledgeBaseId
/** A serialized deployment-version state whose single block points its KB selector at `kbId`. */
const versionState = (kbId: string) => ({
blocks: {
'block-1': {
id: 'block-1',
type: 'knowledge',
name: 'KB Block',
subBlocks: {
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: kbId },
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-keep' },
},
},
},
edges: [],
loops: {},
parallels: {},
})
const draftBlockRow = (kbId: string) => ({
id: 'b-1',
workflowId: 'wf-1',
type: 'knowledge',
subBlocks: {
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: kbId },
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-keep' },
},
})
/** A draft block whose `file-upload` subblock points at a copied workspace-file storage key. */
const fileBlockRow = (fileKey: string) => ({
id: 'b-file',
workflowId: 'wf-1',
type: 'agent',
subBlocks: {
file: { id: 'file', type: 'file-upload', value: { key: fileKey, name: 'a.png' } },
},
})
const failedKbResolver: ForkCopyResolver = (kind, id) =>
kind === 'knowledge-base' && id === 'failed-kb' ? null : id
const failedByKind = () =>
new Map<ForkRemapKind, Set<string>>([['knowledge-base', new Set(['failed-kb'])]])
type StateBlocks = { blocks: Record<string, { subBlocks: Record<string, { value: unknown }> }> }
const kbValue = (state: unknown) =>
(state as StateBlocks).blocks['block-1'].subBlocks.knowledgeBaseId.value
const docValue = (state: unknown) =>
(state as StateBlocks).blocks['block-1'].subBlocks.documentId.value
describe('cleanup-failed', () => {
beforeEach(() => {
vi.clearAllMocks()
dbMock.reset()
vi.mocked(getBlock).mockReturnValue(kbBlockConfig())
})
describe('rewriteDeploymentVersionState', () => {
it('clears a block ref that resolves to a failed id and its dependents', () => {
const result = rewriteDeploymentVersionState(versionState('failed-kb'), failedKbResolver)
expect(result.changed).toBe(true)
expect(kbValue(result.state)).toBe('')
// documentId hangs off knowledgeBaseId, so the cleared parent clears it too.
expect(docValue(result.state)).toBe('')
})
it('leaves a state that references no failed id untouched (same reference, not changed)', () => {
const input = versionState('other-kb')
const result = rewriteDeploymentVersionState(input, failedKbResolver)
expect(result.changed).toBe(false)
expect(result.state).toBe(input)
})
it('is tolerant of a malformed state shape', () => {
const input = { not: 'a workflow state' }
const result = rewriteDeploymentVersionState(input, failedKbResolver)
expect(result.changed).toBe(false)
expect(result.state).toBe(input)
})
// The deployed-version sweep must clear EVERY subblock variety the draft sweep does -
// including a failed id nested in an agent block's `tool-input` tool params, not only
// top-level selectors - via the shared remapForkSubBlocks/clearFailedSubBlockReferences.
it('clears a failed id nested in an agent tool-input param inside a deployed version', () => {
vi.mocked(getBlock).mockImplementation((type) => agentToolConfig(type))
const result = rewriteDeploymentVersionState(agentVersionState('failed-kb'), failedKbResolver)
expect(result.changed).toBe(true)
expect(nestedKbValue(result.state)).toBe('')
})
it('leaves an agent tool-input param that references no failed id untouched', () => {
vi.mocked(getBlock).mockImplementation((type) => agentToolConfig(type))
const input = agentVersionState('other-kb')
const result = rewriteDeploymentVersionState(input, failedKbResolver)
expect(result.changed).toBe(false)
expect(result.state).toBe(input)
})
})
describe('clearFailedReferencesInWorkflows', () => {
it('sweeps the draft blocks and returns the affected workflow ids', async () => {
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')])
const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test')
expect([...affected]).toEqual(['wf-1'])
expect(dbMock.updates).toHaveLength(1)
expect(dbMock.updates[0].table).toBe(workflowBlocks)
const cleared = dbMock.updates[0].values.subBlocks as Record<string, { value: unknown }>
expect(cleared.knowledgeBaseId.value).toBe('')
expect(cleared.documentId.value).toBe('')
})
it('returns an empty set and writes nothing when no block references a failed id', async () => {
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')])
const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test')
expect(affected.size).toBe(0)
expect(dbMock.updates).toHaveLength(0)
})
})
describe('clearFailedReferencesInDeploymentVersions', () => {
it('rewrites a version referencing a failed id and invalidates its deployed-state cache', async () => {
dbMock.queueRead(workflowDeploymentVersion, [
{ id: 'dv-1', version: 5, state: versionState('failed-kb') },
])
await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test')
expect(dbMock.updates).toHaveLength(1)
expect(dbMock.updates[0].table).toBe(workflowDeploymentVersion)
expect(kbValue(dbMock.updates[0].values.state)).toBe('')
expect(docValue(dbMock.updates[0].values.state)).toBe('')
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1)
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1')
})
it('leaves a version that does not reference a failed id unwritten and uncached', async () => {
dbMock.queueRead(workflowDeploymentVersion, [
{ id: 'dv-old', version: 3, state: versionState('other-kb') },
])
await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test')
expect(dbMock.updates).toHaveLength(0)
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
})
it('writes only the changed version when a workflow mixes referencing and non-referencing versions', async () => {
dbMock.queueRead(workflowDeploymentVersion, [
{ id: 'dv-active', version: 5, state: versionState('failed-kb') },
{ id: 'dv-old', version: 4, state: versionState('other-kb') },
])
await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test')
expect(dbMock.updates).toHaveLength(1)
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1)
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active')
})
it('does nothing when no workflows were affected', async () => {
await clearFailedReferencesInDeploymentVersions(new Set(), failedByKind(), 'test')
expect(dbMock.updates).toHaveLength(0)
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
})
})
describe('clearFailedForkResourceReferences', () => {
it('threads the draft sweep into the deployed sweep, then drops the placeholder', async () => {
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')])
dbMock.queueRead(workflowDeploymentVersion, [
{ id: 'dv-active', version: 5, state: versionState('failed-kb') },
{ id: 'dv-old', version: 4, state: versionState('other-kb') },
])
const cleaned = await clearFailedForkResourceReferences({
childWorkspaceId: 'child-ws',
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
requestId: 'test',
})
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
// One draft block update + one deployed version update (only the referencing version).
const updatedTables = dbMock.updates.map((u) => u.table)
expect(updatedTables).toEqual([workflowBlocks, workflowDeploymentVersion])
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1)
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active')
// The orphaned KB placeholder is dropped after both sweeps.
expect(dbMock.deletes).toHaveLength(1)
expect(dbMock.deletes[0].table).toBe(knowledgeBase)
})
it('still drops the placeholder when no workflow referenced the failed resource', async () => {
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')])
const cleaned = await clearFailedForkResourceReferences({
childWorkspaceId: 'child-ws',
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
requestId: 'test',
})
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
// No draft block referenced the failed id AND no deployed targets were threaded, so the
// deployed sweep is skipped entirely.
expect(dbMock.updates).toHaveLength(0)
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
expect(dbMock.deletes).toHaveLength(1)
expect(dbMock.deletes[0].table).toBe(knowledgeBase)
})
it('sweeps a deployed target version even when no draft referenced the failed id', async () => {
// Draft is clean (other-kb), but a deployed target version still points at the dropped
// placeholder - the deployed-target scope (not draft divergence) catches it.
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')])
dbMock.queueRead(workflowDeploymentVersion, [
{ id: 'dv-1', version: 5, state: versionState('failed-kb') },
])
const cleaned = await clearFailedForkResourceReferences({
childWorkspaceId: 'child-ws',
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
deployedTargetWorkflowIds: ['wf-deployed'],
requestId: 'test',
})
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
expect(dbMock.updates.map((u) => u.table)).toContain(workflowDeploymentVersion)
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1')
// Clearing succeeded, so the placeholder is dropped.
expect(dbMock.deletes[0].table).toBe(knowledgeBase)
})
it('clears a file-upload reference to a failed copied blob and drops no row', async () => {
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
dbMock.queueRead(workflowBlocks, [fileBlockRow('workspace/child/failed.png')])
const cleaned = await clearFailedForkResourceReferences({
childWorkspaceId: 'child-ws',
failures: [{ kind: 'file', childKey: 'workspace/child/failed.png' }],
requestId: 'test',
})
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
expect(dbMock.updates).toHaveLength(1)
expect(dbMock.updates[0].table).toBe(workflowBlocks)
const cleared = dbMock.updates[0].values.subBlocks as Record<string, { value: unknown }>
expect(cleared.file.value).toBe('')
// A failed file has no placeholder row to drop (the metadata row stays re-uploadable).
expect(dbMock.deletes).toHaveLength(0)
})
it('reports cleared:0 + clearingFailed and skips the placeholder drop when a clear phase throws', async () => {
// A clear-phase failure must not drop the placeholder: that would turn an empty placeholder
// into a dangling reference to a deleted row. Make the draft block UPDATE throw.
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')])
const originalUpdate = dbMock.db.update
dbMock.db.update = () => {
throw new Error('update failed')
}
try {
const cleaned = await clearFailedForkResourceReferences({
childWorkspaceId: 'child-ws',
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
requestId: 'test',
})
// The count must NOT overstate: nothing was cleared and the flag marks cleanup incomplete.
expect(cleaned).toEqual({ cleared: 0, clearingFailed: true })
} finally {
dbMock.db.update = originalUpdate
}
// The drop is skipped, so the placeholder row survives (no delete issued).
expect(dbMock.deletes).toHaveLength(0)
})
})
})
@@ -0,0 +1,364 @@
import { db } from '@sim/db'
import {
document,
knowledgeBase,
userTableDefinitions,
workflow,
workflowBlocks,
workflowDeploymentVersion,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, asc, eq, gt, inArray } from 'drizzle-orm'
import { isRecord, type SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils'
import type { ForkFailedResource } from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
import {
clearDependentsOnRemap,
type ForkRemapKind,
remapForkSubBlocks,
} from '@/ee/workspace-forking/lib/remap/remap-references'
const logger = createLogger('WorkspaceForkCleanupFailed')
/** Child workflow ids loaded per page so the block sweep never materializes the whole workspace. */
const WORKFLOW_PAGE = 200
/** Deployment versions loaded per page so a workflow with many versions never loads all at once. */
const DEPLOYMENT_VERSION_PAGE = 100
/** Identity-or-clear resolver: a failed id resolves to null (cleared), any other id to itself. */
function buildFailedResolver(failedByKind: Map<ForkRemapKind, Set<string>>): ForkCopyResolver {
return (kind, id) => (failedByKind.get(kind)?.has(id) ? null : id)
}
/**
* Apply the identity-or-clear resolver to one block's subBlocks: a value (top-level selector or
* nested tool param) that resolves to a failed id is cleared, and its `dependsOn` children with it;
* everything else is left untouched. Returns the rewritten record plus whether anything changed.
* Shared by the draft block sweep and the deployment-version state sweep so both clear identically.
*/
function clearFailedSubBlockReferences(
subBlocks: SubBlockRecord,
blockType: string,
resolve: ForkCopyResolver
): { subBlocks: SubBlockRecord; changed: boolean } {
const result = remapForkSubBlocks(subBlocks, resolve, 'create')
// remappedKeys is non-empty only when a failed id was actually cleared, so a block that
// referenced nothing failed is reported unchanged without a write.
if (result.remappedKeys.size === 0) return { subBlocks, changed: false }
return {
subBlocks: clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys),
changed: true,
}
}
/**
* Clean up after a resource whose post-commit content fill failed: clear every subblock
* reference in the child workspace's workflows that points at the failed resource (so no
* subblock keeps a dead id), then drop the orphaned placeholder rows (a KB cascade-drops its
* documents + embeddings; a table cascade-drops its rows). In-content references inside copied
* skill/markdown bodies are intentionally left as graceful broken links rather than mutated.
*
* The deployed-version sweep covers the draft-affected workflows UNION this sync's deployed
* target workflows (`deployedTargetWorkflowIds`): a deployed version can reference the dropped
* placeholder even when the draft no longer does (the user edited the empty-looking block in the
* fill window), so scoping to draft divergence alone would miss it.
*
* Best-effort and isolated: a failure cleaning one resource is logged and the rest continue, so
* a cleanup error never aborts the others. The placeholder drop is SKIPPED when a reference-clear
* phase threw - dropping it then would turn an empty placeholder into a dangling reference to a
* deleted row; leaving it keeps the reference resolvable (to empty content) until a later retry.
*
* Returns `{ cleared, clearingFailed }` for the fork activity metadata. `clearingFailed` is true
* when a reference-clear phase threw - placeholders were then NOT dropped - and `cleared` is 0 in
* that case, so the report never claims references it did not actually clear. On success `cleared`
* is the count of failed resources whose references were cleared.
*
* Storage accounting: this cleanup never decrements storage usage because it never removes
* anything that was counted. Copied file blobs are the only counted copies (incremented in
* `executeForkFileBlobCopies` only after the blob lands), and a failed file's blob never
* landed - its metadata row is intentionally left re-uploadable, and nothing was charged. The
* dropped table/KB/document placeholders are DB rows the upload path never counts, and any KB
* blobs copied before their KB failed are left in storage (rows only are dropped here) but
* uncounted - mirroring the KB upload path, which never counts KB blobs.
*/
export async function clearFailedForkResourceReferences(params: {
childWorkspaceId: string
failures: ForkFailedResource[]
/** Target workflows this sync deployed; their deployed versions are swept regardless of draft. */
deployedTargetWorkflowIds?: string[]
requestId?: string
}): Promise<{ cleared: number; clearingFailed: boolean }> {
const { childWorkspaceId, failures, requestId = 'unknown' } = params
if (failures.length === 0) return { cleared: 0, clearingFailed: false }
const failedByKind = new Map<ForkRemapKind, Set<string>>()
const markFailed = (kind: ForkRemapKind, id: string) => {
const set = failedByKind.get(kind)
if (set) set.add(id)
else failedByKind.set(kind, new Set([id]))
}
const tableIds: string[] = []
const kbIds: string[] = []
// Standalone documents copied into an already-existing target KB (the doc-into-mapped-KB sync
// path) - dropped individually, since their KB is not ours to remove.
const docIds: string[] = []
for (const failure of failures) {
if (failure.kind === 'table') {
markFailed('table', failure.childId)
tableIds.push(failure.childId)
} else if (failure.kind === 'knowledge-document') {
markFailed('knowledge-document', failure.childId)
docIds.push(failure.childId)
} else if (failure.kind === 'file') {
// A failed file blob: clear `file-upload` references to its copied storage key. No row to
// drop - the metadata row is left in place so the user can re-upload the missing blob.
markFailed('file', failure.childKey)
} else {
markFailed('knowledge-base', failure.childId)
for (const docId of failure.documentChildIds) markFailed('knowledge-document', docId)
kbIds.push(failure.childId)
}
}
// Whether BOTH reference-clear phases completed without throwing. The placeholder drop below is
// gated on this: if clearing threw, a workflow (draft or deployed version) may still reference
// the failed id, so dropping its placeholder would create a dangling reference to a deleted row.
let clearingSucceeded = true
let affectedWorkflowIds: Set<string> = new Set()
try {
affectedWorkflowIds = await clearFailedReferencesInWorkflows(
childWorkspaceId,
failedByKind,
requestId
)
} catch (error) {
clearingSucceeded = false
logger.error(`[${requestId}] Failed to clear references for failed fork resources`, {
childWorkspaceId,
error: getErrorMessage(error),
})
}
// The same dead id also lives in DEPLOYED version states (the active one re-cut from the
// placeholder-referencing draft at this sync's redeploy, plus any newer version from a rare
// race), not only the draft just swept above. Sweep those too so "Load deployment" can't
// re-poison the cleaned draft with a dropped id, and the execute path never runs against content
// that no longer exists. Scope is the draft-affected workflows UNION this sync's deployed target
// workflows: a deployed version may reference the placeholder even when the draft no longer does
// (edited in the fill window), so draft divergence alone is not a reliable scope. Isolated: a
// failure here never aborts a sibling resource's cleanup.
const deployedSweepIds = new Set(affectedWorkflowIds)
for (const id of params.deployedTargetWorkflowIds ?? []) deployedSweepIds.add(id)
try {
await clearFailedReferencesInDeploymentVersions(deployedSweepIds, failedByKind, requestId)
} catch (error) {
clearingSucceeded = false
logger.error(`[${requestId}] Failed to clear references in fork deployment versions`, {
childWorkspaceId,
error: getErrorMessage(error),
})
}
// Drop the orphaned placeholders. The KB delete cascades its documents + embeddings; the
// table delete cascades its rows; a standalone document delete cascades its embeddings. Done
// after the refs are cleared so a drop failure can't strand a workflow still pointing at the
// (now content-less) resource - and ONLY when clearing succeeded, so a clear failure never
// turns an empty placeholder into a dangling reference to a deleted row.
if (!clearingSucceeded) {
logger.warn(
`[${requestId}] Skipping fork resource placeholder drop after a reference-clear failure`,
{
childWorkspaceId,
tables: tableIds.length,
knowledgeBases: kbIds.length,
documents: docIds.length,
}
)
return { cleared: 0, clearingFailed: true }
}
try {
if (tableIds.length > 0) {
await db.delete(userTableDefinitions).where(inArray(userTableDefinitions.id, tableIds))
}
if (kbIds.length > 0) {
await db.delete(knowledgeBase).where(inArray(knowledgeBase.id, kbIds))
}
if (docIds.length > 0) {
await db.delete(document).where(inArray(document.id, docIds))
}
} catch (error) {
logger.error(`[${requestId}] Failed to drop orphaned fork resource placeholders`, {
childWorkspaceId,
error: getErrorMessage(error),
})
}
return { cleared: failures.length, clearingFailed: false }
}
/**
* Sweep the child workspace's workflow blocks and clear any subblock value (top-level selector
* or nested tool param) that resolves to a failed child resource id. Reuses the create-mode
* remap with an identity-or-clear resolver: a non-failed id resolves to itself (left unchanged),
* a failed id resolves to null and is cleared, and its `dependsOn` children are cleared too.
* Returns the ids of the workflows whose blocks actually had a reference cleared, so the deployed
* version sweep can scope itself to exactly those workflows.
*/
export async function clearFailedReferencesInWorkflows(
childWorkspaceId: string,
failedByKind: Map<ForkRemapKind, Set<string>>,
requestId: string
): Promise<Set<string>> {
const resolve = buildFailedResolver(failedByKind)
const affectedWorkflowIds = new Set<string>()
let afterWorkflowId: string | null = null
for (;;) {
const workflowRows = await db
.select({ id: workflow.id })
.from(workflow)
.where(
afterWorkflowId === null
? eq(workflow.workspaceId, childWorkspaceId)
: and(eq(workflow.workspaceId, childWorkspaceId), gt(workflow.id, afterWorkflowId))
)
.orderBy(asc(workflow.id))
.limit(WORKFLOW_PAGE)
if (workflowRows.length === 0) break
const workflowIds = workflowRows.map((row) => row.id)
const blocks = await db
.select({
id: workflowBlocks.id,
workflowId: workflowBlocks.workflowId,
type: workflowBlocks.type,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(inArray(workflowBlocks.workflowId, workflowIds))
for (const block of blocks) {
const current = (block.subBlocks ?? {}) as SubBlockRecord
const { subBlocks: cleared, changed } = clearFailedSubBlockReferences(
current,
block.type,
resolve
)
if (!changed) continue
await db
.update(workflowBlocks)
.set({ subBlocks: cleared })
.where(eq(workflowBlocks.id, block.id))
affectedWorkflowIds.add(block.workflowId)
}
if (workflowRows.length < WORKFLOW_PAGE) break
afterWorkflowId = workflowIds[workflowIds.length - 1]
}
return affectedWorkflowIds
}
/**
* Rewrite a deployment version's serialized state in memory, clearing every block subblock that
* resolves to a failed id (and its `dependsOn` children). Returns `changed: false` with the
* original state when no block referenced a failed id - so a version cut before this sync, which
* cannot contain the new placeholder id, is a no-op and never written back. Tolerant of a
* malformed/legacy state shape (anything that is not `{ blocks: {...} }` is left untouched).
*/
export function rewriteDeploymentVersionState(
state: unknown,
resolve: ForkCopyResolver
): { state: unknown; changed: boolean } {
if (!isRecord(state) || !isRecord(state.blocks)) return { state, changed: false }
let nextBlocks: Record<string, unknown> | null = null
for (const [blockId, block] of Object.entries(state.blocks)) {
if (!isRecord(block)) continue
const blockType = typeof block.type === 'string' ? block.type : undefined
if (!blockType || !isRecord(block.subBlocks)) continue
const { subBlocks: cleared, changed } = clearFailedSubBlockReferences(
block.subBlocks as SubBlockRecord,
blockType,
resolve
)
if (!changed) continue
nextBlocks ??= { ...state.blocks }
nextBlocks[blockId] = { ...block, subBlocks: cleared }
}
if (!nextBlocks) return { state, changed: false }
return { state: { ...state, blocks: nextBlocks }, changed: true }
}
/**
* Rewrite the DEPLOYED version states of the workflows whose draft blocks were just swept. The dead
* placeholder id lives in any version re-cut from the (placeholder-referencing) draft at this sync's
* redeploy - in practice the active one - so it must be cleared there too, not only in the draft.
* Every version of each affected workflow is examined, but only versions whose state actually
* changes are written: an older version predates the sync and cannot contain the new id, so it is a
* no-op. After a version is rewritten its cached deployed state is evicted so execute/serve rebuilds
* from the cleaned snapshot. Bounded work (no long transaction): per-version short UPDATEs, versions
* keyset-paginated, and a per-workflow failure is logged without aborting the other workflows.
*/
export async function clearFailedReferencesInDeploymentVersions(
workflowIds: ReadonlySet<string>,
failedByKind: Map<ForkRemapKind, Set<string>>,
requestId: string
): Promise<void> {
if (workflowIds.size === 0) return
const resolve = buildFailedResolver(failedByKind)
for (const workflowId of workflowIds) {
try {
let afterVersion: number | null = null
for (;;) {
const versions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
afterVersion === null
? eq(workflowDeploymentVersion.workflowId, workflowId)
: and(
eq(workflowDeploymentVersion.workflowId, workflowId),
gt(workflowDeploymentVersion.version, afterVersion)
)
)
.orderBy(asc(workflowDeploymentVersion.version))
.limit(DEPLOYMENT_VERSION_PAGE)
if (versions.length === 0) break
for (const version of versions) {
const { state: nextState, changed } = rewriteDeploymentVersionState(
version.state,
resolve
)
if (!changed) continue
await db
.update(workflowDeploymentVersion)
.set({ state: nextState })
.where(eq(workflowDeploymentVersion.id, version.id))
// Evict the post-migration deployed state cached by this immutable version id so the
// execute/serve path rebuilds from the cleaned snapshot.
invalidateDeployedStateCache(version.id)
}
if (versions.length < DEPLOYMENT_VERSION_PAGE) break
afterVersion = versions[versions.length - 1].version
}
} catch (error) {
logger.error(`[${requestId}] Failed to clear references in deployment versions`, {
workflowId,
error: getErrorMessage(error),
})
}
}
}
@@ -0,0 +1,84 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
hasForkContentToCopy,
serializeContentRefMaps,
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files'
import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources'
describe('serializeContentRefMaps', () => {
it('converts each map to a record and drops empty maps', () => {
const result = serializeContentRefMaps({
workspaceId: { from: 'src', to: 'dst' },
fileKeys: new Map([['k1', 'k2']]),
fileIds: new Map(),
workflows: new Map([['wf-src', 'wf-dst']]),
knowledgeBases: new Map([['kb-src', 'kb-dst']]),
skills: new Map([['sk-src', 'sk-dst']]),
})
expect(result.workspaceId).toEqual({ from: 'src', to: 'dst' })
expect(result.fileKeys).toEqual({ k1: 'k2' })
// An empty map is omitted rather than serialized to `{}`.
expect(result.fileIds).toBeUndefined()
expect(result.workflows).toEqual({ 'wf-src': 'wf-dst' })
expect(result.knowledgeBases).toEqual({ 'kb-src': 'kb-dst' })
expect(result.skills).toEqual({ 'sk-src': 'sk-dst' })
// Maps not supplied stay undefined.
expect(result.tables).toBeUndefined()
expect(result.folders).toBeUndefined()
})
it('passes an undefined workspaceId through unchanged', () => {
const result = serializeContentRefMaps({})
expect(result.workspaceId).toBeUndefined()
expect(result.fileKeys).toBeUndefined()
expect(result.skills).toBeUndefined()
})
})
describe('hasForkContentToCopy', () => {
const emptyPlan = (): ForkContentPlan => ({
sourceWorkspaceId: 'src',
childWorkspaceId: 'child',
userId: 'u',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
})
// The helper only inspects array lengths, so a single placeholder entry per kind is enough.
const oneSkill = [{}] as unknown as ForkContentPlan['skills']
const oneDoc = [{}] as unknown as ForkContentPlan['documents']
const oneTable = [{}] as unknown as ForkContentPlan['tables']
const oneKb = [{}] as unknown as ForkContentPlan['knowledgeBases']
const oneBlob = [{}] as unknown as BlobCopyTask[]
const noBlobs: BlobCopyTask[] = []
it('is true when skills are non-empty (the create-fork skill-only fix)', () => {
expect(hasForkContentToCopy({ ...emptyPlan(), skills: oneSkill }, noBlobs)).toBe(true)
})
it('is true when documents are non-empty', () => {
expect(hasForkContentToCopy({ ...emptyPlan(), documents: oneDoc }, noBlobs)).toBe(true)
})
it('is true when tables are non-empty', () => {
expect(hasForkContentToCopy({ ...emptyPlan(), tables: oneTable }, noBlobs)).toBe(true)
})
it('is true when knowledgeBases are non-empty', () => {
expect(hasForkContentToCopy({ ...emptyPlan(), knowledgeBases: oneKb }, noBlobs)).toBe(true)
})
it('is true when there are blob tasks', () => {
expect(hasForkContentToCopy(emptyPlan(), oneBlob)).toBe(true)
})
it('is false for an all-empty plan with no blob tasks', () => {
expect(hasForkContentToCopy(emptyPlan(), noBlobs)).toBe(false)
})
})
@@ -0,0 +1,214 @@
import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { runDetached } from '@/lib/core/utils/background'
import { finishBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
import { clearFailedForkResourceReferences } from '@/ee/workspace-forking/lib/copy/cleanup-failed'
import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files'
import { executeForkFileBlobCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
import type {
ForkContentPlan,
ForkFailedResource,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import { copyForkResourceContent } from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkContentRefMaps } from '@/ee/workspace-forking/lib/remap/remap-content-refs'
const logger = createLogger('WorkspaceForkContentCopy')
/**
* Whether a fork/sync has any heavy content to copy after the commit: table rows, KB documents,
* copied skill bodies, standalone documents, or file blobs. The single gate for scheduling the
* background content fill - shared by fork-create and promote so the two can't diverge. A
* skill-only (or documents-only) copy must still schedule it, otherwise the in-content `sim:` /
* serve-link rewrite in {@link runForkContentCopy} never runs and copied bodies keep source links.
*/
export function hasForkContentToCopy(
contentPlan: ForkContentPlan,
blobTasks: BlobCopyTask[]
): boolean {
return (
contentPlan.tables.length > 0 ||
contentPlan.knowledgeBases.length > 0 ||
contentPlan.skills.length > 0 ||
contentPlan.documents.length > 0 ||
blobTasks.length > 0
)
}
/**
* JSON-serializable form of {@link ForkContentRefMaps} (Maps become Records) so the in-content
* reference maps survive the Trigger.dev payload boundary. Rehydrated to Maps in the runner.
*/
export interface SerializableForkContentRefMaps {
workspaceId?: { from: string; to: string }
fileKeys?: Record<string, string>
fileIds?: Record<string, string>
workflows?: Record<string, string>
knowledgeBases?: Record<string, string>
tables?: Record<string, string>
skills?: Record<string, string>
folders?: Record<string, string>
}
/**
* Serializable payload for the post-fork heavy-content copy. Runs either as a
* Trigger.dev task (`background/fork-content-copy`) or inline via `runDetached`
* when Trigger.dev is disabled - both call {@link runForkContentCopy}.
*/
export interface ForkContentCopyPayload {
contentPlan: ForkContentPlan
blobTasks: BlobCopyTask[]
/** In-content reference maps for rewriting copied markdown blobs (serialized form). */
contentRefMaps?: SerializableForkContentRefMaps
/**
* `background_work_status` row to finish when the copy ends, so the source workspace's
* Manage Forks -> Activity entry resolves (completed / warning / error). Started right
* after the fork commits so it's visible immediately.
*/
statusId?: string
/**
* Target workflow ids this sync deployed (promote's deploy loop). When a copied resource's
* fill fails, its dropped placeholder must be cleared from these workflows' DEPLOYED version
* states too - a deployed version can reference the placeholder even when the draft no longer
* does (edited in the fill window), so the cleanup unions these with the draft-affected set
* rather than relying on draft divergence. Empty/omitted for fork-create (child is undeployed).
*/
deployedTargetWorkflowIds?: string[]
requestId?: string
}
const toRefMap = (record?: Record<string, string>): Map<string, string> | undefined =>
record ? new Map(Object.entries(record)) : undefined
const fromRefMap = (map?: ReadonlyMap<string, string>): Record<string, string> | undefined =>
map && map.size > 0 ? Object.fromEntries(map) : undefined
/**
* Convert the Map-based {@link ForkContentRefMaps} to its JSON-serializable form for the
* Trigger.dev payload. Empty maps are dropped (omitted). Single source of truth for the
* Map->Record direction, paired with {@link deserializeContentRefMaps}.
*/
export function serializeContentRefMaps(maps: ForkContentRefMaps): SerializableForkContentRefMaps {
return {
workspaceId: maps.workspaceId,
fileKeys: fromRefMap(maps.fileKeys),
fileIds: fromRefMap(maps.fileIds),
workflows: fromRefMap(maps.workflows),
knowledgeBases: fromRefMap(maps.knowledgeBases),
tables: fromRefMap(maps.tables),
skills: fromRefMap(maps.skills),
folders: fromRefMap(maps.folders),
}
}
/** Rehydrate the serialized content-ref maps to the Map-based {@link ForkContentRefMaps}. */
function deserializeContentRefMaps(
serialized?: SerializableForkContentRefMaps
): ForkContentRefMaps | undefined {
if (!serialized) return undefined
return {
workspaceId: serialized.workspaceId,
fileKeys: toRefMap(serialized.fileKeys),
fileIds: toRefMap(serialized.fileIds),
workflows: toRefMap(serialized.workflows),
knowledgeBases: toRefMap(serialized.knowledgeBases),
tables: toRefMap(serialized.tables),
skills: toRefMap(serialized.skills),
folders: toRefMap(serialized.folders),
}
}
/**
* Copy the heavy fork content after the fork transaction has committed: table
* rows, KB documents + embeddings (keyset-paginated), and file blobs. Best-effort
* and idempotency-unsafe (per-row inserts use fresh ids), so it must run at most
* once - never blindly retried. Per-resource failures are counted (not thrown), so
* the run finishes `completed_with_warnings` rather than failing the whole copy.
*/
export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise<void> {
const { contentPlan, blobTasks, statusId, requestId } = payload
try {
const contentRefMaps = deserializeContentRefMaps(payload.contentRefMaps)
const resourceCounts = await copyForkResourceContent({ contentPlan, contentRefMaps, requestId })
const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId, contentRefMaps)
// A resource whose content fill failed leaves a dangling reference: a table/KB/doc placeholder
// its workflows still point at, or a `file-upload` whose copied blob is missing. Clear those
// references (draft + deployed versions) and drop the table/KB/doc placeholder so nothing
// dangles; a failed file leaves its metadata row (re-uploadable) but has its refs cleared.
const fileFailures: ForkFailedResource[] = fileCounts.failedTargetKeys.map((childKey) => ({
kind: 'file',
childKey,
}))
const { cleared, clearingFailed } = await clearFailedForkResourceReferences({
childWorkspaceId: contentPlan.childWorkspaceId,
failures: [...resourceCounts.failures, ...fileFailures],
deployedTargetWorkflowIds: payload.deployedTargetWorkflowIds,
requestId,
})
const copied = resourceCounts.copied + fileCounts.copied
const failed = resourceCounts.failed + fileCounts.failed
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: failed > 0 ? 'completed_with_warnings' : 'completed',
message:
failed > 0
? `Copied ${copied} item${copied === 1 ? '' : 's'}; ${failed} could not be copied`
: `Copied ${copied} item${copied === 1 ? '' : 's'}`,
metadata: {
copied,
failed,
clearedReferences: cleared,
...(clearingFailed ? { clearingFailed: true } : {}),
},
})
}
} catch (error) {
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: 'failed',
error: getErrorMessage(error, 'Background resource copy failed'),
}).catch(() => {})
}
throw error
}
}
/**
* Schedule the post-commit heavy-content copy off the request path. Uses the Trigger.dev task
* when enabled (so it survives an app deploy), else `runDetached` inline best-effort. Shared by
* both fork and sync - only the `detachedLabel` (and so the inline job's name) differs. Never
* throws: a scheduling failure is logged and, when a status row exists, marks it failed, so a
* committed fork/sync is never turned into a 500 by a background-scheduling hiccup.
*/
export async function scheduleForkContentCopy(
payload: ForkContentCopyPayload,
options: { detachedLabel: string; requestId?: string }
): Promise<void> {
const { detachedLabel, requestId = 'unknown' } = options
try {
if (isTriggerDevEnabled) {
const [{ forkContentCopyTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/fork-content-copy'),
import('@trigger.dev/sdk'),
import('@/lib/core/async-jobs/region'),
])
await tasks.trigger<typeof forkContentCopyTask>('fork-content-copy', payload, {
region: await resolveTriggerRegion(),
})
} else {
runDetached(detachedLabel, () => runForkContentCopy(payload))
}
} catch (error) {
logger.error(`[${requestId}] Failed to schedule fork content copy`, {
detachedLabel,
error: getErrorMessage(error),
})
if (payload.statusId) {
await finishBackgroundWork(db, payload.statusId, {
status: 'failed',
error: getErrorMessage(error, 'Could not start the background copy'),
}).catch(() => {})
}
}
}
@@ -0,0 +1,153 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import { copyForkChatDeployments } from '@/ee/workspace-forking/lib/copy/copy-chats'
/**
* Sequenced select mock: each `.select().from().where()` resolves the next queued result.
* The chat copy issues (1) source chats, (2) target chat rows, then per identifier attempt
* (3+) the taken-identifier check; inserts are captured.
*/
function makeTx(selectResults: unknown[][]) {
const inserted: Array<Record<string, unknown>> = []
let call = 0
const tx = {
select: () => ({
from: () => ({ where: () => Promise.resolve(selectResults[call++] ?? []) }),
}),
insert: () => ({
values: (values: Array<Record<string, unknown>>) => {
inserted.push(...values)
return Promise.resolve()
},
}),
}
return { tx: tx as unknown as DbOrTx, inserted }
}
const sourceChat = (overrides: Record<string, unknown> = {}) => ({
id: 'chat-src',
workflowId: 'wf-src',
userId: 'src-user',
identifier: 'original-chat',
title: 'Support Chat',
description: 'desc',
isActive: true,
customizations: { welcomeMessage: 'hi' },
authType: 'password',
password: 'hashed-secret',
allowedEmails: ['a@b.com'],
outputConfigs: [{ blockId: 'block-src', path: 'content' }],
archivedAt: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
})
const pair = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
workflowName: 'Support Flow',
}
describe('copyForkChatDeployments', () => {
it('copies a live source chat with a generated identifier and remapped output block ids', async () => {
const { tx, inserted } = makeTx([
[sourceChat()],
[], // target has no chat rows
[], // no identifier collisions
])
const result = await copyForkChatDeployments({
tx,
pairs: [pair],
targetWorkspaceName: 'My Team WS',
userId: 'user-1',
now: new Date('2026-07-07'),
resolveBlockId: (targetWorkflowId, sourceBlockId) =>
`${targetWorkflowId}:${sourceBlockId}:mapped`,
})
expect(result.created).toBe(1)
expect(inserted).toHaveLength(1)
const copy = inserted[0]
expect(copy.id).not.toBe('chat-src')
expect(copy.workflowId).toBe('wf-tgt')
expect(copy.userId).toBe('user-1')
// `{workspace}-{workflow}-{randomnum}` in the identifier charset.
expect(copy.identifier).toMatch(/^my-team-ws-support-flow-\d{6}$/)
// Config copies verbatim - auth (hashed password) included.
expect(copy.title).toBe('Support Chat')
expect(copy.authType).toBe('password')
expect(copy.password).toBe('hashed-secret')
expect(copy.allowedEmails).toEqual(['a@b.com'])
// Output configs bind to the target's block ids via the sync's block resolver.
expect(copy.outputConfigs).toEqual([{ blockId: 'wf-tgt:block-src:mapped', path: 'content' }])
expect(copy.archivedAt).toBeNull()
})
it('leaves a target that already has ANY chat row untouched (live or archived - never resurrects)', async () => {
const { tx, inserted } = makeTx([
[sourceChat()],
[{ workflowId: 'wf-tgt' }], // target already has a chat row
])
const result = await copyForkChatDeployments({
tx,
pairs: [pair],
targetWorkspaceName: 'WS',
userId: 'user-1',
now: new Date(),
resolveBlockId: (_wf, blockId) => blockId,
})
expect(result.created).toBe(0)
expect(inserted).toHaveLength(0)
})
it('never emits duplicate identifiers within a batch (same workspace + workflow slug)', async () => {
const twoChats = makeTx([
[sourceChat(), sourceChat({ id: 'chat-src-2', identifier: 'other' })],
[], // target has no chat rows
[], // no live-identifier collisions
])
const result = await copyForkChatDeployments({
tx: twoChats.tx,
pairs: [pair],
targetWorkspaceName: 'WS',
userId: 'user-1',
now: new Date(),
resolveBlockId: (_wf, blockId) => blockId,
})
expect(result.created).toBe(2)
const identifiers = twoChats.inserted.map((row) => row.identifier)
expect(new Set(identifiers).size).toBe(2)
})
it('no-ops with no pairs or no live source chats', async () => {
const empty = makeTx([[]])
expect(
(
await copyForkChatDeployments({
tx: empty.tx,
pairs: [pair],
targetWorkspaceName: 'WS',
userId: 'user-1',
now: new Date(),
resolveBlockId: (_wf, blockId) => blockId,
})
).created
).toBe(0)
expect(
(
await copyForkChatDeployments({
tx: empty.tx,
pairs: [],
targetWorkspaceName: 'WS',
userId: 'user-1',
now: new Date(),
resolveBlockId: (_wf, blockId) => blockId,
})
).created
).toBe(0)
})
})
@@ -0,0 +1,190 @@
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId, generateShortId } from '@sim/utils/id'
import { randomInt } from '@sim/utils/random'
import { and, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
const logger = createLogger('WorkspaceForkCopyChats')
/** Attempts at a random chat identifier before falling back to a long random suffix. */
const IDENTIFIER_ATTEMPTS = 5
export interface ForkChatCopyPair {
sourceWorkflowId: string
targetWorkflowId: string
/** The target workflow's display name, for the generated chat identifier. */
workflowName: string
}
/** Lowercase a display name into the chat identifier charset (`[a-z0-9-]`), bounded. */
function slugifyForIdentifier(value: string): string {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 24)
.replace(/-+$/g, '')
return slug || 'chat'
}
/**
* `{workspace}-{workflow}-{randomnum}` in the identifier charset. Six digits: concurrent forks
* of one parent share the workspace/workflow slugs and can't see each other's uncommitted rows,
* so the number is the only collision guard across simultaneous transactions.
*/
function buildIdentifierCandidate(workspaceSlug: string, workflowName: string): string {
const random = randomInt(100000, 1000000)
return `${workspaceSlug}-${slugifyForIdentifier(workflowName)}-${random}`
}
/** Remap each output config's `blockId` onto the target workflow's block ids. */
function remapChatOutputConfigs(
value: unknown,
targetWorkflowId: string,
resolveBlockId: (targetWorkflowId: string, sourceBlockId: string) => string
): unknown {
if (!Array.isArray(value)) return value
return value.map((entry) => {
if (!isRecord(entry) || typeof entry.blockId !== 'string') return entry
return { ...entry, blockId: resolveBlockId(targetWorkflowId, entry.blockId) }
})
}
/**
* Carry chat deployments onto the target side of a fork or sync: each LIVE source chat whose
* target workflow has NO chat row at all (live or archived) is copied with a freshly generated
* identifier - `{target-workspace}-{workflow}-{randomnum}` - so the copy serves at its own URL
* immediately once the workflow deploys. Config copies verbatim (title, customizations, auth
* incl. the hashed password and allowed emails); `outputConfigs` block ids are remapped through
* the SAME block-id resolver the workflow write used, so the outputs bind to the target's
* blocks.
*
* Targets with ANY existing chat row are left completely untouched ("maintained"): an already
* carried-over chat keeps its identifier and config on every subsequent sync, and a chat the
* target side deliberately archived is never resurrected. Bounded by the synced workflow count;
* identifiers are collision-checked against live chats and fall back to a long random suffix.
*/
export async function copyForkChatDeployments(params: {
tx: DbOrTx
pairs: ForkChatCopyPair[]
/** The TARGET workspace's display name, the identifier's first segment. */
targetWorkspaceName: string
userId: string
now: Date
resolveBlockId: (targetWorkflowId: string, sourceBlockId: string) => string
requestId?: string
}): Promise<{ created: number }> {
const { tx, pairs, targetWorkspaceName, userId, now, resolveBlockId, requestId } = params
if (pairs.length === 0) return { created: 0 }
const pairBySource = new Map(pairs.map((pair) => [pair.sourceWorkflowId, pair]))
const sourceChats = await tx
.select()
.from(chat)
.where(and(inArray(chat.workflowId, [...pairBySource.keys()]), isNull(chat.archivedAt)))
if (sourceChats.length === 0) return { created: 0 }
// A target workflow with ANY chat row (live or archived) keeps it: live means the carry-over
// already happened (or the target made its own); archived means the target deliberately
// retired it - recreating would resurrect against their intent.
const candidateTargetIds = [
...new Set(
sourceChats
.map((row) => pairBySource.get(row.workflowId)?.targetWorkflowId)
.filter((id): id is string => Boolean(id))
),
]
const existingTargetRows = await tx
.select({ workflowId: chat.workflowId })
.from(chat)
.where(inArray(chat.workflowId, candidateTargetIds))
const targetsWithChat = new Set(existingTargetRows.map((row) => row.workflowId))
const toCopy = sourceChats.filter((row) => {
const pair = pairBySource.get(row.workflowId)
return pair && !targetsWithChat.has(pair.targetWorkflowId)
})
if (toCopy.length === 0) return { created: 0 }
// Generate identifiers, retrying collisions against LIVE chats (the unique index is partial
// on `archived_at IS NULL`, so archived identifiers are reusable) and against this batch.
const workspaceSlug = slugifyForIdentifier(targetWorkspaceName)
const identifierByChatId = new Map<string, string>()
let pending = toCopy.map((row) => ({
chatId: row.id,
workflowName: pairBySource.get(row.workflowId)?.workflowName ?? 'chat',
}))
for (let attempt = 0; attempt < IDENTIFIER_ATTEMPTS && pending.length > 0; attempt++) {
const claimed = new Set(identifierByChatId.values())
const candidates = pending.map((entry) => {
let candidate = buildIdentifierCandidate(workspaceSlug, entry.workflowName)
while (claimed.has(candidate)) {
candidate = buildIdentifierCandidate(workspaceSlug, entry.workflowName)
}
claimed.add(candidate)
return { ...entry, candidate }
})
const taken = new Set(
(
await tx
.select({ identifier: chat.identifier })
.from(chat)
.where(
and(
inArray(
chat.identifier,
candidates.map((entry) => entry.candidate)
),
isNull(chat.archivedAt)
)
)
).map((row) => row.identifier)
)
pending = []
for (const entry of candidates) {
if (taken.has(entry.candidate)) pending.push(entry)
else identifierByChatId.set(entry.chatId, entry.candidate)
}
}
for (const entry of pending) {
// Exhausted the friendly attempts: a long random suffix is effectively collision-free
// (the global unique index still backstops it).
identifierByChatId.set(
entry.chatId,
`${workspaceSlug}-${slugifyForIdentifier(entry.workflowName)}-${generateShortId(10)
.toLowerCase()
.replace(/[^a-z0-9]/g, '0')}`
)
}
const inserts: (typeof chat.$inferInsert)[] = []
for (const row of toCopy) {
const pair = pairBySource.get(row.workflowId)
const identifier = identifierByChatId.get(row.id)
if (!pair || !identifier) continue
inserts.push({
...row,
id: generateId(),
workflowId: pair.targetWorkflowId,
userId,
identifier,
outputConfigs: remapChatOutputConfigs(
row.outputConfigs,
pair.targetWorkflowId,
resolveBlockId
),
archivedAt: null,
createdAt: now,
updatedAt: now,
})
}
if (inserts.length > 0) {
await tx.insert(chat).values(inserts)
logger.info(`[${requestId ?? 'unknown'}] Carried ${inserts.length} chat deployment(s)`, {
identifiers: inserts.map((row) => row.identifier),
})
}
return { created: inserts.length }
}
@@ -0,0 +1,158 @@
/**
* @vitest-environment node
*/
import { storageServiceMock, storageServiceMockFns } from '@sim/testing'
import { omit } from '@sim/utils/object'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIncrementStorageUsage } = vi.hoisted(() => ({
mockIncrementStorageUsage: vi.fn(),
}))
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
vi.mock('@/lib/billing/storage', () => ({
incrementStorageUsage: mockIncrementStorageUsage,
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
generateWorkspaceFileKey: vi.fn(
(workspaceId: string, fileName: string) => `workspace/${workspaceId}/generated-${fileName}`
),
}))
import type { DbOrTx } from '@/lib/db/types'
import {
type BlobCopyTask,
executeForkFileBlobCopies,
planForkFileCopies,
} from '@/ee/workspace-forking/lib/copy/copy-files'
function makeTask(overrides: Partial<BlobCopyTask> = {}): BlobCopyTask {
return {
sourceKey: 'workspace/src-ws/source-a.txt',
targetKey: 'workspace/child-ws/target-a.txt',
context: 'workspace',
fileName: 'a.txt',
contentType: 'text/plain',
size: 100,
userId: 'user-1',
workspaceId: 'child-ws',
...overrides,
}
}
describe('executeForkFileBlobCopies storage accounting', () => {
beforeEach(() => {
vi.clearAllMocks()
storageServiceMockFns.mockHeadObject.mockResolvedValue(null)
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('blob-bytes'))
storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'workspace/child-ws/target' })
mockIncrementStorageUsage.mockResolvedValue(undefined)
})
it('charges the initiating user exactly once per landed blob, by the metadata row size', async () => {
const tasks = [
makeTask({ targetKey: 'workspace/child-ws/t1', size: 100 }),
makeTask({ targetKey: 'workspace/child-ws/t2', size: 200, fileName: 'b.txt' }),
]
const result = await executeForkFileBlobCopies(tasks, 'test')
expect(result).toEqual({ copied: 2, failed: 0, failedTargetKeys: [] })
expect(mockIncrementStorageUsage).toHaveBeenCalledTimes(2)
expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(1, 'user-1', 100, 'child-ws')
expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(2, 'user-1', 200, 'child-ws')
})
it('skips an already-existing target blob without re-copying or re-charging (replayed run)', async () => {
storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 100 })
const result = await executeForkFileBlobCopies([makeTask()], 'test')
expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] })
expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
})
it('never charges a failed copy (the blob did not land)', async () => {
storageServiceMockFns.mockDownloadFile.mockRejectedValue(new Error('source gone'))
const result = await executeForkFileBlobCopies([makeTask()], 'test')
expect(result).toEqual({
copied: 0,
failed: 1,
failedTargetKeys: ['workspace/child-ws/target-a.txt'],
})
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
})
it('treats a tracking failure as best-effort - the copy still counts as landed', async () => {
mockIncrementStorageUsage.mockRejectedValue(new Error('billing hiccup'))
const result = await executeForkFileBlobCopies([makeTask()], 'test')
expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] })
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
})
it('skips the charge for a legacy payload enqueued before size existed', async () => {
// Simulates a Trigger.dev payload serialized by a pre-`size` deploy (rolling upgrade).
const legacyTask = omit(makeTask(), ['size']) as BlobCopyTask
const result = await executeForkFileBlobCopies([legacyTask], 'test')
expect(result.copied).toBe(1)
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
})
})
describe('planForkFileCopies', () => {
it('carries the source metadata size onto each blob task and the child row', async () => {
const sourceMeta = {
id: 'wf_src1',
key: 'workspace/src-ws/1-abc-a.txt',
userId: 'uploader-1',
workspaceId: 'src-ws',
folderId: 'folder-1',
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'),
}
const inserted: Array<Record<string, unknown>> = []
const tx = {
select: vi.fn(() => ({ from: () => ({ where: () => Promise.resolve([sourceMeta]) }) })),
insert: vi.fn(() => ({
values: (row: Record<string, unknown>) => {
inserted.push(row)
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'),
})
expect(result.blobTasks).toHaveLength(1)
expect(result.blobTasks[0]).toMatchObject({
sourceKey: 'workspace/src-ws/1-abc-a.txt',
targetKey: 'workspace/child-ws/generated-a.txt',
size: 4321,
userId: 'user-1',
workspaceId: 'child-ws',
})
expect(inserted[0]).toMatchObject({ size: 4321, workspaceId: 'child-ws' })
})
})
@@ -0,0 +1,242 @@
import { workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
import { incrementStorageUsage } from '@/lib/billing/storage'
import type { DbOrTx } from '@/lib/db/types'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service'
import type { StorageContext } from '@/lib/uploads/shared/types'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import {
type ForkContentRefMaps,
rewriteForkContentRefs,
} from '@/ee/workspace-forking/lib/remap/remap-content-refs'
const logger = createLogger('WorkspaceForkCopyFiles')
const MARKDOWN_CONTENT_TYPES = new Set(['text/markdown', 'text/x-markdown'])
/** Whether a copied blob is markdown text whose in-content references should be rewritten. */
function isMarkdownBlob(task: Pick<BlobCopyTask, 'contentType' | 'fileName'>): boolean {
if (MARKDOWN_CONTENT_TYPES.has(task.contentType)) return true
const name = task.fileName.toLowerCase()
return name.endsWith('.md') || name.endsWith('.mdx') || name.endsWith('.markdown')
}
export interface BlobCopyTask {
sourceKey: string
targetKey: string
context: StorageContext
fileName: string
contentType: string
/**
* Byte size from the source metadata row - the child `workspace_files` row was inserted
* with this same size, so the storage-usage increment after a successful blob copy
* charges exactly the bytes the row advertises (matching the upload path, where the
* incremented bytes always equal the row's `size`).
*/
size: number
userId: string
workspaceId: string
}
export interface PlanForkFileCopiesResult {
/**
* source storage key -> child storage key. `file-upload` subblocks reference
* files by storage key (not `workspace_files.id`), so the fork remap keys on the
* storage key. At sync time this map is persisted in the fork resource map
* (`resourceType: 'file'`, keyed by storage key) so a re-sync resolves the copy
* instead of re-copying; at create-fork time it is not (the child is brand new).
*/
keyMap: Map<string, string>
/**
* source `workspace_files.id` -> child id. Used to rewrite in-content file references
* that key on the file id (`sim:file/<id>`, `/api/files/view/<id>`, the in-app files
* path) inside copied skill/markdown content; not persisted in the fork resource map.
*/
idMap: Map<string, string>
/** Blob duplications to run after the fork transaction commits. */
blobTasks: BlobCopyTask[]
}
/**
* Insert child `workspace_files` metadata rows for the selected files (new id +
* new storage key) and return the source→child storage-key map plus the blob
* copies to run post-commit. The metadata row must exist before the blob upload
* (its idempotent metadata insert reuses the row), and both must run after the
* child workspace row exists (FK). Runs in the fork transaction; blob I/O is
* deferred to {@link executeForkFileBlobCopies}.
*
* Files are selected EITHER by `workspace_files.id` (the fork modal's picker lists files
* by id) OR by storage `key` (sync references key files by their storage key, not id). At
* least one of the two must be non-empty; both may be supplied (their matched rows union).
*/
export async function planForkFileCopies(params: {
tx: DbOrTx
sourceWorkspaceId: string
childWorkspaceId: string
userId: string
fileIds?: string[]
fileKeys?: string[]
now: Date
}): Promise<PlanForkFileCopiesResult> {
const { tx, sourceWorkspaceId, childWorkspaceId, userId, now } = params
const fileIds = params.fileIds ?? []
const fileKeys = params.fileKeys ?? []
const keyMap = new Map<string, string>()
const idMap = new Map<string, string>()
const blobTasks: BlobCopyTask[] = []
if (fileIds.length === 0 && fileKeys.length === 0) return { keyMap, idMap, blobTasks }
// 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,
// scoped to the source workspace, and restricted to durable `workspace` files. Only
// workspace files are forkable - chat/copilot/mothership uploads are session-scoped and
// their chat-bound unique index can't be duplicated - so any non-workspace id/key passed
// here is ignored rather than copied.
const selectors = [
fileIds.length > 0 ? inArray(workspaceFiles.id, fileIds) : undefined,
fileKeys.length > 0 ? inArray(workspaceFiles.key, fileKeys) : undefined,
].filter((clause): clause is NonNullable<typeof clause> => clause !== undefined)
const metas = await tx
.select()
.from(workspaceFiles)
.where(
and(
selectors.length === 1 ? selectors[0] : or(...selectors),
eq(workspaceFiles.workspaceId, sourceWorkspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
for (const meta of metas) {
const childFileId = generateId()
// Use the canonical workspace-file key (`workspace/{id}/...`) so the file-serve
// API can infer the storage context; a bare `{id}/...` key has no context prefix.
const targetKey = generateWorkspaceFileKey(childWorkspaceId, meta.originalName)
await tx.insert(workspaceFiles).values({
...meta,
id: childFileId,
key: targetKey,
workspaceId: childWorkspaceId,
userId,
folderId: null,
deletedAt: null,
uploadedAt: now,
})
keyMap.set(meta.key, targetKey)
idMap.set(meta.id, childFileId)
blobTasks.push({
sourceKey: meta.key,
targetKey,
context: meta.context as StorageContext,
fileName: meta.originalName,
contentType: meta.contentType,
size: meta.size,
userId,
workspaceId: childWorkspaceId,
})
}
return { keyMap, idMap, blobTasks }
}
/**
* Duplicate each planned file blob to its new key. `uploadFile`'s metadata insert
* is idempotent on the key (the row was already created in the transaction), so
* this only copies bytes. Markdown blobs additionally have their in-content references
* (`sim:` links, embedded file/image URLs) rewritten through `contentRefMaps` so they
* point at the copied resources (unmapped targets are left as graceful broken links).
* Best-effort: a content-rewrite failure falls back to copying the raw bytes. A failed
* blob's child storage key is returned in `failedTargetKeys` so the caller can clear the
* `file-upload` references pointing at the now-missing object (the metadata row is left in
* place, so the user can still re-upload the blob).
*
* Storage accounting: each blob that actually lands increments the initiating user's
* storage usage by the metadata row's size - the copied bytes are charged exactly as if
* the file had been uploaded to the target workspace. The increment cannot double-count:
* the content-copy job is at-most-once by config (`maxAttempts: 1`), each task increments
* only after its own successful upload, and the target-existence skip below means a
* manually replayed run neither re-copies nor re-charges a blob a prior attempt landed.
* Like the upload path, a tracking failure is logged and never fails the copy - and is
* never retried, so a landed blob whose increment failed stays uncounted (a manual replay
* skips it without charging). Accepted trade-off, matching the platform's upload paths:
* storage may undercount, but a user is never charged twice or for bytes that didn't land.
*/
export async function executeForkFileBlobCopies(
blobTasks: BlobCopyTask[],
requestId = 'unknown',
contentRefMaps?: ForkContentRefMaps
): Promise<{ copied: number; failed: number; failedTargetKeys: string[] }> {
let copied = 0
const failedTargetKeys: string[] = []
for (const task of blobTasks) {
try {
// Replay guard: target keys are freshly generated per fork/sync, so an existing
// object can only mean an earlier attempt already landed this exact copy. Skip
// without incrementing - a replay must never double-charge, so if the prior
// attempt's best-effort increment failed those bytes stay uncounted (the same
// accepted undercount as a tracking failure on the upload path). `headObject`
// returns null on local storage, where the copy is simply repeated (same bytes
// to the same key).
const existing = await headObject(task.targetKey, task.context)
if (existing) {
copied += 1
continue
}
const buffer = await downloadFile({
key: task.sourceKey,
context: task.context,
maxBytes: MAX_FILE_SIZE,
})
let body: Buffer = buffer
if (contentRefMaps && isMarkdownBlob(task)) {
try {
const text = buffer.toString('utf8')
const rewritten = rewriteForkContentRefs(text, contentRefMaps)
if (rewritten !== text) body = Buffer.from(rewritten, 'utf8')
} catch (error) {
logger.warn(`[${requestId}] Failed to rewrite markdown blob content; copying raw bytes`, {
targetKey: task.targetKey,
error: getErrorMessage(error),
})
}
}
await uploadFile({
file: body,
fileName: task.fileName,
contentType: task.contentType,
context: task.context,
customKey: task.targetKey,
preserveKey: true,
metadata: {
userId: task.userId,
workspaceId: task.workspaceId,
originalName: task.fileName,
},
})
copied += 1
// The typeof guard covers payloads enqueued before `size` existed (rolling deploy).
if (typeof task.size === 'number' && task.size > 0) {
try {
await incrementStorageUsage(task.userId, task.size, task.workspaceId)
} catch (storageError) {
logger.error(`[${requestId}] Failed to update storage tracking for copied file blob`, {
targetKey: task.targetKey,
error: getErrorMessage(storageError),
})
}
}
} catch (error) {
failedTargetKeys.push(task.targetKey)
logger.warn(`[${requestId}] Failed to copy file blob during fork`, {
targetKey: task.targetKey,
error: getErrorMessage(error),
})
}
}
return { copied, failed: failedTargetKeys.length, failedTargetKeys }
}
@@ -0,0 +1,666 @@
/**
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
resetDbChainMock,
storageServiceMock,
storageServiceMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIncrementStorageUsage } = vi.hoisted(() => ({
mockIncrementStorageUsage: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
vi.mock('@/lib/billing/storage', () => ({
incrementStorageUsage: mockIncrementStorageUsage,
}))
import type { DbOrTx } from '@/lib/db/types'
import {
copyForkResourceContainers,
copyForkResourceContent,
type ForkContentPlan,
planForkMappedKbDocumentCopies,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references'
function basePlan(overrides: Partial<ForkContentPlan> = {}): ForkContentPlan {
return {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
...overrides,
}
}
const sourceDoc = {
id: 'doc-1',
knowledgeBaseId: 'src-kb',
storageKey: 'kb/source-key',
fileUrl: '/api/files/serve/kb%2Fsource-key',
filename: 'report.pdf',
mimeType: 'application/pdf',
}
describe('copyForkResourceContent', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('blob-bytes'))
storageServiceMockFns.mockUploadFile.mockResolvedValue({
key: 'kb/child-key',
path: '/api/files/serve/kb/child-key',
})
})
it('rewrites in-workspace resource URLs nested in copied table cell data', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'r1',
tableId: 'src-tbl',
workspaceId: 'src-ws',
data: {
kb: '/workspace/src-ws/knowledge/kb-1',
nested: { wf: '/workspace/src-ws/w/wf-1' },
plain: 'no url here',
},
},
])
const result = await copyForkResourceContent({
contentPlan: basePlan({ tables: [{ sourceId: 'src-tbl', childId: 'child-tbl' }] }),
contentRefMaps: {
workspaceId: { from: 'src-ws', to: 'child-ws' },
knowledgeBases: new Map([['kb-1', 'kb-2']]),
workflows: new Map([['wf-1', 'wf-2']]),
},
requestId: 'test',
})
expect(result.failed).toBe(0)
// The first insert is the table-rows copy (no KBs/docs/skills in this plan).
const inserted = dbChainMockFns.values.mock.calls[0][0] as Array<{
data: { kb: string; nested: { wf: string }; plain: string }
}>
expect(inserted[0].data.kb).toBe('/workspace/child-ws/knowledge/kb-2')
expect(inserted[0].data.nested.wf).toBe('/workspace/child-ws/w/wf-2')
expect(inserted[0].data.plain).toBe('no url here')
})
it('#1 binds a copied KB document blob to the CHILD workspace + initiating user', async () => {
// One live document page, then the embeddings page resolves empty (default).
dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc])
const result = await copyForkResourceContent({
contentPlan: basePlan({
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
}),
requestId: 'test',
})
expect(result.failed).toBe(0)
expect(result.copied).toBe(1)
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
const uploadArg = storageServiceMockFns.mockUploadFile.mock.calls[0][0]
expect(uploadArg.context).toBe('knowledge-base')
expect(uploadArg.preserveKey).toBe(true)
// The ownership binding is what verifyKBFileAccess resolves the owning workspace from;
// it must name the CHILD workspace and the initiating user, or the copy is download-denied.
expect(uploadArg.metadata).toEqual({
userId: 'user-1',
workspaceId: 'child-ws',
originalName: 'report.pdf',
})
})
it('#1 never touches storage accounting for copied KB document blobs (mirrors the KB upload path)', async () => {
// The normal KB upload path never increments `storage_used_bytes` (and embeddings are
// uncounted DB rows), so a fork-copied KB blob must not be charged either - copied KB
// bytes are only headroom-checked pre-fork, exactly like the multipart-initiate check.
dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc])
const result = await copyForkResourceContent({
contentPlan: basePlan({
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
}),
requestId: 'test',
})
expect(result.copied).toBe(1)
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
})
it('#4 re-reads a copied skill body post-commit and rewrites it via db.update (never from payload)', async () => {
// The body is no longer carried in the plan - the content phase keyset-re-reads the child row.
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'child-skill-1', content: 'see [K](sim:knowledge/src-kb)' },
])
const result = await copyForkResourceContent({
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) },
requestId: 'test',
})
expect(result.failed).toBe(0)
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
content: 'see [K](sim:knowledge/child-kb)',
})
})
it('#4 leaves a skill untouched when nothing in its re-read body remaps', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'child-skill-1', content: 'no references here' },
])
const result = await copyForkResourceContent({
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) },
requestId: 'test',
})
expect(result.failed).toBe(0)
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('#4 skips the skill re-read + rewrite entirely when no content maps are supplied', async () => {
await copyForkResourceContent({
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
requestId: 'test',
})
// No maps -> the body is neither re-read from the DB nor updated.
expect(dbChainMockFns.select).not.toHaveBeenCalled()
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})
it('#3 fails the whole KB (all-or-nothing) when one document copy throws', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc])
// The document row insert throws; the blob copy is best-effort (never throws) so the
// failure must come from the persisted copy, marking the entire KB failed for cleanup.
dbChainMockFns.values.mockImplementationOnce(() => {
throw new Error('insert failed')
})
const result = await copyForkResourceContent({
contentPlan: basePlan({
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
}),
requestId: 'test',
})
expect(result.copied).toBe(0)
expect(result.failed).toBe(1)
expect(result.failures).toEqual([
{ kind: 'knowledge-base', childId: 'child-kb', documentChildIds: [] },
])
})
it('U-docs: fills a document copied into an existing target KB (blob re-key + placeholder update)', async () => {
const result = await copyForkResourceContent({
contentPlan: basePlan({
documents: [
{
sourceDocId: 'doc-1',
childDocId: 'child-doc-1',
childKnowledgeBaseId: 'existing-target-kb',
storageKey: 'kb/source-key',
filename: 'report.pdf',
mimeType: 'application/pdf',
},
],
}),
requestId: 'test',
})
expect(result.failed).toBe(0)
expect(result.copied).toBe(1)
// The blob is re-keyed and the pre-created placeholder row's blob fields are updated.
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
})
it('U-docs: a failed document fill is reported as a knowledge-document failure (for cleanup)', async () => {
// The placeholder blob update throws; the doc fails on its own without touching its KB.
dbChainMockFns.set.mockImplementationOnce(() => {
throw new Error('update failed')
})
const result = await copyForkResourceContent({
contentPlan: basePlan({
documents: [
{
sourceDocId: 'doc-1',
childDocId: 'child-doc-1',
childKnowledgeBaseId: 'existing-target-kb',
storageKey: 'kb/source-key',
filename: 'report.pdf',
mimeType: 'application/pdf',
},
],
}),
requestId: 'test',
})
expect(result.copied).toBe(0)
expect(result.failed).toBe(1)
expect(result.failures).toEqual([{ kind: 'knowledge-document', childId: 'child-doc-1' }])
})
})
describe('copyForkResourceContainers custom-tool code env rewrite', () => {
function makeContainerTx(rows: Array<Record<string, unknown>>) {
const inserted: Array<Record<string, unknown>> = []
const tx = {
select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }),
insert: () => ({
values: (values: Array<Record<string, unknown>>) => {
inserted.push(...values)
return Promise.resolve()
},
}),
}
return { tx: tx as unknown as DbOrTx, inserted }
}
const customToolSelection = {
customTools: ['ct-1'],
skills: [],
mcpServers: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: [],
}
it('rewrites {{ENV}} refs in copied custom-tool code when a sync renames the env var', async () => {
const { tx, inserted } = makeContainerTx([
{ id: 'ct-1', title: 'Tool', code: 'fetch("{{SLACK_API_KEY}}", "{{KEEP}}")' },
])
await copyForkResourceContainers({
tx,
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
now: new Date(),
selection: customToolSelection,
workflowIdMap: new Map(),
resolveEnvName: (key) => (key === 'SLACK_API_KEY' ? 'SLACK_API_KEY_TEST' : key),
})
expect(inserted).toHaveLength(1)
// The renamed key is rewritten; the same-name key is left verbatim.
expect(inserted[0].code).toBe('fetch("{{SLACK_API_KEY_TEST}}", "{{KEEP}}")')
expect(inserted[0].workspaceId).toBe('child-ws')
})
it('preserves custom-tool code verbatim when no env resolver is provided (fork-create)', async () => {
const { tx, inserted } = makeContainerTx([
{ id: 'ct-1', title: 'Tool', code: 'fetch("{{SLACK_API_KEY}}")' },
])
await copyForkResourceContainers({
tx,
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
now: new Date(),
selection: customToolSelection,
workflowIdMap: new Map(),
})
expect(inserted[0].code).toBe('fetch("{{SLACK_API_KEY}}")')
})
})
describe('copyForkResourceContainers external MCP server copy', () => {
function makeServerTx(rows: Array<Record<string, unknown>>) {
const inserted: Array<Record<string, unknown>> = []
const tx = {
select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }),
insert: () => ({
values: (values: Array<Record<string, unknown>>) => {
inserted.push(...values)
return Promise.resolve()
},
}),
}
return { tx: tx as unknown as DbOrTx, inserted }
}
it('copies the config row with runtime status reset, records the mapping, and never copies tokens', async () => {
const { tx, inserted } = makeServerTx([
{
id: 'mcp-1',
workspaceId: 'src-ws',
createdBy: 'src-user',
name: 'Linear MCP',
transport: 'streamable-http',
url: 'https://mcp.linear.app/mcp',
authType: 'headers',
headers: { Authorization: 'Bearer {{LINEAR_KEY}}' },
connectionStatus: 'connected',
lastConnected: new Date(),
lastError: 'old error',
statusConfig: { consecutiveFailures: 2, lastSuccessfulDiscovery: 'x' },
toolCount: 12,
lastToolsRefresh: new Date(),
totalRequests: 99,
lastUsed: new Date(),
deletedAt: null,
},
])
const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
mcpServers: ['mcp-1'],
workflowMcpServers: [],
tables: [],
knowledgeBases: [],
},
workflowIdMap: new Map(),
})
expect(inserted).toHaveLength(1)
const child = inserted[0]
expect(child.id).not.toBe('mcp-1')
expect(child.workspaceId).toBe('child-ws')
expect(child.createdBy).toBe('user-1')
// Config copies verbatim - url/headers ({{ENV}} refs resolve against the child's env).
expect(child.url).toBe('https://mcp.linear.app/mcp')
expect(child.headers).toEqual({ Authorization: 'Bearer {{LINEAR_KEY}}' })
// Runtime status resets: tools re-discover on first use in the child (cache is
// workspace-keyed), and no `mcp_server_oauth` row is ever inserted (re-auth required).
expect(child.connectionStatus).toBe('disconnected')
expect(child.lastConnected).toBeNull()
expect(child.lastError).toBeNull()
expect(child.toolCount).toBe(0)
expect(child.lastToolsRefresh).toBeNull()
// The id map + mapping rows record the copy so subblock references remap onto it.
expect(result.idMap.get('mcp_server')?.get('mcp-1')).toBe(child.id)
expect(result.mappingEntries).toContainEqual({
resourceType: 'mcp_server',
parentResourceId: 'mcp-1',
childResourceId: child.id,
})
expect(result.names.mcpServers).toEqual(['Linear MCP'])
})
})
describe('copyForkResourceContainers skill copy', () => {
function makeSkillTx(rows: Array<Record<string, unknown>>) {
const inserted: Array<Record<string, unknown>> = []
const tx = {
select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }),
insert: () => ({
values: (values: Array<Record<string, unknown>>) => {
inserted.push(...values)
return Promise.resolve()
},
}),
}
return { tx: tx as unknown as DbOrTx, inserted }
}
const skillSelection = {
customTools: [],
skills: ['sk-1'],
mcpServers: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: [],
}
it('copies the skill body IN-DB and carries only the child id in the content plan', async () => {
// The source projection deliberately omits `content` (it is copied server-side), so the row
// fed to the tx mock has none - the body must never be materialized in app memory here.
const { tx, inserted } = makeSkillTx([
{
id: 'sk-1',
name: 'My Skill',
description: 'desc',
workspaceId: 'src-ws',
userId: 'src-user',
createdAt: new Date(),
updatedAt: new Date(),
},
])
const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
now: new Date(),
selection: skillSelection,
workflowIdMap: new Map(),
})
expect(inserted).toHaveLength(1)
const childId = inserted[0].id as string
expect(childId).not.toBe('sk-1')
expect(inserted[0].workspaceId).toBe('child-ws')
expect(inserted[0].userId).toBe('user-1')
// The body is deferred to a correlated subquery (in-DB copy), never a materialized string.
expect(typeof inserted[0].content).not.toBe('string')
// The content plan carries ONLY the child id - no skill body text crosses the job payload.
expect(result.contentPlan.skills).toEqual([{ childId }])
expect(result.names.skills).toEqual(['My Skill'])
})
})
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<Array<Record<string, unknown>>>) {
let call = 0
const inserts: Array<Array<Record<string, unknown>>> = []
const tx = {
select: () => ({
from: () => ({ where: () => Promise.resolve(selects[call++] ?? []) }),
}),
insert: () => ({
values: (rows: Array<Record<string, unknown>>) => {
inserts.push(rows)
return Promise.resolve()
},
}),
}
return { tx: tx as unknown as DbOrTx, inserts }
}
const kbSelection = {
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: ['kb-1'],
}
const sourceBase = { id: 'kb-1', name: 'Docs KB', workspaceId: 'src-ws', deletedAt: null }
it('copies the source KB tag definitions to the child KB with fresh ids (other columns verbatim)', async () => {
const { tx, inserts } = makeKbTx([
[sourceBase],
[
{
id: 'tag-1',
knowledgeBaseId: 'kb-1',
tagSlot: 'tag1',
displayName: 'Category',
fieldType: 'text',
},
{
id: 'tag-2',
knowledgeBaseId: 'kb-1',
tagSlot: 'boolean1',
displayName: 'Reviewed',
fieldType: 'boolean',
},
],
])
const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
now: new Date(),
selection: kbSelection,
workflowIdMap: new Map(),
})
const childKbId = result.idMap.get('knowledge_base')?.get('kb-1')
expect(childKbId).toBeTruthy()
// insert #0 is the KB row; insert #1 is the tag-definition batch.
expect(inserts).toHaveLength(2)
const tagRows = inserts[1]
expect(tagRows).toHaveLength(2)
for (const row of tagRows) {
expect(row.knowledgeBaseId).toBe(childKbId)
expect(row.id).not.toBe('tag-1')
expect(row.id).not.toBe('tag-2')
}
expect(tagRows.map((row) => [row.tagSlot, row.displayName, row.fieldType])).toEqual([
['tag1', 'Category', 'text'],
['boolean1', 'Reviewed', 'boolean'],
])
})
it('no-ops the tag-definition copy for a KB with zero definitions', async () => {
const { tx, inserts } = makeKbTx([[sourceBase], []])
await copyForkResourceContainers({
tx,
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
now: new Date(),
selection: kbSelection,
workflowIdMap: new Map(),
})
// Only the KB row itself is inserted - no empty tag-definition insert.
expect(inserts).toHaveLength(1)
})
})
describe('planForkMappedKbDocumentCopies', () => {
const sourceRow = (id: string, knowledgeBaseId: string) => ({
id,
knowledgeBaseId,
storageKey: `kb/${id}`,
filename: `${id}.pdf`,
mimeType: 'application/pdf',
connectorId: 'connector-1',
deletedAt: null,
archivedAt: null,
})
function makeTx(docs: ReturnType<typeof sourceRow>[]) {
const inserted: Array<Record<string, unknown>> = []
let selectCalled = false
const tx = {
select: () => {
selectCalled = true
return { from: () => ({ where: () => Promise.resolve(docs) }) }
},
insert: () => ({
values: (rows: Array<Record<string, unknown>>) => {
inserted.push(...rows)
return Promise.resolve()
},
}),
}
return { tx: tx as unknown as DbOrTx, inserted, wasSelectCalled: () => selectCalled }
}
const mappedKbResolver: ForkReferenceResolver = (kind, id) =>
kind === 'knowledge-base' && id === 'src-kb' ? 'target-kb' : null
it('places a referenced doc into its already-mapped existing KB and returns the maps', async () => {
const { tx, inserted } = makeTx([sourceRow('doc-1', 'src-kb')])
const result = await planForkMappedKbDocumentCopies({
tx,
resolver: mappedKbResolver,
referencedDocumentIds: ['doc-1'],
alreadyCopiedSourceDocIds: new Set(),
})
const childId = result.docIdMap.get('doc-1')
expect(childId).toBeTruthy()
expect(inserted).toHaveLength(1)
expect(inserted[0]).toMatchObject({
id: childId,
knowledgeBaseId: 'target-kb',
connectorId: null,
deletedAt: null,
archivedAt: null,
})
expect(result.mappingEntries).toEqual([
{ resourceType: 'knowledge_document', parentResourceId: 'doc-1', childResourceId: childId },
])
expect(result.documents).toEqual([
{
sourceDocId: 'doc-1',
childDocId: childId,
childKnowledgeBaseId: 'target-kb',
storageKey: 'kb/doc-1',
filename: 'doc-1.pdf',
mimeType: 'application/pdf',
},
])
})
it('skips a referenced doc whose parent KB is not mapped (reference is left to be cleared)', async () => {
const { tx, inserted } = makeTx([sourceRow('doc-1', 'unmapped-kb')])
const result = await planForkMappedKbDocumentCopies({
tx,
resolver: mappedKbResolver,
referencedDocumentIds: ['doc-1'],
alreadyCopiedSourceDocIds: new Set(),
})
expect(inserted).toHaveLength(0)
expect(result.docIdMap.size).toBe(0)
expect(result.documents).toHaveLength(0)
})
it('skips a doc already placed under a copied KB this sync (no duplicate query)', async () => {
const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')])
const result = await planForkMappedKbDocumentCopies({
tx,
resolver: mappedKbResolver,
referencedDocumentIds: ['doc-1'],
alreadyCopiedSourceDocIds: new Set(['doc-1']),
})
expect(result.documents).toHaveLength(0)
expect(wasSelectCalled()).toBe(false)
})
it('skips a doc that already resolves (mapped by a prior sync)', async () => {
const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')])
const result = await planForkMappedKbDocumentCopies({
tx,
resolver: (kind, id) =>
kind === 'knowledge-document' && id === 'doc-1' ? 'existing-child-doc' : null,
referencedDocumentIds: ['doc-1'],
alreadyCopiedSourceDocIds: new Set(),
})
expect(result.documents).toHaveLength(0)
expect(wasSelectCalled()).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,356 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
const { mockSaveWorkflowToNormalizedTables } = vi.hoisted(() => ({
mockSaveWorkflowToNormalizedTables: vi.fn(),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
}))
import {
buildWorkflowNameRegistry,
copyWorkflowStateIntoTarget,
resolveForkFolderMapping,
} from '@/ee/workspace-forking/lib/copy/copy-workflows'
describe('buildWorkflowNameRegistry', () => {
it('reports a name as taken by another workflow in the same folder', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
expect(reg.isTaken('f1', 'Onboarding', null)).toBe(true)
expect(reg.isTaken('f1', 'Onboarding', 'w2')).toBe(true)
})
it('excludes the workflow itself so a replace can keep its own name', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
expect(reg.isTaken('f1', 'Onboarding', 'w1')).toBe(false)
})
it('is folder-scoped: the same name in another folder is free', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
expect(reg.isTaken('f2', 'Onboarding', null)).toBe(false)
expect(reg.isTaken(null, 'Onboarding', null)).toBe(false)
})
it('treats the root (null) folder distinctly, matching coalesce(folderId, "")', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: null, name: 'Root WF' }])
expect(reg.isTaken(null, 'Root WF', null)).toBe(true)
expect(reg.isTaken('f1', 'Root WF', null)).toBe(false)
})
it('claims a new name so a later workflow in the same copy loop sees it taken', () => {
const reg = buildWorkflowNameRegistry([])
expect(reg.isTaken('f1', 'Report', null)).toBe(false)
reg.claim('f1', 'Report', 'wA')
expect(reg.isTaken('f1', 'Report', null)).toBe(true)
expect(reg.isTaken('f1', 'Report', 'wA')).toBe(false)
})
it('releases the prior name when a workflow is renamed (claim moves keys)', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Old' }])
reg.claim('f1', 'New', 'w1')
expect(reg.isTaken('f1', 'Old', null)).toBe(false)
expect(reg.isTaken('f1', 'New', null)).toBe(true)
})
it('re-claiming the same (folder, name) is a no-op', () => {
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Same' }])
reg.claim('f1', 'Same', 'w1')
expect(reg.isTaken('f1', 'Same', 'w1')).toBe(false)
expect(reg.isTaken('f1', 'Same', null)).toBe(true)
})
it('handles multiple holders (legacy duplicates) and partial release', () => {
const reg = buildWorkflowNameRegistry([
{ id: 'w1', folderId: 'f1', name: 'Dup' },
{ id: 'w2', folderId: 'f1', name: 'Dup' },
])
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(true)
reg.claim('f1', 'Other', 'w2')
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(false)
})
})
interface FolderRow {
id: string
name: string
userId: string
workspaceId: string
parentId: string | null
color: string | null
isExpanded: boolean
locked: boolean
sortOrder: number
createdAt: Date
updatedAt: Date
archivedAt: Date | null
}
function folderRow(id: string, name: string, parentId: string | null = null): FolderRow {
return {
id,
name,
userId: 'source-user',
workspaceId: 'ws-source',
parentId,
color: '#6B7280',
isExpanded: true,
locked: false,
sortOrder: 0,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
archivedAt: null,
}
}
/**
* Transaction stub for {@link resolveForkFolderMapping}: the first awaited select resolves
* the source folders, the second the target folders, and inserted rows are captured.
*/
function buildFolderTx(sourceFolders: FolderRow[], targetFolders: FolderRow[] = []) {
const insertedRows: FolderRow[] = []
const selects = [sourceFolders, targetFolders]
let selectIndex = 0
const tx = {
select: () => ({
from: () => ({
where: () => Promise.resolve(selects[selectIndex++] ?? []),
}),
}),
insert: () => ({
values: (rows: FolderRow[]) => {
insertedRows.push(...rows)
return Promise.resolve()
},
}),
} as unknown as DbOrTx
return { tx, insertedRows }
}
function resolveMapping(params: {
tx: DbOrTx
contentFolderIds: ReadonlyArray<string | null>
}): Promise<Map<string, string>> {
return resolveForkFolderMapping({
tx: params.tx,
sourceWorkspaceId: 'ws-source',
targetWorkspaceId: 'ws-target',
userId: 'target-user',
now: new Date('2026-07-01'),
contentFolderIds: params.contentFolderIds,
})
}
describe('resolveForkFolderMapping', () => {
it('keeps the full ancestor chain of a nested folder holding a copied workflow', async () => {
const { tx, insertedRows } = buildFolderTx([
folderRow('A', 'Alpha'),
folderRow('B', 'Beta', 'A'),
folderRow('C', 'Gamma', 'B'),
])
const map = await resolveMapping({ tx, contentFolderIds: ['C'] })
expect(map.size).toBe(3)
expect(insertedRows).toHaveLength(3)
const byName = new Map(insertedRows.map((row) => [row.name, row]))
expect(byName.get('Alpha')?.parentId).toBeNull()
expect(byName.get('Beta')?.parentId).toBe(map.get('A'))
expect(byName.get('Gamma')?.parentId).toBe(map.get('B'))
for (const row of insertedRows) {
expect(row.workspaceId).toBe('ws-target')
expect(row.userId).toBe('target-user')
expect(row.locked).toBe(false)
expect(['A', 'B', 'C']).not.toContain(row.id)
}
})
it('prunes an empty sibling subtree while keeping the occupied folder', async () => {
const { tx, insertedRows } = buildFolderTx([
folderRow('A', 'Occupied'),
folderRow('D', 'Empty parent'),
folderRow('E', 'Empty child', 'D'),
])
const map = await resolveMapping({ tx, contentFolderIds: ['A'] })
expect(insertedRows).toHaveLength(1)
expect(insertedRows[0].name).toBe('Occupied')
expect(map.has('A')).toBe(true)
expect(map.has('D')).toBe(false)
expect(map.has('E')).toBe(false)
})
it('prunes a root-level empty folder when the copied workflows live at root', async () => {
const { tx, insertedRows } = buildFolderTx([folderRow('F', 'Never used')])
const map = await resolveMapping({ tx, contentFolderIds: [null, null] })
expect(insertedRows).toHaveLength(0)
expect(map.size).toBe(0)
})
it('creates no folders when nothing is copied into any folder', async () => {
const { tx, insertedRows } = buildFolderTx([
folderRow('A', 'Alpha'),
folderRow('B', 'Beta', 'A'),
])
const map = await resolveMapping({ tx, contentFolderIds: [] })
expect(insertedRows).toHaveLength(0)
expect(map.size).toBe(0)
})
it('reuses an existing target folder for a kept folder instead of duplicating it', async () => {
const existing = { ...folderRow('T1', 'Shared'), workspaceId: 'ws-target' }
const { tx, insertedRows } = buildFolderTx([folderRow('G', 'Shared')], [existing])
const map = await resolveMapping({ tx, contentFolderIds: ['G'] })
expect(insertedRows).toHaveLength(0)
expect(map.get('G')).toBe('T1')
})
it('maps a pruned folder onto a matching existing target folder without creating it', async () => {
const existing = { ...folderRow('T1', 'Prior sync'), workspaceId: 'ws-target' }
const { tx, insertedRows } = buildFolderTx([folderRow('P', 'Prior sync')], [existing])
const map = await resolveMapping({ tx, contentFolderIds: [] })
expect(insertedRows).toHaveLength(0)
expect(map.get('P')).toBe('T1')
})
it('never root-aliases a pruned nested folder onto a same-named root target folder', async () => {
// Source X is nested under unmatched P; the target's root-level "X" is unrelated.
const existing = { ...folderRow('T-root-x', 'X'), workspaceId: 'ws-target' }
const { tx, insertedRows } = buildFolderTx(
[folderRow('P', 'Parent'), folderRow('X', 'X', 'P')],
[existing]
)
const map = await resolveMapping({ tx, contentFolderIds: [] })
expect(insertedRows).toHaveLength(0)
expect(map.size).toBe(0)
})
it('creates a kept child under a reused existing parent folder', async () => {
const existingParent = { ...folderRow('T-parent', 'Parent'), workspaceId: 'ws-target' }
const { tx, insertedRows } = buildFolderTx(
[folderRow('P', 'Parent'), folderRow('C', 'Child', 'P')],
[existingParent]
)
const map = await resolveMapping({ tx, contentFolderIds: ['C'] })
expect(map.get('P')).toBe('T-parent')
expect(insertedRows).toHaveLength(1)
expect(insertedRows[0].name).toBe('Child')
expect(insertedRows[0].parentId).toBe('T-parent')
})
})
describe('copyWorkflowStateIntoTarget folder fallback', () => {
it('places a copied workflow at the target root when its source folder has no mapping', async () => {
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
const insertedWorkflows: Array<Record<string, unknown>> = []
const tx = {
insert: () => ({
values: (row: Record<string, unknown>) => {
insertedWorkflows.push(row)
return Promise.resolve()
},
}),
} as unknown as DbOrTx
const result = await copyWorkflowStateIntoTarget({
tx,
targetWorkflowId: 'wf-child',
targetWorkspaceId: 'ws-target',
userId: 'target-user',
mode: 'create',
now: new Date('2026-07-01'),
sourceState: { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} },
sourceMeta: {
name: 'Orphaned placement',
description: null,
folderId: 'folder-with-no-mapping',
sortOrder: 0,
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
nameRegistry: buildWorkflowNameRegistry([]),
})
expect(insertedWorkflows).toHaveLength(1)
expect(insertedWorkflows[0].folderId).toBeNull()
expect(result.name).toBe('Orphaned placement')
})
})
describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => {
it(
"persists a transform's reindexed canonicalModes on the copied block, and uses that " +
"SAME reindexed value (not the source's stale one) for every subsequent remap step",
async () => {
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
const seenCanonicalModes: Array<Record<string, 'basic' | 'advanced'> | undefined> = []
const tx = {
insert: () => ({ values: () => Promise.resolve() }),
} as unknown as DbOrTx
await copyWorkflowStateIntoTarget({
tx,
targetWorkflowId: 'wf-child',
targetWorkspaceId: 'ws-target',
userId: 'target-user',
mode: 'create',
now: new Date('2026-07-01'),
sourceState: {
blocks: {
block1: {
id: 'block1',
type: 'agent',
name: 'Agent',
subBlocks: {},
// The source's ORIGINAL (pre-drop) canonicalModes - every step after the
// transform must see the REINDEXED value below instead, not this one.
data: { canonicalModes: { '1:credential': 'advanced' } },
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
},
sourceMeta: { name: 'Reindex test', description: null, folderId: null, sortOrder: 0 },
workflowIdMap: new Map(),
folderIdMap: new Map(),
nameRegistry: buildWorkflowNameRegistry([]),
// Simulates a `tool-input` drop shifting tool 1 -> 0: returns subBlocks unchanged but
// reports the reindexed canonicalModes via the callback, exactly like
// `createForkBootstrapTransform`/`createForkSubBlockTransform` do.
transformSubBlocks: (subBlocks, _blockType, canonicalModes, onCanonicalModesChanged) => {
seenCanonicalModes.push(canonicalModes)
onCanonicalModesChanged?.({ '0:credential': 'advanced' })
return subBlocks
},
})
const [, remappedState] = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)!
const persistedBlock = Object.values(remappedState.blocks)[0] as {
data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> }
}
// The transform received the source's original value...
expect(seenCanonicalModes).toEqual([{ '1:credential': 'advanced' }])
// ...and the PERSISTED block carries the reindexed one, not the stale source value.
expect(persistedBlock.data?.canonicalModes).toEqual({ '0:credential': 'advanced' })
}
)
})
@@ -0,0 +1,610 @@
import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import {
remapConditionIdsInSubBlocks,
remapVariableIdsInSubBlocks,
remapWorkflowReferencesInSubBlocks,
type SubBlockRecord,
sanitizeSubBlocksForDuplicate,
} from '@/lib/workflows/persistence/remap-internal-ids'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
deriveForkBlockId,
type ForkBlockIdResolver,
} from '@/ee/workspace-forking/lib/remap/block-identity'
import {
applyDependentOverrides,
collectClearedDependents,
type NeedsConfigurationField,
type SubBlockTransform,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type {
BlockData,
BlockState,
Loop,
Parallel,
SubBlockState,
WorkflowState,
Variable as WorkflowStateVariable,
} from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkspaceForkCopyWorkflows')
interface ResolveForkFolderMappingParams {
tx: DbOrTx
sourceWorkspaceId: string
targetWorkspaceId: string
userId: string
now: Date
/**
* Source folder ids that will directly hold copied content (workflows); 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: they live in the
* separate `workspace_file_folders` entity and are flattened to root by the copy.
*/
contentFolderIds: ReadonlyArray<string | null>
}
/**
* Mirror into the target workspace the part of the source folder tree that will actually
* receive copied content: the folders in `contentFolderIds` plus their ancestor chains (so
* nesting stays intact). Target folders that already match by name within the same (mapped)
* 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
* map is placed at the target's root (see {@link copyWorkflowStateIntoTarget}).
*/
export async function resolveForkFolderMapping({
tx,
sourceWorkspaceId,
targetWorkspaceId,
userId,
now,
contentFolderIds,
}: ResolveForkFolderMappingParams): Promise<Map<string, string>> {
const map = new Map<string, string>()
const sourceFolders = await tx
.select()
.from(workflowFolder)
.where(
and(eq(workflowFolder.workspaceId, sourceWorkspaceId), isNull(workflowFolder.archivedAt))
)
if (sourceFolders.length === 0) return map
const byId = new Map(sourceFolders.map((folder) => [folder.id, folder]))
// Kept = folders that directly hold copied content plus every ancestor; everything else
// would be empty in the target and is pruned. A dangling (archived) parent ends the walk,
// matching the re-root fallback below.
const kept = new Set<string>()
for (const folderId of contentFolderIds) {
let current = folderId ? byId.get(folderId) : undefined
while (current && !kept.has(current.id)) {
kept.add(current.id)
current = current.parentId ? byId.get(current.parentId) : undefined
}
}
const targetFolders = await tx
.select()
.from(workflowFolder)
.where(
and(eq(workflowFolder.workspaceId, targetWorkspaceId), isNull(workflowFolder.archivedAt))
)
const targetByKey = new Map<string, string>()
for (const folder of targetFolders) {
targetByKey.set(`${folder.parentId ?? ''}::${folder.name}`, folder.id)
}
const ordered: typeof sourceFolders = []
const seen = new Set<string>()
const visit = (folder: (typeof sourceFolders)[number]) => {
if (seen.has(folder.id)) return
const parent = folder.parentId ? byId.get(folder.parentId) : undefined
if (parent) visit(parent)
seen.add(folder.id)
ordered.push(folder)
}
for (const folder of sourceFolders) visit(folder)
const newFolders: (typeof sourceFolders)[number][] = []
for (const folder of ordered) {
const isKept = kept.has(folder.id)
const mappedParentId = folder.parentId ? (map.get(folder.parentId) ?? null) : null
const key = `${mappedParentId ?? ''}::${folder.name}`
const existing = targetByKey.get(key)
if (existing) {
// A pruned folder may still MAP onto an existing target folder, but only when its
// parent chain actually resolved: an unmapped pruned parent aliases the key to root
// level, which could match an unrelated same-named root folder.
if (isKept || !folder.parentId || map.has(folder.parentId)) {
map.set(folder.id, existing)
}
continue
}
if (!isKept) continue
const newFolderId = generateId()
map.set(folder.id, newFolderId)
targetByKey.set(key, newFolderId)
newFolders.push({
...folder,
id: newFolderId,
userId,
workspaceId: targetWorkspaceId,
parentId: mappedParentId,
locked: false,
createdAt: now,
updatedAt: now,
})
}
if (newFolders.length > 0) {
await tx.insert(workflowFolder).values(newFolders)
}
return map
}
// `\u0000` (a NUL byte) can never appear in a Postgres text column, so it is a
// collision-free separator between the folder id and the name.
const workflowNameKey = (folderId: string | null, name: string) => `${folderId ?? ''}\u0000${name}`
/**
* In-memory registry of a workspace's active workflow names, keyed by
* (folderId, name) - the exact columns of the `workflow_workspace_folder_name_active_unique`
* partial index. Lets fork/promote resolve name collisions across many copied workflows
* from a single load instead of one `nameTaken` query per workflow inside the (locked)
* transaction.
*
* Correctness is still guaranteed by that DB unique index; this is only a proactive
* collision avoider. A stale snapshot (e.g. a concurrent non-fork rename mid-promote) can
* therefore only cause a rare, retry-able unique violation - never a duplicate name -
* exactly as the prior per-workflow check-then-write already could.
*/
export interface WorkflowNameRegistry {
/** True when (folderId, name) is held by any active workflow other than `excludeWorkflowId`. */
isTaken(folderId: string | null, name: string, excludeWorkflowId: string | null): boolean
/** Record that `workflowId` now holds (folderId, name), releasing any name it held before. */
claim(folderId: string | null, name: string, workflowId: string): void
}
/** Build a {@link WorkflowNameRegistry} from already-loaded rows (pure - unit-testable). */
export function buildWorkflowNameRegistry(
rows: Array<{ id: string; folderId: string | null; name: string }>
): WorkflowNameRegistry {
const holdersByKey = new Map<string, Set<string>>()
const keyByWorkflow = new Map<string, string>()
for (const row of rows) {
const key = workflowNameKey(row.folderId, row.name)
const holders = holdersByKey.get(key)
if (holders) holders.add(row.id)
else holdersByKey.set(key, new Set([row.id]))
keyByWorkflow.set(row.id, key)
}
return {
isTaken(folderId, name, excludeWorkflowId) {
const holders = holdersByKey.get(workflowNameKey(folderId, name))
if (!holders) return false
for (const id of holders) if (id !== excludeWorkflowId) return true
return false
},
claim(folderId, name, workflowId) {
const newKey = workflowNameKey(folderId, name)
const prevKey = keyByWorkflow.get(workflowId)
if (prevKey === newKey) return
if (prevKey) holdersByKey.get(prevKey)?.delete(workflowId)
const holders = holdersByKey.get(newKey)
if (holders) holders.add(workflowId)
else holdersByKey.set(newKey, new Set([workflowId]))
keyByWorkflow.set(workflowId, newKey)
},
}
}
/** Load every active workflow name in a workspace into a {@link WorkflowNameRegistry}. */
export async function loadWorkflowNameRegistry(
executor: DbOrTx,
workspaceId: string
): Promise<WorkflowNameRegistry> {
const rows = await executor
.select({ id: workflow.id, folderId: workflow.folderId, name: workflow.name })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
return buildWorkflowNameRegistry(rows)
}
/**
* Batched read of the current DRAFT subBlocks for a set of (replace) target
* workflows, keyed `workflowId -> blockId -> subBlocks`. One query for the whole
* promote so the locked apply phase doesn't do N per-workflow loads; called
* pre-write so it reflects the target state the user configured before this sync
* overwrites it. Promote uses it to detect required dependents the sync left empty
* (see {@link collectClearedDependents}); fork-create has no prior target and skips it.
*/
export async function loadTargetDraftSubBlocks(
executor: DbOrTx,
workflowIds: string[]
): Promise<Map<string, Map<string, SubBlockRecord>>> {
const byWorkflow = new Map<string, Map<string, SubBlockRecord>>()
if (workflowIds.length === 0) return byWorkflow
const rows = await executor
.select({
workflowId: workflowBlocks.workflowId,
blockId: workflowBlocks.id,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(inArray(workflowBlocks.workflowId, workflowIds))
for (const row of rows) {
let blocks = byWorkflow.get(row.workflowId)
if (!blocks) {
blocks = new Map<string, SubBlockRecord>()
byWorkflow.set(row.workflowId, blocks)
}
blocks.set(row.blockId, (row.subBlocks ?? {}) as SubBlockRecord)
}
return byWorkflow
}
/**
* Pick a non-colliding name for a copied workflow against the preloaded registry, which
* mirrors the workspace's (folder, name, not-archived, exclude-self) predicate from one
* query instead of one per candidate. Mirrors {@link deduplicateWorkflowName}'s ` (n)`
* numbering, but reads from memory so the copy loop issues no per-workflow name queries.
*/
function resolveTargetWorkflowName(
registry: WorkflowNameRegistry,
folderId: string | null,
name: string,
excludeWorkflowId: string | null
): string {
const taken = (candidate: string) => registry.isTaken(folderId, candidate, excludeWorkflowId)
if (!taken(name)) return name
for (let i = 2; i < 100; i++) {
const candidate = `${name} (${i})`
if (!taken(candidate)) return candidate
}
return `${name} (${generateId().slice(0, 6)})`
}
export interface CopyWorkflowResult {
targetWorkflowId: string
mode: 'create' | 'replace'
name: string
blocksCount: number
edgesCount: number
subflowsCount: number
/**
* `dependsOn` fields (top-level and nested tool-input) a remapped parent left empty
* that weren't restored from the target draft - the parent legitimately changed.
* Carries `required` per field: promote skips redeploy + gates on required ones and
* surfaces optional ones so a cleared filter never broadens behavior silently.
*/
clearedDependents: NeedsConfigurationField[]
/**
* Source block id -> assigned target block id, so the caller can persist the
* block-identity pairs (see `recordForkBlockPairs`) that keep promotes reversible.
*/
blockIdMapping: Map<string, string>
}
export interface CopyWorkflowStateParams {
tx: DbOrTx
targetWorkflowId: string
targetWorkspaceId: string
userId: string
mode: 'create' | 'replace'
now: Date
/** Source workflow's deployed state (the only thing fork/promote copies). */
sourceState: WorkflowState
/** Source workflow metadata for naming, folder placement, and sort order. */
sourceMeta: {
name: string
description: string | null
folderId: string | null
sortOrder: number
/**
* Whether the source's deployed API is public (unauthenticated). Carried onto sync targets
* so a public source stays public after push/pull - the target org's own access-control
* gate (`validatePublicApiAllowed`) still applies at execution. Omitted at fork-create:
* the child starts undeployed and private (going public is an explicit act there).
*/
isPublicApi?: boolean
}
/** source workflow id -> target workflow id, for `workflow-selector` references */
workflowIdMap: Map<string, string>
/** source folder id -> target folder id */
folderIdMap: Map<string, string>
/** Optional resource-reference remap applied to every block's subBlocks. */
transformSubBlocks?: SubBlockTransform
/**
* The target workflow's current draft subBlocks (block id -> subBlocks), for
* `replace` mode only. When present, required dependents that the sync left empty
* (the parent change cleared and the stored mapping didn't fill) are reported in
* {@link CopyWorkflowResult.needsConfiguration}.
*/
targetCurrentBlocks?: Map<string, SubBlockRecord>
/**
* Per-block (block id -> subBlock key -> value) stored dependent values applied last,
* after the reference transform cleared the source's, so the stored mapping is the sole
* source of truth for what each dependent selector resolves to.
*/
dependentOverrides?: Map<string, Map<string, string>>
/**
* Preloaded name registry so name-collision resolution reads from memory instead of one
* query per workflow inside the tx. Build once per copy loop via {@link loadWorkflowNameRegistry}.
*/
nameRegistry: WorkflowNameRegistry
/**
* Resolve each source block to its target block id, reusing the persisted counterpart
* when one exists so a push keeps the parent's original block ids (and webhook URLs)
* instead of re-deriving them (see {@link buildForkBlockIdResolver}). Omitted on fork
* creation, where every id is derived fresh.
*/
resolveBlockId?: ForkBlockIdResolver
requestId?: string
}
/**
* Copy a source workflow's deployed `WorkflowState` into a target workflow,
* assigning deterministic block ids (so trigger webhook URLs and external block
* references stay stable across promotes) and applying the resource-reference
* transform. Writes the remapped state to the target's draft via
* `saveWorkflowToNormalizedTables`. In `create` mode a new workflow row is
* inserted (undeployed); in `replace` mode the existing target row is kept and
* its draft is overwritten. Deploying the target (and capturing the rollback
* point) is the caller's responsibility.
*/
export async function copyWorkflowStateIntoTarget(
params: CopyWorkflowStateParams
): Promise<CopyWorkflowResult> {
const {
tx,
targetWorkflowId,
targetWorkspaceId,
userId,
mode,
now,
sourceState,
sourceMeta,
workflowIdMap,
folderIdMap,
transformSubBlocks,
targetCurrentBlocks,
dependentOverrides,
nameRegistry,
resolveBlockId,
requestId = 'unknown',
} = params
const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null
const varIdMapping = new Map<string, string>()
const remappedVariables: Record<string, WorkflowStateVariable> = {}
for (const [oldVarId, variable] of Object.entries(sourceState.variables ?? {})) {
const newVarId = generateId()
varIdMapping.set(oldVarId, newVarId)
remappedVariables[newVarId] = { ...variable, id: newVarId }
}
const blockIdMapping = new Map<string, string>()
for (const oldBlockId of Object.keys(sourceState.blocks)) {
blockIdMapping.set(
oldBlockId,
resolveBlockId
? resolveBlockId(targetWorkflowId, oldBlockId)
: deriveForkBlockId(targetWorkflowId, oldBlockId)
)
}
const newBlocks: Record<string, BlockState> = {}
const clearedDependents: NeedsConfigurationField[] = []
for (const [oldBlockId, block] of Object.entries(sourceState.blocks)) {
const newBlockId = blockIdMapping.get(oldBlockId)!
let updatedData = block.data
if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) {
const dataObj = block.data as Record<string, unknown>
if (typeof dataObj.parentId === 'string' && blockIdMapping.has(dataObj.parentId)) {
updatedData = {
...dataObj,
parentId: blockIdMapping.get(dataObj.parentId)!,
extent: 'parent',
} as BlockData
}
}
// double-cast-allowed: SubBlockState is structurally a SubBlockRecord entry but lacks the open index signature SubBlockRecord declares
const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks)
let subBlocks: SubBlockRecord = sanitizedSource
// Tracks the block's live `canonicalModes` through this pass, so a `tool-input` reindex
// (a dropped custom-tool/MCP entry shifts later tools' array positions) is visible to every
// later step below that resolves a nested tool's basic/advanced mode - not just the final
// persisted `updatedData`. Starts as the source value; `transformSubBlocks` may replace it.
let activeCanonicalModes: CanonicalModeOverrides | undefined = (
block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined
)?.canonicalModes
if (transformSubBlocks) {
subBlocks = transformSubBlocks(subBlocks, block.type, activeCanonicalModes, (next) => {
activeCanonicalModes = next
updatedData = { ...updatedData, canonicalModes: next } as BlockData
})
}
if (varIdMapping.size > 0) {
subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping)
}
// Cross-workspace copy: clear references to workflows that weren't copied
// rather than leave them pointing at the source workspace.
subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, {
clearUnmapped: true,
canonicalModes: activeCanonicalModes,
})
subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord
// Apply the stored dependent values for this block (the modal's mapping). The reference
// transform already cleared the source's dependent values when their parent was remapped,
// so the stored mapping is the SOLE source of truth - no implicit "preserve the target's
// value" path. Allowlisted (top-level + nested tool params) inside applyDependentOverrides
// so a crafted value can't touch a parent/credential field or inject a bogus subblock.
const targetCurrent = targetCurrentBlocks?.get(newBlockId)
const blockOverrides = dependentOverrides?.get(newBlockId)
if (blockOverrides && blockOverrides.size > 0) {
subBlocks = applyDependentOverrides(subBlocks, block.type, blockOverrides)
}
// Dependents the TARGET had configured that the parent change cleared and nothing
// restored: the target must re-pick required ones (promote skips this workflow's
// redeploy) and is told about optional ones. Keyed on the target draft so a field the
// source carried but the target never set isn't flagged.
if (mode === 'replace' && targetCurrent) {
clearedDependents.push(
...collectClearedDependents(
block.type,
newBlockId,
block.name,
targetCurrent,
subBlocks,
activeCanonicalModes
)
)
}
newBlocks[newBlockId] = {
...block,
id: newBlockId,
// double-cast-allowed: remap helpers return SubBlockRecord; the entries retain the SubBlockState shape this block requires
subBlocks: subBlocks as unknown as Record<string, SubBlockState>,
data: updatedData,
}
}
const newEdges = sourceState.edges.flatMap((edge) => {
const newSource = blockIdMapping.get(edge.source)
const newTarget = blockIdMapping.get(edge.target)
if (!newSource || !newTarget) {
logger.warn(`[${requestId}] Skipping edge with unmapped block reference during fork copy`, {
edgeId: edge.id,
})
return []
}
const newSourceHandle = edge.sourceHandle
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return [
{
...edge,
id: generateId(),
source: newSource,
target: newTarget,
sourceHandle: newSourceHandle,
targetHandle: edge.targetHandle,
},
]
})
const newLoops: Record<string, Loop> = {}
for (const [oldId, loop] of Object.entries(sourceState.loops ?? {})) {
const newId = blockIdMapping.get(oldId) ?? oldId
newLoops[newId] = {
...loop,
id: newId,
nodes: loop.nodes.flatMap((nodeId) => {
const mapped = blockIdMapping.get(nodeId)
return mapped ? [mapped] : []
}),
}
}
const newParallels: Record<string, Parallel> = {}
for (const [oldId, parallel] of Object.entries(sourceState.parallels ?? {})) {
const newId = blockIdMapping.get(oldId) ?? oldId
newParallels[newId] = {
...parallel,
id: newId,
nodes: parallel.nodes.flatMap((nodeId) => {
const mapped = blockIdMapping.get(nodeId)
return mapped ? [mapped] : []
}),
}
}
const resolvedName = resolveTargetWorkflowName(
nameRegistry,
targetFolderId,
sourceMeta.name,
mode === 'replace' ? targetWorkflowId : null
)
// Claim the resolved name so the next workflow in this copy loop sees it taken. The DB
// write below uses the same (folderId, name), so the registry stays consistent with it.
nameRegistry.claim(targetFolderId, resolvedName, targetWorkflowId)
if (mode === 'create') {
await tx.insert(workflow).values({
id: targetWorkflowId,
userId,
workspaceId: targetWorkspaceId,
folderId: targetFolderId,
sortOrder: sourceMeta.sortOrder,
name: resolvedName,
description: sourceMeta.description,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
locked: false,
variables: remappedVariables,
// Deployment visibility follows the source on sync (a public source stays public in
// the target); fork-create omits the field, so the child starts private.
...(sourceMeta.isPublicApi !== undefined ? { isPublicApi: sourceMeta.isPublicApi } : {}),
})
} else {
await tx
.update(workflow)
.set({
name: resolvedName,
description: sourceMeta.description,
folderId: targetFolderId,
variables: remappedVariables,
lastSynced: now,
updatedAt: now,
...(sourceMeta.isPublicApi !== undefined ? { isPublicApi: sourceMeta.isPublicApi } : {}),
})
.where(eq(workflow.id, targetWorkflowId))
}
const remappedState: WorkflowState = {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
variables: remappedVariables,
}
const saved = await saveWorkflowToNormalizedTables(targetWorkflowId, remappedState, tx)
if (!saved.success) {
throw new Error(`Failed to write forked workflow ${targetWorkflowId}: ${saved.error}`)
}
return {
targetWorkflowId,
mode,
name: resolvedName,
blocksCount: Object.keys(newBlocks).length,
edgesCount: newEdges.length,
subflowsCount: Object.keys(newLoops).length + Object.keys(newParallels).length,
clearedDependents,
blockIdMapping,
}
}
@@ -0,0 +1,191 @@
import { db, runOutsideTransactionContext } from '@sim/db'
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkspaceForkDeployBridge')
/**
* Hard ceiling on how many deployed workflows one fork/promote loads into memory at
* once (each as a full `WorkflowState`). There is no per-workspace workflow cap in
* the product, so this is the safety valve: real workspaces hold tens to low
* hundreds, making this ~5-10x headroom that never blocks legitimate use, it sits
* below the fork feature's other item caps (resource selection 2000, mapping
* entries 5000 - both lighter-weight than full states), and it bounds a pathological
* workspace to a few hundred MB of transient state instead of an unbounded load.
*/
export const MAX_FORK_DEPLOYED_WORKFLOWS = 1000
export interface DeployedWorkflowSummary {
id: string
name: string
description: string | null
folderId: string | null
sortOrder: number
/** Whether the deployed API accepts unauthenticated calls; carried onto sync targets. */
isPublicApi: boolean
}
/**
* Workflows in a workspace that are deployed and not archived - the only ones that
* fork/promote. Requires an actually-active deployment version, not just the
* `isDeployed` flag: a workflow flagged deployed with no active version (a "ghost"
* left by an inconsistent state) has nothing to copy, so excluding it here keeps the
* diff/plan counts aligned with what apply actually writes instead of over-reporting
* then silently skipping it. Correlated `exists` (not a join) so a workflow is never
* double-listed if more than one active version row ever exists.
*/
export async function listDeployedWorkflows(
executor: DbOrTx,
workspaceId: string
): Promise<DeployedWorkflowSummary[]> {
return executor
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
folderId: workflow.folderId,
sortOrder: workflow.sortOrder,
isPublicApi: workflow.isPublicApi,
})
.from(workflow)
.where(
and(
eq(workflow.workspaceId, workspaceId),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
exists(
db
.select({ one: sql`1` })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
)
)
)
}
/** The active deployment version number for a workflow, or null when it has none. */
export async function getActiveDeploymentVersionNumber(
executor: DbOrTx,
workflowId: string
): Promise<number | null> {
const [row] = await executor
.select({ version: workflowDeploymentVersion.version })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
return row?.version ?? null
}
/**
* Batched {@link getActiveDeploymentVersionNumber}: the active deployed version per
* workflow id, so promote's apply phase resolves every prior version in one query
* instead of N round-trips inside the (locked) transaction. Workflows with no active
* version are absent from the map.
*/
export async function getActiveDeploymentVersionNumbers(
executor: DbOrTx,
workflowIds: string[]
): Promise<Map<string, number>> {
if (workflowIds.length === 0) return new Map()
const rows = await executor
.select({
workflowId: workflowDeploymentVersion.workflowId,
version: workflowDeploymentVersion.version,
})
.from(workflowDeploymentVersion)
.where(
and(
inArray(workflowDeploymentVersion.workflowId, workflowIds),
eq(workflowDeploymentVersion.isActive, true)
)
)
return new Map(rows.map((row) => [row.workflowId, row.version]))
}
/**
* Read a source workspace's deployed workflows and each one's active deployed state
* on the global pool. Fork/promote callers MUST run this BEFORE opening their
* transaction: doing these heavy per-workflow reads inside the tx checks out a
* SECOND pooled connection while the tx holds the first, which can deadlock the
* pool at saturation (primary pool max is 15). The source is read-only for the
* operation, so a pre-transaction snapshot is the value that gets force-pushed.
*
* Holds every source state in memory at once (bounded by the workspace's deployed
* workflow count) - the apply step needs each state to write its target inside the
* single atomic transaction, so it cannot stream them one at a time.
*/
export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promise<{
deployedWorkflows: DeployedWorkflowSummary[]
sourceStates: Map<string, WorkflowState>
}> {
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
// Fail fast on the cheap count before loading any heavy state into memory.
if (deployedWorkflows.length > MAX_FORK_DEPLOYED_WORKFLOWS) {
throw new ForkError(
`This workspace has ${deployedWorkflows.length} deployed workflows, which exceeds the fork/sync limit of ${MAX_FORK_DEPLOYED_WORKFLOWS}.`,
400
)
}
// Read states in bounded-concurrency batches instead of one serial await per workflow:
// serial cost is O(workflows) round trips (this also runs on the diff preview, refetched
// while the sync modal is open). The cap keeps concurrent global-pool checkouts well
// under the pool max even at the workflow ceiling, and this runs BEFORE any transaction.
const sourceStates = new Map<string, WorkflowState>()
const READ_CONCURRENCY = 5
for (let i = 0; i < deployedWorkflows.length; i += READ_CONCURRENCY) {
const batch = deployedWorkflows.slice(i, i + READ_CONCURRENCY)
const states = await Promise.all(batch.map((wf) => readDeployedState(wf.id, sourceWorkspaceId)))
batch.forEach((wf, index) => {
const state = states[index]
if (state) sourceStates.set(wf.id, state)
})
}
return { deployedWorkflows, sourceStates }
}
/**
* Read a workflow's active deployed state as a `WorkflowState`. Returns null ONLY
* when the workflow genuinely has no active deployment (a legitimate skip); real
* DB/migration errors propagate so the caller fails loudly instead of silently
* dropping the workflow from the fork/promote. Block migrations (credential remap
* to current ids) are applied so copied references reflect current resources.
*/
export async function readDeployedState(
workflowId: string,
workspaceId: string
): Promise<WorkflowState | null> {
// This reads the (unchanged) SOURCE workspace on the global pool. Callers like
// promote run it inside their transaction, so escape the tx context: the read
// must not join the promote's transaction (and the tripwire forbids global-pool
// queries inside a tx). Outside a transaction this is a no-op.
return runOutsideTransactionContext(async () => {
const version = await getActiveDeploymentVersionNumber(db, workflowId)
if (version == null) {
logger.warn('No active deployment for workflow during fork/promote', { workflowId })
return null
}
const data = await loadDeployedWorkflowState(workflowId, workspaceId)
return {
blocks: data.blocks,
edges: data.edges,
loops: data.loops,
parallels: data.parallels,
variables: (data.variables ?? {}) as Record<string, Variable>,
}
})
}
@@ -0,0 +1,140 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckStorageQuota } = vi.hoisted(() => ({
mockCheckStorageQuota: vi.fn(),
}))
vi.mock('@/lib/billing/storage', () => ({
checkStorageQuota: mockCheckStorageQuota,
}))
/**
* Minimal stand-in for the domain error so this unit test never loads the authz module's
* billing/feature-flag import chain. Shape-compatible with the real `ForkError`.
*/
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
ForkError: class ForkError extends Error {
statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = 'ForkError'
this.statusCode = statusCode
}
},
}))
import type { DbOrTx } from '@/lib/db/types'
import {
assertForkStorageHeadroom,
sumForkCopyBytes,
} from '@/ee/workspace-forking/lib/copy/storage-quota'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
/**
* Fake executor resolving one aggregate row per query, in call order. Supports both sum
* shapes: `select().from().where()` (files) and `select().from().innerJoin().where()` (KB
* documents joined to their live KB row).
*/
function makeExecutor(totals: Array<number | string>) {
let call = 0
const next = () => Promise.resolve([{ total: totals[call++] ?? 0 }])
const select = vi.fn(() => ({
from: () => ({
where: next,
innerJoin: () => ({ where: next }),
}),
}))
return { executor: { select } as unknown as DbOrTx, select }
}
describe('sumForkCopyBytes', () => {
it('adds the workspace-file and KB-document byte sums', async () => {
const { executor, select } = makeExecutor([300, 700])
const bytes = await sumForkCopyBytes(executor, 'src-ws', {
fileIds: ['wf-1'],
knowledgeBaseIds: ['kb-1'],
})
expect(bytes).toBe(1000)
expect(select).toHaveBeenCalledTimes(2)
})
it('coerces driver string aggregates (bigint sums) to numbers', async () => {
const { executor } = makeExecutor(['1024'])
const bytes = await sumForkCopyBytes(executor, 'src-ws', { fileKeys: ['workspace/src/k1'] })
expect(bytes).toBe(1024)
})
it('runs no query for an empty selection', async () => {
const { executor, select } = makeExecutor([])
const bytes = await sumForkCopyBytes(executor, 'src-ws', {
fileIds: [],
fileKeys: [],
knowledgeBaseIds: [],
})
expect(bytes).toBe(0)
expect(select).not.toHaveBeenCalled()
})
it('skips the file query when only KBs are selected (and vice versa)', async () => {
const { executor, select } = makeExecutor([555])
const bytes = await sumForkCopyBytes(executor, 'src-ws', { knowledgeBaseIds: ['kb-1'] })
expect(bytes).toBe(555)
expect(select).toHaveBeenCalledTimes(1)
})
})
describe('assertForkStorageHeadroom', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('never consults the quota helper for zero bytes', async () => {
await assertForkStorageHeadroom({ userId: 'user-1', bytes: 0 })
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
})
it('resolves when the scope has headroom', async () => {
mockCheckStorageQuota.mockResolvedValue({ allowed: true, currentUsage: 10, limit: 100 })
await expect(
assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 })
).resolves.toBeUndefined()
expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 50)
})
it("throws a 413 ForkError carrying the upload path's quota message when over quota", async () => {
mockCheckStorageQuota.mockResolvedValue({
allowed: false,
currentUsage: 99,
limit: 100,
error: 'Storage limit exceeded. Used: 10.50GB, Limit: 10GB',
})
const rejection = expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 })).rejects
await rejection.toBeInstanceOf(ForkError)
await rejection.toMatchObject({
statusCode: 413,
message:
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB',
})
})
it('falls back to a generic storage message when the quota helper omits one', async () => {
mockCheckStorageQuota.mockResolvedValue({ allowed: false, currentUsage: 0, limit: 0 })
await expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 1 })).rejects.toThrow(
'Not enough storage to copy the selected resources. Storage limit exceeded'
)
})
})
@@ -0,0 +1,126 @@
import { document, knowledgeBase, workspaceFiles } from '@sim/db/schema'
import { and, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
import { checkStorageQuota } from '@/lib/billing/storage'
import type { DbOrTx } from '@/lib/db/types'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
/** Resource ids whose blob bytes a fork/sync copy would duplicate into the target. */
export interface ForkCopyBytesSelection {
/** Workspace files selected by `workspace_files.id` (the fork modal's picker shape). */
fileIds?: string[]
/** Workspace files selected by storage key (the sync copy selection shape). */
fileKeys?: string[]
/** Knowledge bases whose live documents' stored blobs would be re-keyed into the target. */
knowledgeBaseIds?: string[]
}
/**
* Byte total of the workspace-file blobs a copy selection would duplicate. Applies the
* same row filters as `planForkFileCopies` (source workspace, durable `workspace`
* context, non-deleted, id/key selectors OR'd), so the sum covers exactly the rows the
* copy would plan.
*/
async function sumWorkspaceFileBytes(
executor: DbOrTx,
sourceWorkspaceId: string,
fileIds: string[],
fileKeys: string[]
): Promise<number> {
if (fileIds.length === 0 && fileKeys.length === 0) return 0
const selectors = [
fileIds.length > 0 ? inArray(workspaceFiles.id, fileIds) : undefined,
fileKeys.length > 0 ? inArray(workspaceFiles.key, fileKeys) : undefined,
].filter((clause): clause is NonNullable<typeof clause> => clause !== undefined)
const rows = await executor
.select({ total: sql<string>`coalesce(sum(${workspaceFiles.size}), 0)` })
.from(workspaceFiles)
.where(
and(
selectors.length === 1 ? selectors[0] : or(...selectors),
eq(workspaceFiles.workspaceId, sourceWorkspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
// `sum()` comes back as a string (bigint) from the driver; coerce explicitly.
return Number(rows[0]?.total ?? 0)
}
/**
* Byte total of the KB document blobs the selected knowledge bases would re-key into the
* target. Scoped to live KBs in the source workspace (mirroring the container copy) and
* to LIVE documents with an internal blob: external/`data:` documents have a null
* `storageKey` (no blob is duplicated), and embeddings are DB rows the upload path never
* counts, so neither contributes bytes here.
*/
async function sumKbDocumentBytes(
executor: DbOrTx,
sourceWorkspaceId: string,
knowledgeBaseIds: string[]
): Promise<number> {
if (knowledgeBaseIds.length === 0) return 0
const rows = await executor
.select({ total: sql<string>`coalesce(sum(${document.fileSize}), 0)` })
.from(document)
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
.where(
and(
inArray(knowledgeBase.id, knowledgeBaseIds),
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
isNull(knowledgeBase.deletedAt),
isNull(document.deletedAt),
isNull(document.archivedAt),
isNotNull(document.storageKey)
)
)
return Number(rows[0]?.total ?? 0)
}
/**
* Byte total a fork/sync copy selection would duplicate into the target: selected
* workspace-file blobs plus the selected knowledge bases' stored document blobs. Sizes
* come from the metadata rows (`workspace_files.size`, `document.file_size`) - no blob
* reads. Both sums scope to the source workspace with the same filters the copy itself
* applies, so an id that is not actually copyable can only over-count (block), never
* under-count.
*/
export async function sumForkCopyBytes(
executor: DbOrTx,
sourceWorkspaceId: string,
selection: ForkCopyBytesSelection
): Promise<number> {
const fileBytes = await sumWorkspaceFileBytes(
executor,
sourceWorkspaceId,
selection.fileIds ?? [],
selection.fileKeys ?? []
)
const kbBytes = await sumKbDocumentBytes(
executor,
sourceWorkspaceId,
selection.knowledgeBaseIds ?? []
)
return fileBytes + kbBytes
}
/**
* Assert the initiating user's storage scope has headroom for `bytes` of copied blobs,
* using the exact quota helper the upload path uses (`checkStorageQuota`, which resolves
* the org-pooled vs personal scope from the user's subscription and always allows when
* billing is disabled). Over quota throws a {@link ForkError} (413, matching the upload
* routes' storage-limit status) carrying the upload path's quota error message, so the
* fork/sync modals surface the same user-facing text an over-quota upload would.
*/
export async function assertForkStorageHeadroom(params: {
userId: string
bytes: number
}): Promise<void> {
const { userId, bytes } = params
if (bytes <= 0) return
const quota = await checkStorageQuota(userId, bytes)
if (quota.allowed) return
throw new ForkError(
`Not enough storage to copy the selected resources. ${quota.error ?? 'Storage limit exceeded'}`,
413
)
}
@@ -0,0 +1,41 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildForkWorkflowIdMap } from '@/ee/workspace-forking/lib/copy/workflow-id-map'
describe('buildForkWorkflowIdMap', () => {
const sequentialIds = () => {
let n = 0
return () => `child-${++n}`
}
it('excludes a deployed source whose state failed to load from the map (and so the identity seed)', () => {
const deployed = [{ id: 'wf-a' }, { id: 'wf-b' }, { id: 'wf-c' }]
// wf-b's deployed state failed to load - the copy loop skips it.
const map = buildForkWorkflowIdMap(deployed, new Set(['wf-a', 'wf-c']), sequentialIds())
// wf-b is absent, so a copied workflow's ref to it clears (not dangle) and the identity seed
// (derived from this map's entries) never gets an orphan row pointing at a never-created child.
expect([...map.keys()]).toEqual(['wf-a', 'wf-c'])
expect(map.has('wf-b')).toBe(false)
expect(map.get('wf-a')).toBe('child-1')
expect(map.get('wf-c')).toBe('child-2')
})
it('maps a both-deployed pair when both states loaded (refs remap, not clear)', () => {
const deployed = [{ id: 'parent-wf' }, { id: 'child-wf' }]
const map = buildForkWorkflowIdMap(
deployed,
new Set(['parent-wf', 'child-wf']),
sequentialIds()
)
expect([...map.keys()]).toEqual(['parent-wf', 'child-wf'])
expect(map.get('parent-wf')).toBe('child-1')
expect(map.get('child-wf')).toBe('child-2')
})
it('returns an empty map when no states loaded', () => {
const map = buildForkWorkflowIdMap([{ id: 'wf-a' }], new Set(), () => 'x')
expect(map.size).toBe(0)
})
})
@@ -0,0 +1,21 @@
import { generateId } from '@sim/utils/id'
/**
* Build the source->child workflow id map for a fork create, scoped to the deployed workflows whose
* state actually LOADED (the set the copy loop writes). A deployed source whose state failed to load
* is EXCLUDED, so a copied workflow's reference to it clears (clearUnmapped) instead of pointing at a
* never-created child, and no orphan `workspace_fork_resource_map` identity row is seeded for it (the
* identity seed is derived from this map). Mirrors promote's writtenItems-only scoping
* (`buildPromoteWorkflowIdMap`). `generateChildId` is injectable for deterministic tests.
*/
export function buildForkWorkflowIdMap(
deployedWorkflows: ReadonlyArray<{ id: string }>,
loadedStateWorkflowIds: ReadonlySet<string>,
generateChildId: () => string = generateId
): Map<string, string> {
const map = new Map<string, string>()
for (const wf of deployedWorkflows) {
if (loadedStateWorkflowIds.has(wf.id)) map.set(wf.id, generateChildId())
}
return map
}
@@ -0,0 +1,224 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
const { mockGetEdgeMappingRows, mockAcquireLock } = vi.hoisted(() => ({
mockGetEdgeMappingRows: vi.fn(),
mockAcquireLock: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
getEdgeMappingRows: mockGetEdgeMappingRows,
}))
vi.mock('@/lib/mcp/server-locks', () => ({
acquireWorkflowMcpServerLock: mockAcquireLock,
}))
import type { DbOrTx } from '@/lib/db/types'
import {
copyForkWorkflowMcpAttachments,
reconcileForkWorkflowMcpAttachments,
} from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments'
/** Sequenced select mock + captured inserts/updates. */
function makeTx(selectResults: unknown[][]) {
const inserted: Array<Record<string, unknown>> = []
const updates: Array<Record<string, unknown>> = []
let call = 0
const select = vi.fn(() => ({
from: () => ({ where: () => Promise.resolve(selectResults[call++] ?? []) }),
}))
const tx = {
select,
insert: () => ({
values: (values: Array<Record<string, unknown>>) => {
inserted.push(...values)
return Promise.resolve()
},
}),
update: () => ({
set: (set: Record<string, unknown>) => ({
where: () => {
updates.push(set)
return Promise.resolve()
},
}),
}),
}
return { tx: tx as unknown as DbOrTx, inserted, updates, select }
}
const attachment = (overrides: Record<string, unknown> = {}) => ({
serverId: 'srv-src',
workflowId: 'wf-src',
toolName: 'run_support_flow',
toolDescription: 'Runs the support flow',
parameterSchema: { type: 'object', properties: {} },
parameterDescriptionOverrides: {},
...overrides,
})
const serverMappingRow = {
id: 'map-1',
childWorkspaceId: 'child-ws',
resourceType: 'workflow_mcp_server' as const,
parentResourceId: 'srv-parent',
childResourceId: 'srv-child',
}
describe('reconcileForkWorkflowMcpAttachments', () => {
it('creates the target attachment for a mapped server + written pair (push: child -> parent)', async () => {
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
mockAcquireLock.mockClear()
const { tx, inserted, select } = makeTx([
[{ id: 'srv-child' }, { id: 'srv-parent' }], // both mapped servers still live
[attachment({ serverId: 'srv-child', workflowId: 'wf-child' })],
[], // no existing target attachments
])
const result = await reconcileForkWorkflowMcpAttachments({
tx,
childWorkspaceId: 'child-ws',
sourceIsParent: false, // push: source is the child
now: new Date(),
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
})
expect(inserted).toHaveLength(1)
expect(inserted[0]).toMatchObject({
serverId: 'srv-parent',
workflowId: 'wf-parent',
toolName: 'run_support_flow',
})
expect(result.affectedServerIds).toEqual(['srv-parent'])
expect(mockAcquireLock).toHaveBeenCalledWith(tx, 'srv-parent')
// The lock must precede every read: locking after the diff is computed would let a
// concurrent attach commit in between and abort the promote on the unique constraint.
expect(mockAcquireLock.mock.invocationCallOrder[0]).toBeLessThan(
select.mock.invocationCallOrder[0]
)
})
it('archives a target attachment whose source counterpart was detached', async () => {
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
const { tx, inserted, updates } = makeTx([
[{ id: 'srv-child' }, { id: 'srv-parent' }], // both mapped servers still live
[], // source has no attachments left
[
{
id: 'tool-tgt',
serverId: 'srv-parent',
workflowId: 'wf-parent',
toolName: 'run_support_flow',
toolDescription: null,
parameterDescriptionOverrides: {},
},
],
])
const result = await reconcileForkWorkflowMcpAttachments({
tx,
childWorkspaceId: 'child-ws',
sourceIsParent: false,
now: new Date(),
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
})
expect(inserted).toHaveLength(0)
expect(updates).toHaveLength(1)
expect(updates[0].archivedAt).toBeInstanceOf(Date)
expect(result.affectedServerIds).toEqual(['srv-parent'])
})
it('refreshes drifted metadata on an existing target attachment', async () => {
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
const { tx, updates } = makeTx([
[{ id: 'srv-child' }, { id: 'srv-parent' }], // both mapped servers still live
[attachment({ serverId: 'srv-child', workflowId: 'wf-child', toolName: 'renamed_tool' })],
[
{
id: 'tool-tgt',
serverId: 'srv-parent',
workflowId: 'wf-parent',
toolName: 'run_support_flow',
toolDescription: 'Runs the support flow',
parameterDescriptionOverrides: {},
},
],
])
await reconcileForkWorkflowMcpAttachments({
tx,
childWorkspaceId: 'child-ws',
sourceIsParent: false,
now: new Date(),
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
})
expect(updates).toHaveLength(1)
expect(updates[0].toolName).toBe('renamed_tool')
})
it('skips a mapped pair whose target server was deleted (stale identity row must never FK-crash the sync)', async () => {
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
const { tx, inserted } = makeTx([
[{ id: 'srv-child' }], // target server srv-parent hard-deleted: only the source is live
])
const result = await reconcileForkWorkflowMcpAttachments({
tx,
childWorkspaceId: 'child-ws',
sourceIsParent: false,
now: new Date(),
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
})
expect(inserted).toHaveLength(0)
expect(result.affectedServerIds).toEqual([])
})
it('no-ops with no mapped servers (attachments follow the server identity)', async () => {
mockGetEdgeMappingRows.mockResolvedValue([
{ ...serverMappingRow, resourceType: 'table' as const },
])
const { tx, inserted } = makeTx([])
const result = await reconcileForkWorkflowMcpAttachments({
tx,
childWorkspaceId: 'child-ws',
sourceIsParent: false,
now: new Date(),
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
})
expect(inserted).toHaveLength(0)
expect(result.affectedServerIds).toEqual([])
})
})
describe('copyForkWorkflowMcpAttachments', () => {
it('copies an attachment only when BOTH its server and workflow were copied', async () => {
const { tx, inserted } = makeTx([
[
attachment(), // both mapped
attachment({ serverId: 'srv-uncopied', workflowId: 'wf-src' }),
attachment({ serverId: 'srv-src', workflowId: 'wf-uncopied' }),
],
])
const result = await copyForkWorkflowMcpAttachments({
tx,
serverIdMap: new Map([['srv-src', 'srv-copy']]),
workflowIdMap: new Map([['wf-src', 'wf-copy']]),
now: new Date(),
})
expect(result.copied).toBe(1)
expect(inserted[0]).toMatchObject({
serverId: 'srv-copy',
workflowId: 'wf-copy',
toolName: 'run_support_flow',
})
})
it('no-ops when either id map is empty', async () => {
const { tx, inserted } = makeTx([])
const result = await copyForkWorkflowMcpAttachments({
tx,
serverIdMap: new Map(),
workflowIdMap: new Map([['wf-src', 'wf-copy']]),
now: new Date(),
})
expect(result.copied).toBe(0)
expect(inserted).toHaveLength(0)
})
})
@@ -0,0 +1,296 @@
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { acquireWorkflowMcpServerLock } from '@/lib/mcp/server-locks'
import { validateMcpToolMetadataForStorage } from '@/lib/mcp/tool-limits'
import { getEdgeMappingRows } from '@/ee/workspace-forking/lib/mapping/mapping-store'
/**
* The seed `parameterSchema` for a copied attachment. The source's schema is copied so the tool
* serves correctly before the target's first deploy, UNLESS it exceeds the per-tool storage
* limit - then the empty schema is seeded instead (the same degradation the deploy-time sync
* applies) and the deployment outbox re-derives the real one when the target deploys.
*/
function seedParameterSchema(parameterSchema: unknown): unknown {
const invalid = validateMcpToolMetadataForStorage({
parameterSchema: parameterSchema as Record<string, unknown>,
})
return invalid ? { type: 'object', properties: {} } : parameterSchema
}
export interface ForkMcpAttachmentPair {
sourceWorkflowId: string
targetWorkflowId: string
}
/**
* Copy `workflow_mcp_tool` attachments into a fresh fork: every source attachment whose server
* AND workflow were both copied gets a child row (fresh id; metadata + schema copied - the child
* re-derives the schema when it first deploys). Insert-only: the child is brand new, so there is
* nothing to update or archive, and no server locks are needed (the child's servers are
* invisible until the fork transaction commits). Must run AFTER the child workflow rows exist
* (FK). A no-op when either map is empty.
*/
export async function copyForkWorkflowMcpAttachments(params: {
tx: DbOrTx
/** Source workflow-publishing server id -> child copy id. */
serverIdMap: ReadonlyMap<string, string>
/** Source workflow id -> child workflow id. */
workflowIdMap: ReadonlyMap<string, string>
now: Date
}): Promise<{ copied: number }> {
const { tx, serverIdMap, workflowIdMap, now } = params
if (serverIdMap.size === 0 || workflowIdMap.size === 0) return { copied: 0 }
const sourceAttachments = await tx
.select({
serverId: workflowMcpTool.serverId,
workflowId: workflowMcpTool.workflowId,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
})
.from(workflowMcpTool)
.where(
and(
inArray(workflowMcpTool.serverId, [...serverIdMap.keys()]),
inArray(workflowMcpTool.workflowId, [...workflowIdMap.keys()]),
isNull(workflowMcpTool.archivedAt)
)
)
const inserts: (typeof workflowMcpTool.$inferInsert)[] = []
for (const attachment of sourceAttachments) {
const childServerId = serverIdMap.get(attachment.serverId)
const childWorkflowId = workflowIdMap.get(attachment.workflowId)
if (!childServerId || !childWorkflowId) continue
inserts.push({
id: generateId(),
serverId: childServerId,
workflowId: childWorkflowId,
toolName: attachment.toolName,
toolDescription: attachment.toolDescription,
parameterSchema: seedParameterSchema(attachment.parameterSchema),
parameterDescriptionOverrides: attachment.parameterDescriptionOverrides,
createdAt: now,
updatedAt: now,
})
}
if (inserts.length > 0) await tx.insert(workflowMcpTool).values(inserts)
return { copied: inserts.length }
}
/**
* Mirror `workflow_mcp_tool` attachments (a workflow exposed as a tool on a
* workflow-publishing MCP server) onto the target side of a sync, through the edge's
* `workflow_mcp_server` identity map (seeded when a fork copies the server shells).
*
* For each written workflow pair whose source is attached to a MAPPED server:
* - a missing target attachment is created (metadata copied; `parameterSchema` is copied as a
* seed and re-derived by the deployment outbox when the target deploys),
* - an existing one has its user-set metadata (tool name / description / description
* overrides) refreshed to the source's,
* - a target attachment on a mapped server + synced workflow with NO source counterpart is
* archived (the source detached it) - target attachments on unmapped servers or unsynced
* workflows are never touched.
*
* Unmapped servers are skipped entirely: attachment sync follows the server identity, exactly
* like subblock references follow resource mappings. Bounded by (written workflows x mapped
* servers); acquires the same per-server advisory locks the deploy-time tool sync takes.
* Returns the affected target server ids so the caller can notify them post-commit.
*/
export async function reconcileForkWorkflowMcpAttachments(params: {
tx: DbOrTx
childWorkspaceId: string
/** True when the sync SOURCE is the parent workspace (a pull). */
sourceIsParent: boolean
now: Date
/** The workflow pairs THIS sync wrote (replace + create). */
writtenPairs: ForkMcpAttachmentPair[]
}): Promise<{ affectedServerIds: string[] }> {
const { tx, childWorkspaceId, sourceIsParent, now, writtenPairs } = params
if (writtenPairs.length === 0) return { affectedServerIds: [] }
const mappingRows = await getEdgeMappingRows(tx, childWorkspaceId)
const serverMap = new Map<string, string>()
for (const row of mappingRows) {
if (row.resourceType !== 'workflow_mcp_server' || row.childResourceId == null) continue
if (sourceIsParent) serverMap.set(row.parentResourceId, row.childResourceId)
else serverMap.set(row.childResourceId, row.parentResourceId)
}
if (serverMap.size === 0) return { affectedServerIds: [] }
// Same per-server serialization as the deploy-time tool sync and the attach/delete routes,
// in sorted order so two concurrent syncs can't deadlock on each other's server locks.
// Acquired BEFORE the reads below: every other attachment writer locks first, so locking
// after computing the diff would let a concurrent attach commit in between and turn our
// insert into a unique-constraint abort of the whole promote transaction (and a concurrent
// server delete into an FK abort).
for (const serverId of [...new Set(serverMap.values())].sort()) {
await acquireWorkflowMcpServerLock(tx, serverId)
}
// Liveness guard: a mapped server may have been deleted since the fork (server deletion is a
// hard delete that cascades its tools but leaves the identity row). A dead SOURCE server has
// nothing to mirror; a dead TARGET server must be skipped or the insert below would violate
// the `server_id` FK and abort the whole promote transaction. Target liveness is stable for
// the rest of the transaction: the delete route takes the per-server lock we now hold.
const mappedServerIds = [...new Set([...serverMap.keys(), ...serverMap.values()])]
const liveServerIds = new Set(
(
await tx
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(
and(inArray(workflowMcpServer.id, mappedServerIds), isNull(workflowMcpServer.deletedAt))
)
).map((row) => row.id)
)
for (const [sourceServerId, targetServerId] of serverMap) {
if (!liveServerIds.has(sourceServerId) || !liveServerIds.has(targetServerId)) {
serverMap.delete(sourceServerId)
}
}
if (serverMap.size === 0) return { affectedServerIds: [] }
const targetBySource = new Map(
writtenPairs.map((pair) => [pair.sourceWorkflowId, pair.targetWorkflowId])
)
const sourceAttachments = await tx
.select({
serverId: workflowMcpTool.serverId,
workflowId: workflowMcpTool.workflowId,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterSchema: workflowMcpTool.parameterSchema,
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
})
.from(workflowMcpTool)
.where(
and(
inArray(workflowMcpTool.workflowId, [...targetBySource.keys()]),
inArray(workflowMcpTool.serverId, [...serverMap.keys()]),
isNull(workflowMcpTool.archivedAt)
)
)
/** Desired live target pairs, keyed `${targetServerId}\u0000${targetWorkflowId}`. */
const desired = new Map<
string,
{
serverId: string
workflowId: string
toolName: string
toolDescription: string | null
parameterSchema: unknown
parameterDescriptionOverrides: Record<string, string>
}
>()
for (const attachment of sourceAttachments) {
const targetServerId = serverMap.get(attachment.serverId)
const targetWorkflowId = targetBySource.get(attachment.workflowId)
if (!targetServerId || !targetWorkflowId) continue
desired.set(`${targetServerId}\u0000${targetWorkflowId}`, {
serverId: targetServerId,
workflowId: targetWorkflowId,
toolName: attachment.toolName,
toolDescription: attachment.toolDescription,
parameterSchema: attachment.parameterSchema,
parameterDescriptionOverrides: attachment.parameterDescriptionOverrides,
})
}
// The reconcile scope: every mapped TARGET server x every synced TARGET workflow. Rows
// outside this product are never touched.
const mappedTargetServerIds = [...new Set(serverMap.values())].sort()
const syncedTargetWorkflowIds = [...new Set(targetBySource.values())]
const existing = await tx
.select({
id: workflowMcpTool.id,
serverId: workflowMcpTool.serverId,
workflowId: workflowMcpTool.workflowId,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
})
.from(workflowMcpTool)
.where(
and(
inArray(workflowMcpTool.serverId, mappedTargetServerIds),
inArray(workflowMcpTool.workflowId, syncedTargetWorkflowIds),
isNull(workflowMcpTool.archivedAt)
)
)
const existingByKey = new Map(
existing.map((row) => [`${row.serverId}\u0000${row.workflowId}`, row])
)
const inserts: (typeof workflowMcpTool.$inferInsert)[] = []
const updates: Array<{ id: string; set: Partial<typeof workflowMcpTool.$inferInsert> }> = []
const archiveIds: string[] = []
const affectedServerIds = new Set<string>()
for (const [key, want] of desired) {
const current = existingByKey.get(key)
if (!current) {
inserts.push({
id: generateId(),
serverId: want.serverId,
workflowId: want.workflowId,
toolName: want.toolName,
toolDescription: want.toolDescription,
// Seed with the source's schema; the deployment outbox re-derives it from the
// target's deployed state right after this sync deploys the workflow.
parameterSchema: seedParameterSchema(want.parameterSchema),
parameterDescriptionOverrides: want.parameterDescriptionOverrides,
createdAt: now,
updatedAt: now,
})
affectedServerIds.add(want.serverId)
continue
}
const overridesChanged =
JSON.stringify(current.parameterDescriptionOverrides ?? {}) !==
JSON.stringify(want.parameterDescriptionOverrides ?? {})
if (
current.toolName !== want.toolName ||
current.toolDescription !== want.toolDescription ||
overridesChanged
) {
updates.push({
id: current.id,
set: {
toolName: want.toolName,
toolDescription: want.toolDescription,
parameterDescriptionOverrides: want.parameterDescriptionOverrides,
updatedAt: now,
},
})
affectedServerIds.add(current.serverId)
}
}
for (const [key, row] of existingByKey) {
if (desired.has(key)) continue
archiveIds.push(row.id)
affectedServerIds.add(row.serverId)
}
if (inserts.length === 0 && updates.length === 0 && archiveIds.length === 0) {
return { affectedServerIds: [] }
}
if (inserts.length > 0) await tx.insert(workflowMcpTool).values(inserts)
for (const update of updates) {
await tx.update(workflowMcpTool).set(update.set).where(eq(workflowMcpTool.id, update.id))
}
if (archiveIds.length > 0) {
await tx
.update(workflowMcpTool)
.set({ archivedAt: now, updatedAt: now })
.where(inArray(workflowMcpTool.id, archiveIds))
}
return { affectedServerIds: [...affectedServerIds] }
}
@@ -0,0 +1,215 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSumForkCopyBytes,
mockAssertForkStorageHeadroom,
mockLoadSourceDeployedStates,
mockPlanForkFileCopies,
mockCopyForkResourceContainers,
mockStartBackgroundWork,
mockFinishBackgroundWork,
mockScheduleForkContentCopy,
mockSeedEdgeMappings,
} = vi.hoisted(() => ({
mockSumForkCopyBytes: vi.fn(),
mockAssertForkStorageHeadroom: vi.fn(),
mockLoadSourceDeployedStates: vi.fn(),
mockPlanForkFileCopies: vi.fn(),
mockCopyForkResourceContainers: vi.fn(),
mockStartBackgroundWork: vi.fn(),
mockFinishBackgroundWork: vi.fn(),
mockScheduleForkContentCopy: vi.fn(),
mockSeedEdgeMappings: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/workflows/defaults', () => ({
buildDefaultWorkflowArtifacts: vi.fn(() => ({ workflowState: {} })),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
saveWorkflowToNormalizedTables: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
startBackgroundWork: mockStartBackgroundWork,
finishBackgroundWork: mockFinishBackgroundWork,
}))
vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({
hasForkContentToCopy: vi.fn(() => false),
scheduleForkContentCopy: mockScheduleForkContentCopy,
serializeContentRefMaps: vi.fn(() => ({})),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-chats', () => ({
copyForkChatDeployments: vi.fn(async () => ({ created: 0 })),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
planForkFileCopies: mockPlanForkFileCopies,
}))
vi.mock('@/ee/workspace-forking/lib/copy/workflow-mcp-attachments', () => ({
copyForkWorkflowMcpAttachments: vi.fn(async () => ({ copied: 0 })),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-resources', () => ({
copyForkResourceContainers: mockCopyForkResourceContainers,
}))
vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
sumForkCopyBytes: mockSumForkCopyBytes,
assertForkStorageHeadroom: mockAssertForkStorageHeadroom,
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({
copyWorkflowStateIntoTarget: vi.fn(),
loadWorkflowNameRegistry: vi.fn(async () => new Map()),
resolveForkFolderMapping: vi.fn(async () => new Map()),
}))
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
loadSourceDeployedStates: mockLoadSourceDeployedStates,
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
reconcileForkBlockPairs: vi.fn(),
toForkBlockPairs: vi.fn(() => []),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
seedEdgeMappings: mockSeedEdgeMappings,
}))
vi.mock('@/ee/workspace-forking/lib/remap/fork-bootstrap', () => ({
createForkBootstrapTransform: vi.fn(() => (subBlocks: unknown) => subBlocks),
}))
vi.mock('@/ee/workspace-forking/lib/remap/reference-scan', () => ({
collectReferencedDocumentIds: vi.fn(() => new Set<string>()),
}))
vi.mock('@/lib/workspaces/policy', () => ({
WORKSPACE_MODE: {
PERSONAL: 'personal',
ORGANIZATION: 'organization',
GRANDFATHERED_SHARED: 'grandfathered_shared',
},
}))
import { createFork } from '@/ee/workspace-forking/lib/create-fork'
const SOURCE = { id: 'src-ws', name: 'Parent' } as never
const POLICY = {
organizationId: null,
workspaceMode: 'personal',
billedAccountUserId: null,
} as never
function forkParams(selection?: {
files?: string[]
knowledgeBases?: string[]
}): Parameters<typeof createFork>[0] {
return {
source: SOURCE,
policy: POLICY,
userId: 'user-1',
name: 'My Fork',
selection: {
files: selection?.files ?? [],
tables: [],
knowledgeBases: selection?.knowledgeBases ?? [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
requestId: 'test',
}
}
describe('createFork storage headroom gate', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockSumForkCopyBytes.mockResolvedValue(0)
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map(),
})
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map(),
idMap: new Map(),
blobTasks: [],
})
mockCopyForkResourceContainers.mockResolvedValue({
idMap: new Map(),
mappingEntries: [],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'child-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockStartBackgroundWork.mockResolvedValue('status-1')
mockFinishBackgroundWork.mockResolvedValue(undefined)
})
it('fails an over-quota fork BEFORE any read or write, with the storage error', async () => {
mockSumForkCopyBytes.mockResolvedValue(999_999)
mockAssertForkStorageHeadroom.mockRejectedValue(
new Error(
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB'
)
)
await expect(
createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] }))
).rejects.toThrow('Not enough storage to copy the selected resources')
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 })
// Nothing was read, created, or recorded: the fork failed before all of it.
expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled()
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
expect(mockStartBackgroundWork).not.toHaveBeenCalled()
})
it('proceeds under quota, summing exactly the selected files + knowledge bases', async () => {
mockSumForkCopyBytes.mockResolvedValue(500)
const result = await createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] }))
expect(result.workspace.name).toBe('My Fork')
expect(result.workflowsCopied).toBe(0)
expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', {
fileIds: ['wf-1'],
knowledgeBaseIds: ['kb-1'],
})
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 500 })
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
})
it('seeds identity mappings for copied FILES by storage key (a later sync must not re-offer them)', async () => {
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]),
idMap: new Map([['file-1', 'file-1-copy']]),
blobTasks: [],
})
await createFork(forkParams({ files: ['file-1'] }))
expect(mockSeedEdgeMappings).toHaveBeenCalledTimes(1)
const seeded = mockSeedEdgeMappings.mock.calls[0][3] as Array<Record<string, unknown>>
expect(seeded).toContainEqual({
resourceType: 'file',
parentResourceId: 'workspace/src-ws/a.png',
childResourceId: 'workspace/child/a.png',
})
})
})
@@ -0,0 +1,496 @@
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
}
@@ -0,0 +1,199 @@
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isAppConfigEnabled, isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { HttpError } from '@/lib/core/utils/http-error'
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
import { type ForkEdge, resolveForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
/** Direction of a promote, relative to the workspace the caller is acting from. */
export type PromoteDirection = 'push' | 'pull'
/**
* Gate shared by every fork/promote route. The deployment/entitlement gate is
* unchanged: on Sim Cloud the gate is the Enterprise plan; on self-hosted it's
* `FORKING_ENABLED`, which 404s when unset so a newer image doesn't silently expose
* forking. Mirrors the data-drains gate - this repo gates EE features by plan + env
* flag, not by directory.
*
* Layered on top is the runtime `workspace-forking` flag, a rollout switch enforced
* ONLY where AppConfig is the source of truth (Sim Cloud). It lets us dark-launch
* forking to specific orgs/users/admins without a redeploy; self-hosted/local
* deployments have no AppConfig, so their behaviour is untouched by the flag.
*/
async function assertForkingEnabled(organizationId: string | null, userId: string): Promise<void> {
if (!isBillingEnabled && !isForkingEnabled) {
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
}
if (isBillingEnabled) {
const hasEnterprise = organizationId
? await isOrganizationOnEnterprisePlan(organizationId)
: false
if (!hasEnterprise) {
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
}
}
if (
isAppConfigEnabled &&
!(await isFeatureEnabled('workspace-forking', { userId, orgId: organizationId }))
) {
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
}
}
/**
* Non-throwing availability verdict of the exact {@link assertForkingEnabled} gate
* (env/plan + AppConfig rollout flag), for surfaces that show/hide fork UI. The
* availability route serves this to the client so tab visibility can never drift
* from what the fork routes actually enforce.
*/
export async function isForkingAvailableForWorkspace(
organizationId: string | null,
userId: string
): Promise<boolean> {
try {
await assertForkingEnabled(organizationId, userId)
return true
} catch {
return false
}
}
/**
* Domain error for fork/promote operations. Carries a concrete `statusCode` so
* `withRouteHandler` maps it to the right HTTP status and forwards the
* client-safe `message`.
*/
export class ForkError extends HttpError {
readonly statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = 'ForkError'
this.statusCode = statusCode
}
}
async function requireWorkspace(
workspaceId: string,
userId: string
): Promise<{ workspace: WorkspaceWithOwner; canAdmin: boolean }> {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.exists || !access.workspace) {
throw new ForkError('Workspace not found', 404)
}
await assertForkingEnabled(access.workspace.organizationId, userId)
return { workspace: access.workspace, canAdmin: access.canAdmin }
}
/** Require admin access; returns the (active) workspace. */
export async function assertWorkspaceAdminAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceWithOwner> {
const { workspace, canAdmin } = await requireWorkspace(workspaceId, userId)
if (!canAdmin) {
throw new ForkError('Admin access is required for this workspace', 403)
}
return workspace
}
export interface ForkAuthorization {
source: WorkspaceWithOwner
policy: WorkspaceCreationPolicy
}
/**
* Authorize forking `sourceWorkspaceId`: the caller needs admin access to the
* source (a fork copies its deployed workflows and resources en masse) and must
* pass the workspace-creation policy for the parent's org (the child inherits the
* parent's org/mode; plan caps apply). Org owners/admins derive workspace admin.
*
* `pinOrganization` makes the child ALWAYS land in the source's org - including a
* personal source (org `null`) - rather than the acting user's membership org,
* which the policy would otherwise fall back to when the source is personal.
*/
export async function assertCanFork(
sourceWorkspaceId: string,
userId: string
): Promise<ForkAuthorization> {
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
const policy = await getWorkspaceCreationPolicy({
userId,
activeOrganizationId: source.organizationId,
pinOrganization: true,
})
if (!policy.canCreate) {
throw new ForkError(
policy.reason ?? 'You cannot create another workspace on your current plan',
policy.status >= 400 ? policy.status : 403
)
}
return { source, policy }
}
export interface PromoteAuthorization {
edge: ForkEdge
source: WorkspaceWithOwner
target: WorkspaceWithOwner
sourceWorkspaceId: string
targetWorkspaceId: string
}
/**
* Authorize a promote along the strict edge between `currentWorkspaceId` and
* `otherWorkspaceId`. Requires admin on BOTH the source and the target: a sync
* reads the source's deployed workflows/resources and force-replaces the target's,
* and the sync surface is only ever offered to workspace admins. `push` sends
* current -> other; `pull` brings other -> current.
*/
export async function assertCanPromote(
currentWorkspaceId: string,
otherWorkspaceId: string,
direction: PromoteDirection,
userId: string
): Promise<PromoteAuthorization> {
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
if (!edge) {
throw new ForkError('These workspaces are not a direct fork edge', 400)
}
const sourceWorkspaceId = direction === 'push' ? currentWorkspaceId : otherWorkspaceId
const targetWorkspaceId = direction === 'push' ? otherWorkspaceId : currentWorkspaceId
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
const target = await assertWorkspaceAdminAccess(targetWorkspaceId, userId)
return { edge, source, target, sourceWorkspaceId, targetWorkspaceId }
}
/** Authorize rolling back the last promote into `targetWorkspaceId` (admin only). */
export async function assertCanRollback(
targetWorkspaceId: string,
userId: string
): Promise<WorkspaceWithOwner> {
return assertWorkspaceAdminAccess(targetWorkspaceId, userId)
}
export interface UnlinkAuthorization {
edge: ForkEdge
current: WorkspaceWithOwner
}
/**
* Authorize permanently dissolving the fork edge between `currentWorkspaceId` and
* `otherWorkspaceId`. Requires admin on the ACTING side only (mirrors rollback):
* either participant may sever the association about itself — unlinking removes
* shared edge metadata without reading or writing the other workspace's content,
* and requiring both-side admin would strand an edge whose other side lost its
* admins.
*/
export async function assertCanUnlink(
currentWorkspaceId: string,
otherWorkspaceId: string,
userId: string
): Promise<UnlinkAuthorization> {
const current = await assertWorkspaceAdminAccess(currentWorkspaceId, userId)
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
if (!edge) {
throw new ForkError('These workspaces are not a direct fork edge', 400)
}
return { edge, current }
}
@@ -0,0 +1,129 @@
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
export interface ForkLineageNode {
id: string
name: string
organizationId: string | null
}
export interface ForkLineageChild extends ForkLineageNode {
createdAt: Date
}
export interface ForkEdge {
childWorkspaceId: string
parentWorkspaceId: string
}
/**
* The parent workspace id a fork was created from, or null when the workspace
* is not a fork (or has been archived).
*/
export async function getForkParentId(workspaceId: string): Promise<string | null> {
const [row] = await db
.select({ parentId: workspace.forkedFromWorkspaceId })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
return row?.parentId ?? null
}
/** The parent lineage node for a fork, or null when it has no live parent. */
export async function getForkParent(workspaceId: string): Promise<ForkLineageNode | null> {
const parentId = await getForkParentId(workspaceId)
if (!parentId) return null
const [row] = await db
.select({
id: workspace.id,
name: workspace.name,
organizationId: workspace.organizationId,
})
.from(workspace)
.where(and(eq(workspace.id, parentId), isNull(workspace.archivedAt)))
.limit(1)
return row ?? null
}
/**
* The live (non-archived) forks created from this workspace, newest first, for
* the Forks settings page's read-only fork list.
*/
export async function getForkChildren(workspaceId: string): Promise<ForkLineageChild[]> {
return db
.select({
id: workspace.id,
name: workspace.name,
organizationId: workspace.organizationId,
createdAt: workspace.createdAt,
})
.from(workspace)
.where(and(eq(workspace.forkedFromWorkspaceId, workspaceId), isNull(workspace.archivedAt)))
.orderBy(desc(workspace.createdAt))
}
/**
* Resolve the strict fork edge between two workspaces, identifying which is the
* child (the one whose `forkedFromWorkspaceId` points at the other). Returns
* null when the two workspaces are not a direct parent/child pair.
*/
export async function resolveForkEdge(
workspaceAId: string,
workspaceBId: string
): Promise<ForkEdge | null> {
if (workspaceAId === workspaceBId) return null
if ((await getForkParentId(workspaceAId)) === workspaceBId) {
return { childWorkspaceId: workspaceAId, parentWorkspaceId: workspaceBId }
}
if ((await getForkParentId(workspaceBId)) === workspaceAId) {
return { childWorkspaceId: workspaceBId, parentWorkspaceId: workspaceAId }
}
return null
}
/**
* How long a fork transaction waits for a lock before aborting. Bounds the wait on the
* target/edge advisory locks (and any incidental row lock) so a contended sync into the
* same target fails fast and returns its pooled connection instead of piling waiters up
* and stagnating the pool at scale. 10s favors completing a legit sync queued behind an
* in-flight one, while still tripping on a pathological hold. Connection-level timeouts
* are not used (PlanetScale rejects them) - this is transaction-scoped only.
*/
const FORK_LOCK_TIMEOUT_MS = 10_000
/**
* Apply {@link FORK_LOCK_TIMEOUT_MS} to the current transaction (`set_config(local)`),
* so it covers `pg_advisory_xact_lock` waits too. Call at the very start of a fork
* transaction, before acquiring any lock.
*/
export async function setForkLockTimeout(tx: DbOrTx): Promise<void> {
await tx.execute(sql`select set_config('lock_timeout', ${`${FORK_LOCK_TIMEOUT_MS}ms`}, true)`)
}
/**
* Serialize concurrent promote/rollback on a fork edge with a transaction-scoped
* advisory lock keyed by the edge (the child workspace id). `hashtextextended`
* (64-bit, matching every other advisory lock in the repo) makes a collision
* between distinct keys astronomically unlikely; a collision would only cause
* unnecessary serialization, never a correctness issue.
*/
export async function acquireForkEdgeLock(tx: DbOrTx, childWorkspaceId: string): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-edge:${childWorkspaceId}`}, 0))`
)
}
/**
* Serialize every promote/rollback whose TARGET is this workspace. Sibling forks
* promote into the same parent on different edge locks, so the edge lock alone does
* not serialize them; this lock does, keeping concurrent syncs into one target from
* interleaving and keeping rollback's "newest sync" check race-free. Always acquire
* this BEFORE {@link acquireForkEdgeLock} so the two are taken in a consistent order.
*/
export async function acquireForkTargetLock(tx: DbOrTx, targetWorkspaceId: string): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-target:${targetWorkspaceId}`}, 0))`
)
}
@@ -0,0 +1,65 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockTransaction, mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({
mockTransaction: vi.fn(),
mockSetForkLockTimeout: vi.fn(),
mockAcquireForkEdgeLock: vi.fn(),
}))
vi.mock('@sim/db', () => ({ db: { transaction: mockTransaction } }))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
setForkLockTimeout: mockSetForkLockTimeout,
acquireForkEdgeLock: mockAcquireForkEdgeLock,
}))
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
/** A fake tx whose update returns `updatedRows` and whose deletes record their calls. */
function fakeTx(updatedRows: Array<{ id: string }>) {
const updateWhere = vi.fn(() => ({ returning: vi.fn().mockResolvedValue(updatedRows) }))
const updateSet = vi.fn(() => ({ where: updateWhere }))
const update = vi.fn(() => ({ set: updateSet }))
const deleteWhere = vi.fn().mockResolvedValue(undefined)
const del = vi.fn(() => ({ where: deleteWhere }))
return { tx: { update, delete: del }, update, updateSet, del }
}
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
describe('unlinkForkEdge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('nulls the child pointer and purges all four edge tables under the edge lock', async () => {
const { tx, update, updateSet, del } = fakeTx([{ id: 'child-ws' }])
mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
const result = await unlinkForkEdge(EDGE, 'req-1')
expect(result).toEqual({ unlinked: true })
expect(mockSetForkLockTimeout).toHaveBeenCalledTimes(1)
expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(tx, 'child-ws')
expect(update).toHaveBeenCalledTimes(1)
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ forkedFromWorkspaceId: null }))
expect(del).toHaveBeenCalledTimes(4)
})
it('is an idempotent no-op when the edge was already dissolved', async () => {
const { tx, del } = fakeTx([])
mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
const result = await unlinkForkEdge(EDGE)
expect(result).toEqual({ unlinked: false })
expect(del).not.toHaveBeenCalled()
})
it('propagates a transaction failure without swallowing it', async () => {
mockTransaction.mockRejectedValue(new Error('lock timeout'))
await expect(unlinkForkEdge(EDGE)).rejects.toThrow('lock timeout')
})
})
@@ -0,0 +1,78 @@
import { db } from '@sim/db'
import {
workspace,
workspaceForkBlockMap,
workspaceForkDependentValue,
workspaceForkPromoteRun,
workspaceForkResourceMap,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import {
acquireForkEdgeLock,
type ForkEdge,
setForkLockTimeout,
} from '@/ee/workspace-forking/lib/lineage/lineage'
const logger = createLogger('ForkUnlink')
export interface UnlinkForkResult {
/** False when the edge was already dissolved by a concurrent unlink (idempotent no-op). */
unlinked: boolean
}
/**
* Permanently dissolve a fork edge: null the child's `forkedFromWorkspaceId` (the
* edge's single source of truth) and purge the edge's fork state — resource map,
* block map, dependent values, and promote-run undo points. Both workspaces are
* left untouched; only the association and its metadata are removed.
*
* Runs in one transaction under the edge advisory lock, which every promote and
* rollback on the edge also holds, so an in-flight sync either finishes before the
* unlink or re-resolves the edge afterwards and fails with "not a direct fork edge".
* The edge is re-verified inside the lock; a concurrently-dissolved edge is an
* idempotent success rather than an error.
*/
export async function unlinkForkEdge(
edge: ForkEdge,
requestId?: string
): Promise<UnlinkForkResult> {
const { childWorkspaceId, parentWorkspaceId } = edge
const unlinked = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
await acquireForkEdgeLock(tx, childWorkspaceId)
const updated = await tx
.update(workspace)
.set({ forkedFromWorkspaceId: null, updatedAt: new Date() })
.where(
and(
eq(workspace.id, childWorkspaceId),
eq(workspace.forkedFromWorkspaceId, parentWorkspaceId)
)
)
.returning({ id: workspace.id })
if (updated.length === 0) return false
await tx
.delete(workspaceForkResourceMap)
.where(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId))
await tx
.delete(workspaceForkBlockMap)
.where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId))
await tx
.delete(workspaceForkDependentValue)
.where(eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId))
await tx
.delete(workspaceForkPromoteRun)
.where(eq(workspaceForkPromoteRun.childWorkspaceId, childWorkspaceId))
return true
})
logger.info(`[${requestId ?? 'unlink'}] Fork edge ${unlinked ? 'dissolved' : 'already gone'}`, {
childWorkspaceId,
parentWorkspaceId,
})
return { unlinked }
}
@@ -0,0 +1,50 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { toForkBlockPairs } from '@/ee/workspace-forking/lib/mapping/block-map-store'
describe('toForkBlockPairs', () => {
const mapping = new Map([
['src-1', 'tgt-1'],
['src-2', 'tgt-2'],
])
it('orients source->target as parent->child on pull/create (source = parent)', () => {
expect(toForkBlockPairs(mapping, true, 'wf-parent', 'wf-child')).toEqual([
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'src-1',
childBlockId: 'tgt-1',
},
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'src-2',
childBlockId: 'tgt-2',
},
])
})
it('orients source->target as child->parent on push (source = child)', () => {
expect(toForkBlockPairs(mapping, false, 'wf-child', 'wf-parent')).toEqual([
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'tgt-1',
childBlockId: 'src-1',
},
{
parentWorkflowId: 'wf-parent',
childWorkflowId: 'wf-child',
parentBlockId: 'tgt-2',
childBlockId: 'src-2',
},
])
})
it('returns an empty list for an empty mapping', () => {
expect(toForkBlockPairs(new Map(), true, 'wf-parent', 'wf-child')).toEqual([])
})
})
@@ -0,0 +1,120 @@
import { workspaceForkBlockMap } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import type { ForkBlockMap } from '@/ee/workspace-forking/lib/remap/block-identity'
/** One persisted block-identity pair for an edge (carries both workflow sides). */
export interface ForkBlockPair {
parentWorkflowId: string
parentBlockId: string
childWorkflowId: string
childBlockId: string
}
/**
* Load an edge's persisted block-identity pairs into both lookup directions, each entry
* carrying its target-side workflow so the resolver can scope a reuse to the right workflow
* (see {@link buildForkBlockIdResolver}). Blocks without a pair (added since the last sync)
* fall back to the deterministic derive.
*/
export async function loadForkBlockMap(
executor: DbOrTx,
childWorkspaceId: string
): Promise<ForkBlockMap> {
const rows = await executor
.select({
parentWorkflowId: workspaceForkBlockMap.parentWorkflowId,
parentBlockId: workspaceForkBlockMap.parentBlockId,
childWorkflowId: workspaceForkBlockMap.childWorkflowId,
childBlockId: workspaceForkBlockMap.childBlockId,
})
.from(workspaceForkBlockMap)
.where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId))
const parentToChild = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
const childToParent = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
for (const row of rows) {
parentToChild.set(row.parentBlockId, {
targetBlockId: row.childBlockId,
targetWorkflowId: row.childWorkflowId,
})
childToParent.set(row.childBlockId, {
targetBlockId: row.parentBlockId,
targetWorkflowId: row.parentWorkflowId,
})
}
return { parentToChild, childToParent }
}
/**
* Orient one workflow's copy mapping (`sourceBlockId -> targetBlockId`) into block pairs.
* On pull/create the source is the parent; on push it's the child. The workflow ids are
* fixed for the whole mapping (one source workflow copied into one target workflow).
*/
export function toForkBlockPairs(
blockIdMapping: ReadonlyMap<string, string>,
sourceIsParent: boolean,
sourceWorkflowId: string,
targetWorkflowId: string
): ForkBlockPair[] {
const parentWorkflowId = sourceIsParent ? sourceWorkflowId : targetWorkflowId
const childWorkflowId = sourceIsParent ? targetWorkflowId : sourceWorkflowId
const pairs: ForkBlockPair[] = []
for (const [sourceBlockId, targetBlockId] of blockIdMapping) {
pairs.push({
parentWorkflowId,
childWorkflowId,
parentBlockId: sourceIsParent ? sourceBlockId : targetBlockId,
childBlockId: sourceIsParent ? targetBlockId : sourceBlockId,
})
}
return pairs
}
/**
* Replace the persisted pairs for the promoted SOURCE workflows with the live ones. Deleting
* by the (stable) source-side workflow id first does two things: it nukes pairs for blocks
* the source deleted since the last sync (e.g. a removed trigger), and it clears the old pair
* for a workflow whose target was archived + re-created - so the map always reflects the live
* lineage and a stale pair can never re-home a block onto an archived workflow's id. Block
* identity is otherwise immutable, so on a steady sync this just deletes and re-inserts the
* same pairs. `sourceIsParent` is true on pull/create, false on push.
*/
export async function reconcileForkBlockPairs(
executor: DbOrTx,
childWorkspaceId: string,
sourceIsParent: boolean,
sourceWorkflowIds: string[],
pairs: ForkBlockPair[]
): Promise<void> {
if (sourceWorkflowIds.length > 0) {
const sourceWorkflowColumn = sourceIsParent
? workspaceForkBlockMap.parentWorkflowId
: workspaceForkBlockMap.childWorkflowId
await executor
.delete(workspaceForkBlockMap)
.where(
and(
eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId),
inArray(sourceWorkflowColumn, sourceWorkflowIds)
)
)
}
if (pairs.length === 0) return
const now = new Date()
await executor
.insert(workspaceForkBlockMap)
.values(
pairs.map((pair) => ({
id: generateId(),
childWorkspaceId,
parentWorkflowId: pair.parentWorkflowId,
parentBlockId: pair.parentBlockId,
childWorkflowId: pair.childWorkflowId,
childBlockId: pair.childBlockId,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoNothing()
}
@@ -0,0 +1,149 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
import type {
ForkReference,
ForkReferenceResolver,
} from '@/ee/workspace-forking/lib/remap/remap-references'
/** Executor that returns the queued result arrays in the order queries are issued. */
function queuedExecutor(results: unknown[][]): DbOrTx {
let index = 0
const builder = {
from: () => builder,
innerJoin: () => builder,
where: () => Promise.resolve(results[index++] ?? []),
}
return { select: () => builder } as unknown as DbOrTx
}
function ref(kind: ForkReference['kind'], sourceId: string): ForkReference {
return { kind, sourceId, subBlockKey: 'tools', required: false }
}
const resolveNone: ForkReferenceResolver = () => null
const resolveAll: ForkReferenceResolver = (_kind, sourceId) => sourceId
describe('detectForkCascadeReferences', () => {
it('returns empty when there are no content references', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([]),
sourceWorkspaceId: 'ws',
references: [ref('credential', 'cred-1'), ref('table', 'tbl-1')],
resolve: resolveNone,
})
expect(result.references).toEqual([])
expect(result.unmapped).toEqual([])
expect(result.mcpReauthServerIds).toEqual([])
})
it('surfaces env keys from custom tool code as required unmapped env-var refs', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: 'fetch(`{{API_KEY}}`)' }]]),
sourceWorkspaceId: 'ws',
references: [ref('custom-tool', 't1')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(1)
expect(result.references[0]).toMatchObject({
kind: 'env-var',
sourceId: 'API_KEY',
required: true,
})
expect(result.unmapped).toHaveLength(1)
})
it('marks env-var cascade refs mapped when the resolver finds them in the target', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: '{{API_KEY}}' }]]),
sourceWorkspaceId: 'ws',
references: [ref('custom-tool', 't1')],
resolve: resolveAll,
})
expect(result.references).toHaveLength(1)
expect(result.unmapped).toHaveLength(0)
})
it('extracts env keys from MCP url/headers and flags oauth servers for re-auth', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[
{
id: 'mcp-1',
name: 'Server',
url: 'https://x/{{HOST_KEY}}',
headers: { Authorization: '{{TOKEN}}' },
authType: 'headers',
},
{ id: 'mcp-2', name: 'OAuth Server', url: 'https://y', headers: {}, authType: 'oauth' },
],
]),
sourceWorkspaceId: 'ws',
references: [ref('mcp-server', 'mcp-1'), ref('mcp-server', 'mcp-2')],
resolve: resolveNone,
})
const envIds = result.references
.filter((r) => r.kind === 'env-var')
.map((r) => r.sourceId)
.sort()
expect(envIds).toEqual(['HOST_KEY', 'TOKEN'])
expect(result.mcpReauthServerIds).toEqual(['mcp-2'])
})
it('flags literal MCP header values as inline secrets (not env)', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[
{
id: 'mcp-1',
name: 'Server',
url: 'https://x',
headers: { Authorization: 'sk-literal' },
authType: 'headers',
},
],
]),
sourceWorkspaceId: 'ws',
references: [ref('mcp-server', 'mcp-1')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(0)
expect(result.inlineSecretSources).toHaveLength(1)
})
it('surfaces KB connector credentials as required credential refs', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[{ id: 'kc-1', knowledgeBaseId: 'kb-1', credentialId: 'cred-9', encryptedApiKey: null }],
]),
sourceWorkspaceId: 'ws',
references: [ref('knowledge-base', 'kb-1')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(1)
expect(result.references[0]).toMatchObject({
kind: 'credential',
sourceId: 'cred-9',
required: true,
})
})
it('dedupes a shared env key referenced by two custom tools', async () => {
const result = await detectForkCascadeReferences({
executor: queuedExecutor([
[
{ id: 't1', title: 'A', code: '{{SHARED}}' },
{ id: 't2', title: 'B', code: '{{SHARED}}' },
],
]),
sourceWorkspaceId: 'ws',
references: [ref('custom-tool', 't1'), ref('custom-tool', 't2')],
resolve: resolveNone,
})
expect(result.references).toHaveLength(1)
expect(result.references[0].sourceId).toBe('SHARED')
})
})
@@ -0,0 +1,186 @@
import { customTools, knowledgeBase, knowledgeConnector, mcpServers } from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import {
ENV_REF_PATTERN,
type ForkReference,
type ForkReferenceResolver,
} from '@/ee/workspace-forking/lib/remap/remap-references'
function extractEnvKeys(text: string): string[] {
const keys = new Set<string>()
for (const match of text.matchAll(ENV_REF_PATTERN)) {
if (match[1]) keys.add(match[1])
}
return Array.from(keys)
}
export interface ForkCascadeResult {
/** Transitive env-var / credential references discovered inside referenced resources. */
references: ForkReference[]
unmapped: ForkReference[]
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
mcpReauthServerIds: string[]
/** Human-readable descriptions of inline secrets that cannot be mapped (review-only). */
inlineSecretSources: string[]
}
const EMPTY: ForkCascadeResult = {
references: [],
unmapped: [],
mcpReauthServerIds: [],
inlineSecretSources: [],
}
/**
* Walk the bodies of resources a workflow references (custom tools, MCP servers,
* knowledge bases) and surface the secrets they carry transitively: `{{ENV}}`
* keys inside custom tool code and MCP url/headers, and credential ids on KB
* connectors. These become additional required env-var / credential references
* (validated for existence in the target via `resolve`). OAuth MCP servers and
* inline connector keys are surfaced separately for review since they cannot be
* id-mapped. Reads only the source workspace's resources.
*/
export async function detectForkCascadeReferences(params: {
executor: DbOrTx
sourceWorkspaceId: string
references: ForkReference[]
resolve: ForkReferenceResolver
}): Promise<ForkCascadeResult> {
const { executor, sourceWorkspaceId, references, resolve } = params
const customToolIds = new Set<string>()
const mcpServerIds = new Set<string>()
const knowledgeBaseIds = new Set<string>()
for (const reference of references) {
if (reference.kind === 'custom-tool') customToolIds.add(reference.sourceId)
else if (reference.kind === 'mcp-server') mcpServerIds.add(reference.sourceId)
else if (reference.kind === 'knowledge-base') knowledgeBaseIds.add(reference.sourceId)
}
if (customToolIds.size === 0 && mcpServerIds.size === 0 && knowledgeBaseIds.size === 0) {
return EMPTY
}
const refs = new Map<string, ForkReference>()
const unmapped = new Map<string, ForkReference>()
const mcpReauthServerIds = new Set<string>()
const inlineSecretSources: string[] = []
const recordEnv = (key: string, sourceLabel: string) => {
const dedupeKey = `env-var:${key}`
if (refs.has(dedupeKey)) return
const reference: ForkReference = {
kind: 'env-var',
sourceId: key,
subBlockKey: '(cascade)',
blockName: sourceLabel,
required: true,
}
refs.set(dedupeKey, reference)
if (resolve('env-var', key) == null) unmapped.set(dedupeKey, reference)
}
const recordCredential = (credentialId: string, sourceLabel: string) => {
const dedupeKey = `credential:${credentialId}`
if (refs.has(dedupeKey)) return
const reference: ForkReference = {
kind: 'credential',
sourceId: credentialId,
subBlockKey: '(cascade)',
blockName: sourceLabel,
required: true,
}
refs.set(dedupeKey, reference)
if (resolve('credential', credentialId) == null) unmapped.set(dedupeKey, reference)
}
if (customToolIds.size > 0) {
const tools = await executor
.select({ id: customTools.id, title: customTools.title, code: customTools.code })
.from(customTools)
.where(
and(
inArray(customTools.id, Array.from(customToolIds)),
eq(customTools.workspaceId, sourceWorkspaceId)
)
)
for (const tool of tools) {
for (const key of extractEnvKeys(tool.code ?? '')) {
recordEnv(key, `Custom tool: ${tool.title}`)
}
}
}
if (mcpServerIds.size > 0) {
const servers = await executor
.select({
id: mcpServers.id,
name: mcpServers.name,
url: mcpServers.url,
headers: mcpServers.headers,
authType: mcpServers.authType,
})
.from(mcpServers)
.where(
and(
inArray(mcpServers.id, Array.from(mcpServerIds)),
eq(mcpServers.workspaceId, sourceWorkspaceId)
)
)
for (const server of servers) {
const label = `MCP server: ${server.name}`
if (server.url) {
for (const key of extractEnvKeys(server.url)) recordEnv(key, label)
}
const headers = (server.headers ?? {}) as Record<string, unknown>
for (const [headerName, headerValue] of Object.entries(headers)) {
if (typeof headerValue !== 'string') continue
const keys = extractEnvKeys(headerValue)
if (keys.length > 0) {
for (const key of keys) recordEnv(key, label)
} else if (server.authType === 'headers' && headerValue) {
inlineSecretSources.push(`${label} (header ${headerName})`)
}
}
if (server.authType === 'oauth') mcpReauthServerIds.add(server.id)
}
}
if (knowledgeBaseIds.size > 0) {
// Join to knowledgeBase + filter by the source workspace (defense-in-depth, matching the
// customTools/mcpServers reads above): a connector is only cascaded when its KB actually
// belongs to the source workspace, so a stale/crafted KB id can't pull in a foreign connector.
const connectors = await executor
.select({
id: knowledgeConnector.id,
knowledgeBaseId: knowledgeConnector.knowledgeBaseId,
credentialId: knowledgeConnector.credentialId,
encryptedApiKey: knowledgeConnector.encryptedApiKey,
})
.from(knowledgeConnector)
.innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id))
.where(
and(
inArray(knowledgeConnector.knowledgeBaseId, Array.from(knowledgeBaseIds)),
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
isNull(knowledgeBase.deletedAt),
isNull(knowledgeConnector.deletedAt)
)
)
for (const connector of connectors) {
if (connector.credentialId) {
recordCredential(connector.credentialId, `Knowledge base connector`)
} else if (connector.encryptedApiKey) {
inlineSecretSources.push(`Knowledge base connector ${connector.id} (API key)`)
}
}
}
return {
references: Array.from(refs.values()),
unmapped: Array.from(unmapped.values()),
mcpReauthServerIds: Array.from(mcpReauthServerIds),
inlineSecretSources,
}
}
@@ -0,0 +1,751 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import {
collectForkDependentReconfigs,
collectForkResourceUsages,
} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
EMPTY_FORK_BLOCK_MAP,
} from '@/ee/workspace-forking/lib/remap/block-identity'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
const sourceState = (
blockType: string,
subBlocks: Record<string, { value: unknown }>
): WorkflowState =>
({
blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } },
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
const replaceItem = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace' as const,
}
// No persisted block map in these unit tests, so the resolver derives - matching the
// `deriveForkBlockId(...)` ids the expectations assert.
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
describe('collectForkDependentReconfigs', () => {
it("emits the active operation's credential-dependent selector (condition-gated)", () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'operation', title: 'Operation', type: 'dropdown' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
condition: { field: 'operation', value: 'read' },
},
// A different operation's variant -> excluded by its condition, not by emptiness.
{
id: 'otherFolder',
title: 'Move To Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
condition: { field: 'operation', value: 'move' },
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', {
credential: { value: 'cred-src' },
operation: { value: 'read' },
folder: { value: 'INBOX' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'folder',
selectorKey: 'gmail.labels',
title: 'Label',
currentValue: 'INBOX',
required: true,
consumesContextKeys: [],
context: {},
sourceValue: 'INBOX',
},
])
})
it('skips an anchor whose canonical pair is in advanced (manual) mode - the value passes through', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'knowledgeBaseSelector',
title: 'Knowledge Base',
type: 'knowledge-base-selector',
canonicalParamId: 'knowledgeBaseId',
mode: 'basic',
},
{
id: 'manualKnowledgeBaseId',
title: 'KB ID',
type: 'short-input',
canonicalParamId: 'knowledgeBaseId',
mode: 'advanced',
},
{
id: 'documentSelector',
title: 'Document',
type: 'document-selector',
selectorKey: 'knowledge.documents',
dependsOn: ['knowledgeBaseSelector'],
required: true,
},
])
)
// Advanced mode active: the dormant basic selector is empty; the active manual id holds the KB.
const advancedState = {
blocks: {
'block-1': {
id: 'block-1',
type: 'knowledge',
name: 'Block',
data: { canonicalModes: { knowledgeBaseId: 'advanced' } },
subBlocks: {
knowledgeBaseSelector: { value: '' },
manualKnowledgeBaseId: { value: 'kb-active' },
documentSelector: { value: 'doc-1' },
},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
} as unknown as WorkflowState
const result = collectForkDependentReconfigs(
[replaceItem],
new Map([['wf-src', advancedState]]),
resolve
)
// Advanced (manual) mode: the manual KB id is user-owned and passes through verbatim on
// sync - it is never remapped, so its dependents are never cleared and there is nothing
// to re-pick. No reconfig is offered.
expect(result).toEqual([])
})
it('emits a knowledge-base-dependent document selector', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'knowledgeBaseSelector',
title: 'Knowledge Base',
type: 'knowledge-base-selector',
canonicalParamId: 'knowledgeBaseId',
},
{
id: 'documentSelector',
title: 'Document',
type: 'document-selector',
canonicalParamId: 'documentId',
selectorKey: 'knowledge.documents',
dependsOn: ['knowledgeBaseSelector'],
required: true,
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('knowledge', {
knowledgeBaseSelector: { value: 'kb-src' },
documentSelector: { value: 'doc-src' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'knowledge-base',
parentSourceId: 'kb-src',
parentContextKey: 'knowledgeBaseId',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'documentSelector',
selectorKey: 'knowledge.documents',
title: 'Document',
currentValue: 'doc-src',
required: true,
consumesContextKeys: [],
context: {},
sourceValue: 'doc-src',
},
])
})
it('offers an active credential selector even when the source left it empty', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
// The source has the credential but no label - the user must still be able to set one
// during the swap (a prior sync may have cleared it), so the selector is offered.
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: '' } }),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ subBlockKey: 'folder', parentSourceId: 'cred-src' })
})
it('still skips a selector whose parent credential is unset', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
const states = new Map<string, WorkflowState>([
['wf-src', sourceState('gmail', { credential: { value: '' }, folder: { value: 'INBOX' } })],
])
expect(collectForkDependentReconfigs([replaceItem], states, resolve)).toEqual([])
})
it('skips create-mode targets', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
const created = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: 'INBOX' } }),
],
])
expect(
collectForkDependentReconfigs(
[{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt', mode: 'create' }],
created,
resolve
)
).toEqual([])
})
it('walks the transitive chain and tags the context key a re-pick provides', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'spreadsheetId',
title: 'Spreadsheet',
type: 'file-selector',
canonicalParamId: 'spreadsheetId',
selectorKey: 'google.drive',
dependsOn: ['credential'],
},
{
id: 'sheetName',
title: 'Sheet',
type: 'sheet-selector',
selectorKey: 'google.sheets',
dependsOn: ['spreadsheetId'],
required: true,
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('google_sheets', {
credential: { value: 'cred-src' },
spreadsheetId: { value: 'ss-src' },
sheetName: { value: 'Sheet1' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
// Both the spreadsheet (direct) and its sheet (transitive) are offered, in order.
expect(result.map((entry) => entry.subBlockKey)).toEqual(['spreadsheetId', 'sheetName'])
const spreadsheet = result.find((entry) => entry.subBlockKey === 'spreadsheetId')
expect(spreadsheet?.parentKind).toBe('credential')
expect(spreadsheet?.providesContextKey).toBe('spreadsheetId')
const sheet = result.find((entry) => entry.subBlockKey === 'sheetName')
expect(sheet?.parentKind).toBe('credential')
expect(sheet?.required).toBe(true)
// The sheet consumes the spreadsheet's key, so the modal gates it on that re-pick.
expect(sheet?.consumesContextKeys).toEqual(['spreadsheetId'])
// The source spreadsheet rides in context; the modal overlays the re-picked one.
expect(sheet?.context.spreadsheetId).toBe('ss-src')
})
it('emits a credential-dependent selector nested inside a tool-input tool', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail 1',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-tgt',
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
blockName: 'Block',
subBlockKey: 'tools[0].folder',
selectorKey: 'gmail.labels',
title: 'Label',
toolName: 'Gmail 1',
currentValue: 'INBOX',
required: true,
consumesContextKeys: [],
context: {},
sourceValue: 'INBOX',
},
])
})
it('honors a nested tool-scoped advanced override (manual mode passes through - no re-pick)', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{
id: 'credential',
title: 'Credential',
type: 'oauth-input',
canonicalParamId: 'credential',
mode: 'basic',
},
{
id: 'manualCredential',
title: 'Credential ID',
type: 'short-input',
canonicalParamId: 'credential',
mode: 'advanced',
},
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
},
])
return undefined as unknown as BlockConfig
})
// Agent block with a nested gmail tool; the dormant basic credential holds a stale id while the
// tool-scoped `0:credential` override (when present) marks advanced as active.
const agentState = (canonicalModes?: Record<string, 'basic' | 'advanced'>) =>
({
blocks: {
'block-1': {
id: 'block-1',
type: 'agent',
name: 'Block',
data: canonicalModes ? { canonicalModes } : {},
subBlocks: {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail 1',
params: {
credential: 'cred-stale',
manualCredential: 'cred-active',
folder: 'INBOX',
},
},
],
},
},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
// Scoped override present -> advanced (manual) mode: the manual credential passes through
// verbatim on sync, so its dependents are never cleared and no re-pick is offered.
const withOverride = collectForkDependentReconfigs(
[replaceItem],
new Map([['wf-src', agentState({ '0:credential': 'advanced' })]]),
resolve
)
expect(withOverride).toEqual([])
// Control: no override -> the value heuristic keeps the non-empty basic (unchanged behavior).
const heuristic = collectForkDependentReconfigs(
[replaceItem],
new Map([['wf-src', agentState()]]),
resolve
)
expect(heuristic).toHaveLength(1)
expect(heuristic[0]).toMatchObject({
parentSourceId: 'cred-stale',
subBlockKey: 'tools[0].folder',
})
})
it('regression: two same-type nested tools resolve their canonical-mode override independently', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{
id: 'credential',
title: 'Credential',
type: 'oauth-input',
canonicalParamId: 'credential',
mode: 'basic',
},
{
id: 'manualCredential',
title: 'Credential ID',
type: 'short-input',
canonicalParamId: 'credential',
mode: 'advanced',
},
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
required: true,
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
{
blocks: {
'block-1': {
id: 'block-1',
type: 'agent',
name: 'Block',
// Tool #0 is scoped to advanced (manual mode passes through - no re-pick, per the
// test above); tool #1 is scoped to basic (re-pick offered on its own value). Both
// are the SAME tool type ("gmail") and the SAME canonicalId ("credential"), so only
// the per-instance index can tell them apart - before the fix both shared the single
// `gmail:credential` key, which would have made tool #1 advanced-active too (and
// therefore ALSO skipped, dropping the result to zero entries instead of one).
data: { canonicalModes: { '0:credential': 'advanced', '1:credential': 'basic' } },
subBlocks: {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail 1',
params: {
credential: 'cred-0-stale',
manualCredential: 'cred-0-active',
folder: 'INBOX',
},
},
{
type: 'gmail',
title: 'Gmail 2',
params: {
credential: 'cred-1-basic',
manualCredential: 'cred-1-stale',
folder: 'SENT',
},
},
],
},
},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
} as unknown as WorkflowState,
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
parentSourceId: 'cred-1-basic',
subBlockKey: 'tools[1].folder',
})
})
it('offers a nested tool selector even when the source left it empty', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
return undefined as unknown as BlockConfig
})
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{ type: 'gmail', title: 'Gmail 1', params: { credential: 'cred-src', folder: '' } },
],
},
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
subBlockKey: 'tools[0].folder',
title: 'Label',
toolName: 'Gmail 1',
})
})
it('evaluates a nested tool selector condition against the tool-level operation', () => {
vi.mocked(getBlock).mockImplementation((type) => {
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
if (type === 'gmail')
return blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
// Active only under read - and `operation` lives at the tool level, not params.
condition: { field: 'operation', value: 'read_gmail' },
},
])
return undefined as unknown as BlockConfig
})
const reading = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail',
operation: 'read_gmail',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
expect(collectForkDependentReconfigs([replaceItem], reading, resolve)).toHaveLength(1)
// Same tool under a different operation -> the read-only label is gated off.
const sending = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('agent', {
tools: {
value: [
{
type: 'gmail',
title: 'Gmail',
operation: 'send_gmail',
params: { credential: 'cred-src', folder: 'INBOX' },
},
],
},
}),
],
])
expect(collectForkDependentReconfigs([replaceItem], sending, resolve)).toEqual([])
})
it('anchors on a table selector for its column dependents', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'tableSelector',
title: 'Table',
type: 'table-selector',
canonicalParamId: 'tableId',
},
{
id: 'conflictColumnSelector',
title: 'Column',
type: 'column-selector',
canonicalParamId: 'conflictColumn',
selectorKey: 'table.columns',
dependsOn: ['tableSelector'],
},
])
)
const states = new Map<string, WorkflowState>([
[
'wf-src',
sourceState('table', {
tableSelector: { value: 'tbl-src' },
conflictColumnSelector: { value: 'col1' },
}),
],
])
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
parentKind: 'table',
parentSourceId: 'tbl-src',
parentContextKey: 'tableId',
subBlockKey: 'conflictColumnSelector',
selectorKey: 'table.columns',
})
})
})
describe('collectForkResourceUsages', () => {
const usageItem = (
sourceWorkflowId: string,
targetWorkflowId: string,
name: string,
mode: 'create' | 'replace' = 'replace'
) => ({ sourceWorkflowId, targetWorkflowId, mode, sourceMeta: { name } })
// The reference scan reads each subblock entry's own `type`, so credential usages need
// typed entries (unlike the dependent collector, which keys off the block config).
const credentialState = (credentialId: string): WorkflowState =>
({
blocks: {
'block-1': {
id: 'block-1',
type: 'gmail',
name: 'Block',
subBlocks: { credential: { id: 'credential', type: 'oauth-input', value: credentialId } },
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
it('lists each replace workflow a resource is used in, with its (target) name', () => {
const states = new Map<string, WorkflowState>([
['wf-a', credentialState('cred-src')],
['wf-b', credentialState('cred-src')],
])
const result = collectForkResourceUsages(
[usageItem('wf-a', 'wf-tgt-a', 'Workflow A'), usageItem('wf-b', 'wf-tgt-b', 'Workflow B')],
states
)
expect(result).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
workflows: [
{ workflowId: 'wf-tgt-a', workflowName: 'Workflow A' },
{ workflowId: 'wf-tgt-b', workflowName: 'Workflow B' },
],
},
])
})
it('includes create-mode targets (never-synced workflows count toward the next sync)', () => {
const states = new Map<string, WorkflowState>([['wf-a', credentialState('cred-src')]])
expect(
collectForkResourceUsages([usageItem('wf-a', 'wf-tgt-a', 'A', 'create')], states)
).toEqual([
{
parentKind: 'credential',
parentSourceId: 'cred-src',
workflows: [{ workflowId: 'wf-tgt-a', workflowName: 'A' }],
},
])
})
})
@@ -0,0 +1,383 @@
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import {
buildSelectorContextFromBlock,
SELECTOR_CONTEXT_FIELDS,
} from '@/lib/workflows/subblocks/context'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isNonEmptyValue,
scopeCanonicalModesForTool,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
import {
createCanonicalModeGates,
isSubBlockRequired,
scanWorkflowReferences,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const isSelectorContextKey = (
key: string
): key is Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0] =>
SELECTOR_CONTEXT_FIELDS.has(key as Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0])
interface ReconfigItem {
sourceWorkflowId: string
targetWorkflowId: string
mode: 'create' | 'replace'
}
/**
* Parent anchor types a dependent selector can hang off, with the SelectorContext key
* the new parent value is supplied under. A parent is a remappable resource (rewritten
* source->target on sync) whose target swap clears its dependents. MCP servers are
* intentionally excluded: their tool dependent has no `selectorKey` and a separate
* (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing.
*/
const PARENT_ANCHORS: ReadonlyArray<{
subBlockType: string
parentKind: ForkDependentReconfig['parentKind']
parentContextKey: string
}> = [
{ subBlockType: 'oauth-input', parentKind: 'credential', parentContextKey: 'oauthCredential' },
{
subBlockType: 'knowledge-base-selector',
parentKind: 'knowledge-base',
parentContextKey: 'knowledgeBaseId',
},
{ subBlockType: 'table-selector', parentKind: 'table', parentContextKey: 'tableId' },
]
interface EmitAnchoredParams {
/** The block (top-level) or tool config whose subblocks are scanned for anchors. */
config: NonNullable<ReturnType<typeof getBlock>>
/** Flat id -> value map for that config (top-level subblock values, or a tool's params). */
values: Record<string, unknown>
/** Block/tool type + its subblock shape, for building the source selector context. */
contextBlockType: string
contextSubBlocks: Record<string, { value?: unknown }>
blockName: string
targetWorkflowId: string
/** Canonical-mode overrides for resolving the active parent member (undefined -> value heuristic). */
canonicalModes?: CanonicalModeOverrides
/** Memoized so the deterministic target block id is derived at most once per block. */
resolveTargetBlockId: () => string
/** Map a dependent's config id to its wire `subBlockKey` (identity, or nested `tools[i].id`). */
makeSubBlockKey: (dependentId: string) => string
makeTitle: (dependent: SubBlockConfig) => string
/** Nested `tool-input` tool display name; omitted for top-level block subblocks. */
toolName?: string
/**
* Emit `providesContextKey`/`consumesContextKeys` so the modal can chain in-block
* re-picks. Top-level chains; nested tool params don't (a tool's chain would need
* per-tool context scoping - out of scope - and the common nested case is a single
* credential-anchored field).
*/
chaining: boolean
out: ForkDependentReconfig[]
}
/**
* Emit one config's credential/KB/table-anchored selector dependents that the source had
* configured. Shared by the top-level subblock scan and the nested `tool-input` tool scan.
* Walks the FULL transitive dependent chain (parent -> child -> grandchild) per anchor and
* dedups fields reachable via multiple anchors/paths.
*/
function emitAnchoredDependents(params: EmitAnchoredParams): void {
const {
config,
values,
contextBlockType,
contextSubBlocks,
blockName,
targetWorkflowId,
canonicalModes,
resolveTargetBlockId,
makeSubBlockKey,
makeTitle,
toolName,
chaining,
out,
} = params
const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks)
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes)
const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg]))
// A field could hang off two anchors (or be reachable via two paths); emit it once.
const seen = new Set<string>()
for (const anchor of PARENT_ANCHORS) {
for (const anchorCfg of config.subBlocks) {
if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue
// An anchor whose canonical pair is in ADVANCED (manual) mode is skipped entirely: the
// active value is the user-owned manual member's, which is verbatim by policy - a sync
// never remaps it, so its dependents are never cleared and there is nothing to re-pick.
if (gates.isAdvancedActiveGroup(anchorCfg.id)) continue
// Basic mode: the anchor selector's own value. Nested tools can store the pick under the
// pair's `canonicalParamId` instead (the tool-input UI writes the canonical key), so fall
// back to it - but only when that key is not itself a subblock id (when it is, the key
// is the manual member's own field and reading it would leak a manual value).
let rawValue = values[anchorCfg.id]
if (
!isNonEmptyValue(rawValue) &&
anchorCfg.canonicalParamId &&
!configById.has(anchorCfg.canonicalParamId)
) {
rawValue = values[anchorCfg.canonicalParamId]
}
const parentSourceId = typeof rawValue === 'string' ? rawValue : ''
if (!parentSourceId) continue
// Multi-value parents (comma-joined) can't match a single mapping entry; skip
// (the field falls back to needs-config) rather than mis-bind to one of several.
if (parentSourceId.includes(',')) continue
// Context the dependents need (spreadsheetId, ...) minus the parent key the modal supplies.
const context: Record<string, string> = {}
for (const [key, value] of Object.entries(fullContext)) {
if (key === anchor.parentContextKey) continue
if (typeof value === 'string' && value) context[key] = value
}
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, anchorCfg.id)) {
const dependent = configById.get(clear.subBlockId)
if (!dependent?.id || !dependent.selectorKey) continue
// Skip fields gated off by their `condition` - a selector under a now-inactive
// operation (e.g. a move-only label while the block reads) isn't in play. We do
// NOT require a source value: an active selector the source left empty is still
// offered, so the user can set a label/sheet during the swap even when the source
// (or a prior sync) cleared it - the whole point of the in-place re-pick.
if (dependent.condition && !evaluateSubBlockCondition(dependent.condition, values)) continue
// Skip a DORMANT canonical member: when the dependent's own pair is in advanced
// (manual) mode, the selector is not the live field - the manual member is, and it
// is verbatim by policy (never cleared by a remap), so there's nothing to re-pick.
if (gates.isDormantMember(dependent.id)) continue
// The SelectorContext key this field supplies to its own descendants, so the
// modal can chain re-picks (re-picked spreadsheet feeds the sheet selector).
const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[dependent.id] ?? dependent.id
// Dedup by canonical key so a basic/advanced pair (or two paths to the same field)
// is offered exactly once.
if (seen.has(canonicalKey)) continue
seen.add(canonicalKey)
const providesContextKey =
chaining && isSelectorContextKey(canonicalKey) ? canonicalKey : undefined
// The SelectorContext keys this field needs from in-block siblings (e.g. a sheet
// needs the spreadsheet), excluding the anchor key the modal already supplies, so
// the modal can keep a child disabled until its re-picked parent is chosen.
const consumesContextKeys = chaining
? [
...new Set(
getDependsOnFields(dependent.dependsOn)
.map((parent) => canonicalIndex.canonicalIdBySubBlockId[parent] ?? parent)
.filter((key) => key !== anchor.parentContextKey && isSelectorContextKey(key))
),
]
: []
// Carry the selector's static `mimeType` filter (Drive/Sheets pickers) so the
// modal selector loads the same filtered list the editor would, not all files.
const dependentContext =
typeof dependent.mimeType === 'string' && dependent.mimeType
? { ...context, mimeType: dependent.mimeType }
: context
// Nested tools can store the pick under the pair's `canonicalParamId` (the tool-input
// UI writes the canonical key); fall back to it when the key isn't a subblock's own id.
const rawDependentValue =
values[dependent.id] ??
(dependent.canonicalParamId && !configById.has(dependent.canonicalParamId)
? values[dependent.canonicalParamId]
: undefined)
const rawSourceValue = typeof rawDependentValue === 'string' ? rawDependentValue : ''
out.push({
parentKind: anchor.parentKind,
parentSourceId,
parentContextKey: anchor.parentContextKey,
targetWorkflowId,
targetBlockId: resolveTargetBlockId(),
blockName,
subBlockKey: makeSubBlockKey(dependent.id),
selectorKey: dependent.selectorKey,
title: makeTitle(dependent),
...(toolName ? { toolName } : {}),
// Source value, so the always-on listing pre-fills a stable parent's selector.
// The diff route overlays the stored/target-draft value onto `currentValue`;
// `sourceValue` stays the raw source reference (the copy-resolved parent's seed).
currentValue: rawSourceValue,
required: isSubBlockRequired(dependent.required, values),
providesContextKey,
consumesContextKeys,
context: dependentContext,
sourceValue: rawSourceValue,
})
}
}
}
}
/**
* Scan the source's deployed workflows for configured selector fields that `dependsOn`
* a remappable parent (a credential, knowledge base, or table) - the fields a sync clears
* whenever that parent's target changes. Covers top-level block subblocks AND selectors
* nested inside `tool-input` tools (Agent/tool blocks), so a Gmail tool's label inside an
* Agent block is offered for re-pick too. Each entry carries the deterministic target
* block id, the parent it hangs off (so the modal can bind it to the newly-chosen target),
* and the source-derived selector context. Every selector active for the source's current
* operation is emitted - including ones the source left empty - so the user can set a
* value in place during the swap even when the source (or a prior sync) had none; only
* selectors gated off by their `condition` (a different operation's variant) are skipped.
* Scans one target `mode` per call: `replace` for targets that exist (re-pick against the
* swapped parent), `create` for never-synced workflows (pre-configure what the first sync
* writes - the diff route emits both).
*
* `resolveTargetBlockId` MUST be the same resolver `copyWorkflowStateIntoTarget` uses for
* this promote (see {@link buildForkBlockIdResolver}); otherwise the modal would key a
* re-pick by a derived id while the sync writes the block under its persisted counterpart,
* and the override would silently miss.
*/
export function collectForkDependentReconfigs(
items: ReconfigItem[],
sourceStates: Map<string, WorkflowState>,
resolveTargetBlockId: ForkBlockIdResolver,
/**
* Which target mode to scan. Defaults to `replace` (the reconfigure UI, where the user re-picks
* a dependent against a swapped parent). The pre-sync cleared-ref list passes `create` to surface
* dependents a new target inherits that a remapped parent will clear (it can't be re-picked yet).
*/
mode: 'create' | 'replace' = 'replace'
): ForkDependentReconfig[] {
const out: ForkDependentReconfig[] = []
for (const item of items) {
if (item.mode !== mode) continue
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
const config = getBlock(block.type)
if (!config) continue
const subBlocks = (block.subBlocks ?? {}) as Record<string, { value?: unknown }>
const sourceValues = buildSubBlockValues(subBlocks)
let cachedTargetBlockId: string | null = null
const resolveBlockId = () =>
(cachedTargetBlockId ??= resolveTargetBlockId(item.targetWorkflowId, sourceBlockId))
// Top-level credential/KB/table-anchored selectors. Block-level canonicalModes pick the
// active parent member; nested tools below pass their tool-scoped overrides (via
// scopeCanonicalModesForTool), falling back to the value heuristic only when none is set.
emitAnchoredDependents({
config,
values: sourceValues,
contextBlockType: block.type,
contextSubBlocks: subBlocks,
blockName: block.name,
targetWorkflowId: item.targetWorkflowId,
canonicalModes: block.data?.canonicalModes,
resolveTargetBlockId: resolveBlockId,
makeSubBlockKey: (id) => id,
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
chaining: true,
out,
})
// Nested `tool-input` tools: each selected tool's own credential-anchored selectors,
// keyed `toolInput[index].paramId` (matching the needs-config key). Field `title` stays
// plain; `toolName` carries the tool so the UI can show block → tool → field tiers.
for (const cfg of config.subBlocks) {
if (cfg.type !== 'tool-input' || !cfg.id) continue
const { array: tools } = coerceObjectArray(subBlocks[cfg.id]?.value)
if (!tools) continue
for (let index = 0; index < tools.length; index++) {
const tool = tools[index]
if (!isRecord(tool) || typeof tool.type !== 'string') continue
const toolConfig = getBlock(tool.type)
if (!toolConfig) continue
const toolParams = isRecord(tool.params) ? tool.params : {}
// A tool's `operation` is stored at the tool level, not in params, but subblock
// conditions reference it (e.g. a Gmail label only under `read_gmail`). Merge it
// in so condition-gating matches the editor's `{ operation, ...params }`.
const toolValues =
typeof tool.operation === 'string'
? { operation: tool.operation, ...toolParams }
: toolParams
const toolContextSubBlocks: Record<string, { value?: unknown }> = {}
for (const [key, value] of Object.entries(toolValues)) {
toolContextSubBlocks[key] = { value }
}
const toolLabel =
typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
const toolInputKey = cfg.id
const toolIndex = index
emitAnchoredDependents({
config: toolConfig,
values: toolValues,
contextBlockType: tool.type,
contextSubBlocks: toolContextSubBlocks,
blockName: block.name,
targetWorkflowId: item.targetWorkflowId,
canonicalModes: scopeCanonicalModesForTool(
block.data?.canonicalModes,
toolIndex,
tool.type
),
resolveTargetBlockId: resolveBlockId,
makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`,
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
toolName: toolLabel,
chaining: false,
out,
})
}
}
}
}
return out
}
interface ResourceUsageItem {
sourceWorkflowId: string
targetWorkflowId: string
mode: 'create' | 'replace'
/** Source workflow name, shown as the (renamed-aware) target name in the listing. */
sourceMeta: { name: string }
}
/**
* Every workflow each mapped resource (any kind) is used in - the spine of the always-on
* reconfigure listing under a mapping entry. Scans each source workflow's references
* (deduped per workflow, so a resource used by several blocks is one workflow usage) and
* groups them by `(kind, sourceId)`. Unlike {@link collectForkDependentReconfigs} this is
* NOT anchor-limited: it includes resources with no configurable dependent (env vars, files,
* a Gmail block with no active label) so the modal can still list - greyed - the workflows
* they appear in. Covers EVERY deployed source workflow - replace targets and creates
* (never-synced workflows) alike - so the listing accounts for the full next sync.
*/
export function collectForkResourceUsages(
items: ResourceUsageItem[],
sourceStates: Map<string, WorkflowState>
): ForkResourceUsage[] {
const byResource = new Map<string, ForkResourceUsage>()
for (const item of items) {
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
// scanWorkflowReferences already dedups by `${kind}:${sourceId}` across the workflow,
// so each resource appears once per workflow here.
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
const key = `${reference.kind}\u0000${reference.sourceId}`
let usage = byResource.get(key)
if (!usage) {
usage = { parentKind: reference.kind, parentSourceId: reference.sourceId, workflows: [] }
byResource.set(key, usage)
}
usage.workflows.push({
workflowId: item.targetWorkflowId,
workflowName: item.sourceMeta.name,
})
}
}
return Array.from(byResource.values())
}
@@ -0,0 +1,174 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import {
type ForkDependentValue,
forkDependentValueKey,
loadForkDependentValues,
reconcileForkDependentValues,
translateForkDependentValues,
} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references'
describe('forkDependentValueKey', () => {
it('builds a stable triple key', () => {
expect(forkDependentValueKey('wf', 'blk', 'folder')).toBe('wf\u0000blk\u0000folder')
})
it("doesn't collide when an id contains a printable separator", () => {
// 'a:b' + 'c' must differ from 'a' + 'b:c' - the NUL separator guarantees it.
expect(forkDependentValueKey('a:b', 'c', 'd')).not.toBe(forkDependentValueKey('a', 'b:c', 'd'))
})
})
describe('loadForkDependentValues', () => {
it('selects the edge rows', async () => {
const where = vi
.fn()
.mockResolvedValue([
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
])
const from = vi.fn(() => ({ where }))
const executor = { select: vi.fn(() => ({ from })) }
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1')
expect(executor.select).toHaveBeenCalledTimes(1)
expect(rows).toEqual([
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
])
})
it('scopes the read to the given target workflows', async () => {
const where = vi.fn().mockResolvedValue([])
const from = vi.fn(() => ({ where }))
const executor = { select: vi.fn(() => ({ from })) }
await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', ['wf-1', 'wf-2'])
expect(executor.select).toHaveBeenCalledTimes(1)
expect(where).toHaveBeenCalledTimes(1)
})
it('short-circuits an empty target filter without querying', async () => {
const executor = { select: vi.fn() }
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', [])
expect(executor.select).not.toHaveBeenCalled()
expect(rows).toEqual([])
})
})
describe('translateForkDependentValues', () => {
const value = (overrides: Partial<ForkDependentValue> = {}): ForkDependentValue => ({
targetWorkflowId: 'wf-1',
targetBlockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-src',
...overrides,
})
/** Resolver mapping only the copied/mapped source document ids, like promote's post-copy one. */
const resolver: ForkReferenceResolver = (kind, sourceId) =>
kind === 'knowledge-document' && sourceId === 'doc-src' ? 'doc-copy' : null
it('rewrites a SOURCE document id to its copied counterpart (the apply must never write a source id)', () => {
expect(translateForkDependentValues([value()], resolver)).toEqual([
value({ value: 'doc-copy' }),
])
})
it('keeps values the resolver does not know verbatim (target doc ids, labels, column ids)', () => {
const targetDoc = value({ value: 'doc-tgt-existing' })
const label = value({ subBlockKey: 'folder', value: 'INBOX' })
expect(translateForkDependentValues([targetDoc, label], resolver)).toEqual([targetDoc, label])
})
it('keeps empty (cleared) values untouched without consulting the resolver', () => {
const resolve = vi.fn(() => 'never')
const cleared = value({ value: '' })
expect(translateForkDependentValues([cleared], resolve)).toEqual([cleared])
expect(resolve).not.toHaveBeenCalled()
})
it('consults only the knowledge-document kind (documents are the one copied dependent value)', () => {
const resolve = vi.fn(() => null)
translateForkDependentValues([value()], resolve)
expect(resolve).toHaveBeenCalledTimes(1)
expect(resolve).toHaveBeenCalledWith('knowledge-document', 'doc-src')
})
})
describe('reconcileForkDependentValues', () => {
function fakeExecutor() {
const deleteWhere = vi.fn().mockResolvedValue(undefined)
const insertValues = vi.fn().mockResolvedValue(undefined)
const executor = {
delete: vi.fn(() => ({ where: deleteWhere })),
insert: vi.fn(() => ({ values: insertValues })),
}
return { executor: executor as unknown as DbOrTx, deleteWhere, insertValues }
}
it('deletes the given workflows then inserts only non-empty values', async () => {
const { executor, deleteWhere, insertValues } = fakeExecutor()
await reconcileForkDependentValues(
executor,
'ws-1',
['wf-1'],
[
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
{ targetWorkflowId: 'wf-1', targetBlockId: 'b2', subBlockKey: 'folder', value: '' },
]
)
expect(deleteWhere).toHaveBeenCalledTimes(1)
expect(insertValues).toHaveBeenCalledTimes(1)
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
childWorkspaceId: 'ws-1',
targetWorkflowId: 'wf-1',
targetBlockId: 'b1',
subBlockKey: 'folder',
value: 'INBOX',
})
})
it('skips the delete when no workflows are given, and skips insert when all values are empty', async () => {
const { executor, deleteWhere, insertValues } = fakeExecutor()
await reconcileForkDependentValues(
executor,
'ws-1',
[],
[{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: '' }]
)
expect(deleteWhere).not.toHaveBeenCalled()
expect(insertValues).not.toHaveBeenCalled()
})
it('clears a workflow (delete, no insert) when its full set is now empty', async () => {
const { executor, deleteWhere, insertValues } = fakeExecutor()
await reconcileForkDependentValues(executor, 'ws-1', ['wf-1'], [])
expect(deleteWhere).toHaveBeenCalledTimes(1)
expect(insertValues).not.toHaveBeenCalled()
})
it('dedupes duplicate field entries (last value wins) so a retried payload cannot trip the unique index', async () => {
const { executor, insertValues } = fakeExecutor()
await reconcileForkDependentValues(
executor,
'ws-1',
['wf-1'],
[
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'SENT' },
]
)
expect(insertValues).toHaveBeenCalledTimes(1)
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
targetWorkflowId: 'wf-1',
targetBlockId: 'b1',
subBlockKey: 'folder',
value: 'SENT',
})
})
})
@@ -0,0 +1,124 @@
import { workspaceForkDependentValue } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references'
/** One stored dependent-field value for an edge. */
export interface ForkDependentValue {
targetWorkflowId: string
targetBlockId: string
subBlockKey: string
value: string
}
/** Stable key for a stored value (target workflow + block + subblock). */
export function forkDependentValueKey(
targetWorkflowId: string,
targetBlockId: string,
subBlockKey: string
): string {
// NUL separators so ids/keys containing ':' can't be confused for a different triple.
return `${targetWorkflowId}\u0000${targetBlockId}\u0000${subBlockKey}`
}
/**
* Load an edge's stored dependent values - the single source of truth for what each dependent
* selector (Gmail label, KB document, sheet tab) is set to. Consumed two ways: the diff
* overlays them as the modal's pre-filled value, and a promote applies them verbatim. Pass
* `targetWorkflowIds` to scope the read to a plan's replace targets (matching the
* `(childWorkspaceId, targetWorkflowId)` index) instead of loading the whole edge; an empty
* array short-circuits to no rows.
*/
export async function loadForkDependentValues(
executor: DbOrTx,
childWorkspaceId: string,
targetWorkflowIds?: string[]
): Promise<ForkDependentValue[]> {
if (targetWorkflowIds && targetWorkflowIds.length === 0) return []
const where = targetWorkflowIds
? and(
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
)
: eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId)
return executor
.select({
targetWorkflowId: workspaceForkDependentValue.targetWorkflowId,
targetBlockId: workspaceForkDependentValue.targetBlockId,
subBlockKey: workspaceForkDependentValue.subBlockKey,
value: workspaceForkDependentValue.value,
})
.from(workspaceForkDependentValue)
.where(where)
}
/**
* Translate dependent values through the promote resolver before they are applied to the
* written state and persisted: a value that is a SOURCE knowledge-document id (a pick under a
* copy-resolved KB) becomes its copied/mapped counterpart id, so the
* dependent-value apply - which runs AFTER the reference remap and wins for its subblock -
* never writes a source-workspace document id into the target, and the store stays coherent
* for the next sync's (then-mapped) display. Only ids the resolver actually knows are
* rewritten: a target document id, a Gmail label, a column id, or any other opaque value
* misses the map and is kept verbatim. Documents are the one dependent-selector value that is
* itself a copied resource id, so `knowledge-document` is the only kind consulted. Pure.
*/
export function translateForkDependentValues(
values: ForkDependentValue[],
resolve: ForkReferenceResolver
): ForkDependentValue[] {
return values.map((entry) => {
if (entry.value === '') return entry
const translated = resolve('knowledge-document', entry.value)
return translated != null && translated !== entry.value
? { ...entry, value: translated }
: entry
})
}
/**
* Replace the stored dependent values for the given target workflows with `values` (the full
* set the modal sent). Deletes those workflows' rows first, then inserts the non-empty values,
* so the store always equals exactly what the user configured - cleared fields drop out, and
* blocks/fields that no longer exist are pruned. Empty values aren't stored (an empty store
* entry and a missing one mean the same thing: unset).
*/
export async function reconcileForkDependentValues(
executor: DbOrTx,
childWorkspaceId: string,
targetWorkflowIds: string[],
values: ForkDependentValue[]
): Promise<void> {
if (targetWorkflowIds.length > 0) {
await executor
.delete(workspaceForkDependentValue)
.where(
and(
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
)
)
}
// Dedupe by the stored (workflow, block, subblock) triple (last value wins) before building
// insert rows, so a duplicated/retried payload entry can't trip the `..._field_unique` index
// and abort the whole sync transaction. Empty values aren't stored.
const deduped = new Map<string, ForkDependentValue>()
for (const entry of values) {
if (entry.value === '') continue
deduped.set(
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
entry
)
}
const rows = Array.from(deduped.values()).map((entry) => ({
id: generateId(),
childWorkspaceId,
targetWorkflowId: entry.targetWorkflowId,
targetBlockId: entry.targetBlockId,
subBlockKey: entry.subBlockKey,
value: entry.value,
}))
if (rows.length === 0) return
await executor.insert(workspaceForkDependentValue).values(rows)
}
@@ -0,0 +1,248 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
const { mockFilterExisting, mockGetCredentialProviders, mockGetEnvKeys } = vi.hoisted(() => ({
mockFilterExisting: vi.fn(),
mockGetCredentialProviders: vi.fn(),
mockGetEnvKeys: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({
listForkResourceCandidates: vi.fn(),
classifyCredentialResourceType: vi.fn(),
getWorkspaceEnvKeys: mockGetEnvKeys,
filterExistingForkTargets: mockFilterExisting,
getCredentialProvidersByIds: mockGetCredentialProviders,
CANDIDATE_LIMIT: 1000,
}))
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import {
findDuplicateTargetEntry,
suggestTarget,
validateForkMappingTargets,
} from '@/ee/workspace-forking/lib/mapping/mapping-service'
import type { ForkResourceCandidate } from '@/ee/workspace-forking/lib/mapping/resources'
type ExistingByKind = Partial<Record<ForkRemapKind, Set<string>>>
describe('validateForkMappingTargets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFilterExisting.mockResolvedValue({} as ExistingByKind)
mockGetEnvKeys.mockResolvedValue(new Set<string>())
mockGetCredentialProviders.mockResolvedValue(new Map<string, string | null>())
})
it('rejects a workflow-type entry with a target (identity is system-managed)', async () => {
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'workflow', sourceId: 'wf-src', targetId: 'wf-tgt' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('short-circuits without querying when no entry has a target', async () => {
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: null },
])
).resolves.toBeUndefined()
expect(mockFilterExisting).not.toHaveBeenCalled()
expect(mockGetEnvKeys).not.toHaveBeenCalled()
})
it('accepts an env-var whose target key exists in the target workspace', async () => {
mockGetEnvKeys.mockResolvedValue(new Set(['API_KEY']))
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'API_KEY' },
])
).resolves.toBeUndefined()
})
it('rejects an env-var whose target key is not in the target workspace', async () => {
mockGetEnvKeys.mockResolvedValue(new Set())
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'missing' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('accepts a target validated by exact id even when picker lists are capped', async () => {
// filterExistingForkTargets checks by exact id (cap-free), so a target that would
// sit past the candidate cap still validates.
mockFilterExisting.mockResolvedValue({ table: new Set(['table-1001']) })
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-1001' },
])
).resolves.toBeUndefined()
})
it('rejects a target that does not exist in the target workspace', async () => {
mockFilterExisting.mockResolvedValue({ table: new Set<string>() })
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-gone' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('accepts a file target whose storage key exists in the target workspace', async () => {
// Files are mappable like any other content kind; the target is the storage key, and
// filterExistingForkTargets resolves file existence by key in the target workspace.
mockFilterExisting.mockResolvedValue({ file: new Set(['workspace/DST/report.pdf']) })
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{
resourceType: 'file',
sourceId: 'workspace/SRC/report.pdf',
targetId: 'workspace/DST/report.pdf',
},
])
).resolves.toBeUndefined()
})
it('rejects a file target whose storage key is missing in the target workspace', async () => {
mockFilterExisting.mockResolvedValue({ file: new Set<string>() })
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{
resourceType: 'file',
sourceId: 'workspace/SRC/report.pdf',
targetId: 'workspace/DST/gone.pdf',
},
])
).rejects.toBeInstanceOf(ForkError)
})
it('rejects a credential whose target provider differs from the source provider', async () => {
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
workspaceId === 'ws-source'
? new Map([['cred-src', 'google-email']])
: new Map([['cred-tgt', 'google-calendar']])
)
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
])
).rejects.toBeInstanceOf(ForkError)
})
it('accepts a credential whose target provider matches the source provider', async () => {
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
workspaceId === 'ws-source'
? new Map([['cred-src', 'google-email']])
: new Map([['cred-tgt', 'google-email']])
)
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
])
).resolves.toBeUndefined()
})
it('rejects a credential whose source is not a credential in the source workspace', async () => {
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
workspaceId === 'ws-source'
? new Map<string, string | null>() // cred-foreign is not in the source
: new Map([['cred-tgt', 'google-email']])
)
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-foreign', targetId: 'cred-tgt' },
])
).rejects.toBeInstanceOf(ForkError)
})
})
describe('findDuplicateTargetEntry', () => {
it('returns null when every target is used by at most one source', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 't1' },
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 't2' },
])
).toBeNull()
})
it('flags two distinct sources mapped to the same target', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'shared' },
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 'shared' },
])
).toEqual({ resourceType: 'oauth_credential', targetId: 'shared' })
})
it('ignores cleared (null target) entries', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: null },
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: null },
])
).toBeNull()
})
it('does not flag the same source+target repeated', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
])
).toBeNull()
})
it('does not conflate the same target id across resource types', () => {
expect(
findDuplicateTargetEntry([
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'same' },
{ resourceType: 'table', sourceId: 'c2', targetId: 'same' },
])
).toBeNull()
})
})
describe('suggestTarget', () => {
const cand = (id: string, label: string, providerId?: string): ForkResourceCandidate => ({
id,
label,
providerId,
})
it('disambiguates same-name credentials by matching the source provider', () => {
const target = suggestTarget('credential', 'Work', 'google-email', [
cand('c1', 'Work', 'google-calendar'),
cand('c2', 'Work', 'google-email'),
])
expect(target).toBe('c2')
})
it('suggests a unique name match for a non-credential kind', () => {
expect(
suggestTarget('table', 'Orders', undefined, [cand('t1', 'Orders'), cand('t2', 'Other')])
).toBe('t1')
})
it('returns null when the name is ambiguous (two same-name candidates)', () => {
expect(
suggestTarget('table', 'Dup', undefined, [cand('t1', 'Dup'), cand('t2', 'Dup')])
).toBeNull()
})
it('returns null when no candidate name matches', () => {
expect(suggestTarget('table', 'Orders', undefined, [cand('t1', 'Other')])).toBeNull()
})
it('matches the name case- and whitespace-insensitively', () => {
expect(suggestTarget('table', ' Orders ', undefined, [cand('t1', 'orders')])).toBe('t1')
})
})
@@ -0,0 +1,436 @@
import { db } from '@sim/db'
import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
listDeployedWorkflows,
readDeployedState,
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
import {
buildForkResolver,
deleteEdgeMappingsByChildResources,
type ForkResourceType,
getEdgeMappingRows,
nonCredentialForkKindToResourceType,
resourceTypeToForkKind,
upsertEdgeMappings,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
CANDIDATE_LIMIT,
classifyCredentialResourceType,
type ForkResourceCandidate,
filterExistingForkTargets,
getCredentialProvidersByIds,
getWorkspaceEnvKeys,
listForkResourceCandidates,
} from '@/ee/workspace-forking/lib/mapping/resources'
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
import {
type ForkReference,
type ForkRemapKind,
scanWorkflowReferences,
} from '@/ee/workspace-forking/lib/remap/remap-references'
interface ForkMappingViewParams {
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
}
export function suggestTarget(
kind: ForkRemapKind,
sourceLabel: string,
sourceProviderId: string | undefined,
candidates: ForkResourceCandidate[]
): string | null {
const normalized = sourceLabel.trim().toLowerCase()
const byLabel = candidates.filter((c) => c.label.trim().toLowerCase() === normalized)
if (kind === 'credential' && sourceProviderId) {
const match = byLabel.find((c) => c.providerId === sourceProviderId)
if (match) return match.id
}
if (byLabel.length === 1) return byLabel[0].id
return null
}
/**
* Build the direction-oriented mapping view: every detected source reference with
* its current target (persisted or env identity), an auto-suggested target by
* name/provider, and the list of target candidates the UI can choose from.
*/
export async function getForkMappingView(
params: ForkMappingViewParams
): Promise<{ entries: ForkMappingEntry[] }> {
const { edge, sourceWorkspaceId, targetWorkspaceId } = params
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
const [mappingRows, targetEnvKeys, sourceEnvKeys, sourceCandidates, targetCandidates] =
await Promise.all([
getEdgeMappingRows(db, edge.childWorkspaceId),
getWorkspaceEnvKeys(db, targetWorkspaceId),
getWorkspaceEnvKeys(db, sourceWorkspaceId),
listForkResourceCandidates(db, sourceWorkspaceId),
listForkResourceCandidates(db, targetWorkspaceId),
])
const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys })
const resourceTypeBySourceId = new Map<string, ForkMappableResourceType>()
for (const row of mappingRows) {
// Workflow + workflow-publishing-server identity rows are system-managed and document rows
// ride their parent KB - none is user-mappable. Skip them so a scanned reference can never
// be labeled with a non-mappable type and the view stays within the mappable-type contract.
if (
row.resourceType === 'workflow' ||
row.resourceType === 'workflow_mcp_server' ||
row.resourceType === 'knowledge_document'
) {
continue
}
const key = sourceIsParent ? row.parentResourceId : row.childResourceId
if (key) resourceTypeBySourceId.set(key, row.resourceType)
}
// Scan one deployed workflow state at a time and merge deduped references, so
// peak memory stays at a single workflow state rather than all of them at once.
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
const referenceByKey = new Map<string, ForkReference>()
for (const wf of deployedWorkflows) {
const state = await readDeployedState(wf.id, sourceWorkspaceId)
if (!state) continue
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
}
const cascade = await detectForkCascadeReferences({
executor: db,
sourceWorkspaceId,
references: Array.from(referenceByKey.values()),
resolve: () => null,
})
for (const reference of cascade.references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
const references: ForkReference[] = Array.from(referenceByKey.values())
// First pass: resolve each reference's stored target + the data to build its entry,
// collecting stored target ids so existence is checked by exact id (cap-free) - a
// valid mapping to a target past the display cap must be RETAINED, not shown unmapped.
interface PendingEntry {
reference: ForkReference
resourceType: ForkMappableResourceType
sourceLabel: string
sourceProviderId: string | undefined
candidates: ForkResourceCandidate[]
storedTargetId: string | null
}
const pending: PendingEntry[] = []
const storedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const reference of references) {
// Only SOURCE workspace secrets are mappable; a `{{KEY}}` that isn't a source
// workspace env var is a personal (user-scoped) secret - leave it as-is.
if (reference.kind === 'env-var' && !sourceEnvKeys.has(reference.sourceId)) continue
// Knowledge documents are not a standalone mappable kind: a document is a dependent field
// of its knowledge base (the `document-selector` dependsOn the KB selector), re-picked in
// that KB's reconfigure flow and auto-remapped when the KB is copied. So a document never
// gets its own mapping entry - it follows its parent KB's target.
if (reference.kind === 'knowledge-document') continue
let resourceType = resourceTypeBySourceId.get(reference.sourceId)
if (!resourceType) {
resourceType =
reference.kind === 'credential'
? await classifyCredentialResourceType(db, reference.sourceId, sourceWorkspaceId)
: nonCredentialForkKindToResourceType(reference.kind)
}
const sourceCandidate = sourceCandidates[reference.kind].find(
(c) => c.id === reference.sourceId
)
const sourceLabel = sourceCandidate?.label ?? reference.sourceId
const sourceProviderId = sourceCandidate?.providerId
// A credential reference only maps to a target credential of the SAME OAuth
// provider - a Gmail (google-email) reference must never offer a Google Calendar
// credential. Non-credential kinds carry no provider, so their full list stands.
const candidates =
reference.kind === 'credential' && sourceProviderId
? targetCandidates[reference.kind].filter(
(candidate) => candidate.providerId === sourceProviderId
)
: targetCandidates[reference.kind]
const storedTargetId = resolver(reference.kind, reference.sourceId) ?? null
if (storedTargetId && reference.kind !== 'env-var') {
;(storedTargetIdsByKind[reference.kind] ??= new Set()).add(storedTargetId)
}
pending.push({
reference,
resourceType,
sourceLabel,
sourceProviderId,
candidates,
storedTargetId,
})
}
// Cap-free existence of every stored target (env vars validated against env keys).
const existingStoredTargets = await filterExistingForkTargets(
db,
targetWorkspaceId,
storedTargetIdsByKind
)
const entries: ForkMappingEntry[] = []
for (const p of pending) {
const targetExists =
p.storedTargetId != null &&
(p.reference.kind === 'env-var'
? targetEnvKeys.has(p.storedTargetId)
: (existingStoredTargets[p.reference.kind]?.has(p.storedTargetId) ?? false))
const currentTargetId = targetExists ? p.storedTargetId : null
// If the retained current target isn't in the (capped) candidate list, append it
// so the picker can still display the current selection.
let candidates = p.candidates
if (currentTargetId && !candidates.some((candidate) => candidate.id === currentTargetId)) {
candidates = [...candidates, { id: currentTargetId, label: currentTargetId }]
}
const targetId =
currentTargetId ??
suggestTarget(p.reference.kind, p.sourceLabel, p.sourceProviderId, candidates)
// True when `targetId` is an unconfirmed name/provider suggestion (no persisted
// mapping). The modal treats a suggestion as a pending change so it shows the
// pre-sync reconfigure rather than letting an accepted suggestion silently clear
// dependents and surface them only after the sync.
const suggested = currentTargetId == null && targetId != null
entries.push({
kind: p.reference.kind,
resourceType: p.resourceType,
sourceId: p.reference.sourceId,
sourceLabel: p.sourceLabel,
targetId,
suggested,
// Every entry here is a reference a synced workflow actually carries, and a sync is
// blocked while ANY reference would clear - so every entry is required. Copyable kinds
// (table / KB / file / custom tool / skill) also satisfy the gate by being selected for
// copy; map-only kinds (credential / env-var / MCP server) and source-deleted resources
// (no copy candidate) must be mapped.
required: true,
candidates,
// The full (unfiltered) target list for this kind hit the cap, so the picker is
// showing a partial list - the UI tells the user to refine.
candidatesTruncated: targetCandidates[p.reference.kind].length >= CANDIDATE_LIMIT,
})
}
return { entries }
}
export interface ApplyForkMappingEntry {
resourceType: ForkResourceType
sourceId: string
targetId: string | null
}
/**
* The first target two distinct sources are mapped to (same resourceType + targetId,
* different sourceId), or null when every target is used by at most one source. Cleared
* entries (null target) are ignored. Used by the PUSH path only: a push row is unique on
* the parent (target) side, so such a pair collides on that unique index and one mapping
* would be silently dropped - the caller rejects it instead. Pull is the inverse (many
* parent sources may share one child target, which the resolver handles), so pull does not
* use this guard.
*/
export function findDuplicateTargetEntry(
entries: ApplyForkMappingEntry[]
): { resourceType: ForkResourceType; targetId: string } | null {
const sourcesByTarget = new Map<string, Set<string>>()
for (const entry of entries) {
if (entry.targetId == null) continue
// Null-byte separator so a targetId containing ':' can't be confused with a
// different (resourceType, targetId) pair.
const key = `${entry.resourceType}\u0000${entry.targetId}`
const sources = sourcesByTarget.get(key)
if (!sources) {
sourcesByTarget.set(key, new Set([entry.sourceId]))
continue
}
sources.add(entry.sourceId)
if (sources.size > 1) return { resourceType: entry.resourceType, targetId: entry.targetId }
}
return null
}
/**
* Persist mapping edits for a direction. Pull maps a parent source to a child
* target; push maps a child source to a parent target (clearing a push mapping
* deletes the row).
*/
export async function applyForkMappingEntries(
tx: DbOrTx,
edge: ForkEdge,
userId: string,
direction: 'push' | 'pull',
entries: ApplyForkMappingEntry[]
): Promise<number> {
if (entries.length === 0) return 0
if (direction === 'pull') {
// Pull maps a parent source to a child target - one batched upsert.
await upsertEdgeMappings(
tx,
edge.childWorkspaceId,
userId,
entries.map((entry) => ({
resourceType: entry.resourceType,
parentResourceId: entry.sourceId,
childResourceId: entry.targetId,
}))
)
return entries.length
}
// Push rows are unique on the parent (target) side, so two distinct sources mapped to
// the same target would collide on that index and one would be silently dropped (its
// reference then resolves unmapped). Reject loudly - on push each parent target can back
// only one source. (Pull is the inverse: many parent sources may share one child target,
// which the resolver handles, so pull skips this guard. The modal also disables an
// already-taken target on push so users never reach this error normally.)
const collision = findDuplicateTargetEntry(entries)
if (collision) {
const kind = resourceTypeToForkKind(collision.resourceType) ?? collision.resourceType
throw new ForkError(
`Two sources are mapped to the same ${kind} target. Each target can be mapped from only one source.`,
400
)
}
// Push rows are keyed by the child (source) side, but the table's unique key is on
// the parent side - so clear any existing row for each source first (one grouped
// delete), otherwise changing a push target leaves the old (parent, source) row
// behind and resolution becomes ambiguous. Then upsert the new (target, source)
// rows in one batch; a null target is a cleared mapping (delete only, no reinsert).
await deleteEdgeMappingsByChildResources(
tx,
edge.childWorkspaceId,
entries.map((entry) => ({ resourceType: entry.resourceType, childResourceId: entry.sourceId }))
)
await upsertEdgeMappings(
tx,
edge.childWorkspaceId,
userId,
entries
.filter((entry) => entry.targetId != null)
.map((entry) => ({
resourceType: entry.resourceType,
parentResourceId: entry.targetId as string,
childResourceId: entry.sourceId,
}))
)
return entries.length
}
/**
* Reject mapping entries whose chosen target does not belong to the target
* workspace, so a caller cannot point a remapped reference (or credential-access
* propagation) at a resource in a workspace they do not administer. Entries whose
* resource type is not user-mappable (only `workflow`, whose identity is
* system-managed) are rejected outright. Credential targets must additionally share
* the source credential's OAuth provider, so a Gmail reference can never be pointed
* at a Google Calendar credential (the UI enforces this; this is the write-side
* boundary that catches direct API calls and stale rows).
*/
export async function validateForkMappingTargets(
sourceWorkspaceId: string,
targetWorkspaceId: string,
entries: ApplyForkMappingEntry[]
): Promise<void> {
const withTarget = entries.filter((entry) => entry.targetId != null)
if (withTarget.length === 0) return
// Collect the exact target ids per kind so existence is checked by id, NOT against
// the display-capped candidate list - a valid target that simply sits past the cap
// must never be rejected on save.
const targetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
let hasEnvVar = false
for (const entry of withTarget) {
const kind = resourceTypeToForkKind(entry.resourceType)
if (!kind) {
// `workflow` is the only null-kind type, and its identity is system-managed by
// fork/promote/rollback. A non-null target for it here is an invalid (or
// crafted) entry the editor must never persist.
throw new ForkError(
`Resource type "${entry.resourceType}" cannot be mapped via the mapping editor`,
400
)
}
if (kind === 'env-var') {
hasEnvVar = true
continue
}
;(targetIdsByKind[kind] ??= new Set()).add(entry.targetId as string)
}
const credentialEntries = withTarget.filter(
(entry) => resourceTypeToForkKind(entry.resourceType) === 'credential'
)
const [existingTargets, targetEnvKeys, sourceProviders, targetProviders] = await Promise.all([
filterExistingForkTargets(db, targetWorkspaceId, targetIdsByKind),
hasEnvVar ? getWorkspaceEnvKeys(db, targetWorkspaceId) : Promise.resolve(new Set<string>()),
getCredentialProvidersByIds(
db,
sourceWorkspaceId,
credentialEntries.map((entry) => entry.sourceId)
),
getCredentialProvidersByIds(
db,
targetWorkspaceId,
credentialEntries.map((entry) => entry.targetId as string)
),
])
for (const entry of withTarget) {
const kind = resourceTypeToForkKind(entry.resourceType)
if (!kind) continue
const targetId = entry.targetId as string
if (kind === 'env-var') {
if (!targetEnvKeys.has(targetId)) {
throw new ForkError(
`Mapping target "${targetId}" is not an environment variable in the target workspace`,
400
)
}
continue
}
if (!existingTargets[kind]?.has(targetId)) {
throw new ForkError(
`Mapping target "${targetId}" is not a valid ${kind} in the target workspace`,
400
)
}
if (kind === 'credential') {
// The source must be a real credential in the source workspace. A foreign id
// (not present) would skip the provider check and let a crafted mapping drive
// cross-workspace credential-access propagation on promote.
if (!sourceProviders.has(entry.sourceId)) {
throw new ForkError(
`Source credential "${entry.sourceId}" is not a credential in the source workspace`,
400
)
}
const sourceProviderId = sourceProviders.get(entry.sourceId)
const targetProviderId = targetProviders.get(targetId) ?? null
if (sourceProviderId && targetProviderId !== sourceProviderId) {
throw new ForkError(
`Mapping target "${targetId}" must use the same provider as the source credential`,
400
)
}
}
}
}
@@ -0,0 +1,78 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildForkResolver,
type ForkMappingRow,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
const credentialRow: ForkMappingRow = {
id: 'm1',
childWorkspaceId: 'ws-child',
resourceType: 'oauth_credential',
parentResourceId: 'cred-parent',
childResourceId: 'cred-child',
}
describe('buildForkResolver', () => {
it('resolves source->target for a pull (source is parent)', () => {
const resolve = buildForkResolver([credentialRow], { sourceIsParent: true })
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
})
it('resolves source->target for a push (source is child)', () => {
const resolve = buildForkResolver([credentialRow], { sourceIsParent: false })
expect(resolve('credential', 'cred-child')).toBe('cred-parent')
})
it('skips unmapped rows (null childResourceId)', () => {
const resolve = buildForkResolver([{ ...credentialRow, childResourceId: null }], {
sourceIsParent: true,
})
expect(resolve('credential', 'cred-parent')).toBeNull()
})
it('drops a mapped target that no longer exists in the target workspace', () => {
const resolve = buildForkResolver([credentialRow], {
sourceIsParent: true,
// target cred-child was deleted after the mapping was saved
validTargetIdsByKind: { credential: new Set<string>() },
})
expect(resolve('credential', 'cred-parent')).toBeNull()
})
it('keeps a mapped target that still exists in the target workspace', () => {
const resolve = buildForkResolver([credentialRow], {
sourceIsParent: true,
validTargetIdsByKind: { credential: new Set(['cred-child']) },
})
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
})
it('does not existence-check kinds absent from validTargetIdsByKind', () => {
const resolve = buildForkResolver([credentialRow], {
sourceIsParent: true,
validTargetIdsByKind: { table: new Set<string>() },
})
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
})
it('falls back to identity for a workspace env key present in the target', () => {
const resolve = buildForkResolver([], {
sourceIsParent: true,
sourceEnvKeys: new Set(['API_KEY']),
targetEnvKeys: new Set(['API_KEY']),
})
expect(resolve('env-var', 'API_KEY')).toBe('API_KEY')
})
it('leaves a personal (non-source-workspace) env key as-is', () => {
const resolve = buildForkResolver([], {
sourceIsParent: true,
sourceEnvKeys: new Set(['WORKSPACE_KEY']),
targetEnvKeys: new Set(),
})
expect(resolve('env-var', 'PERSONAL_KEY')).toBe('PERSONAL_KEY')
})
})
@@ -0,0 +1,315 @@
import { workspaceForkResourceMap } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, inArray, or, sql } from 'drizzle-orm'
import type { z } from 'zod'
import type { forkResourceTypeSchema } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import type {
ForkReferenceResolver,
ForkRemapKind,
} from '@/ee/workspace-forking/lib/remap/remap-references'
/** Mapping rows per insert; each row binds ~8 params, keeping well under PG's limit. */
const MAPPING_INSERT_CHUNK = 1000
/** Derived from the wire contract so the DB enum, Zod schema, and TS type stay in lockstep. */
export type ForkResourceType = z.infer<typeof forkResourceTypeSchema>
export interface ForkMappingRow {
id: string
childWorkspaceId: string
resourceType: ForkResourceType
parentResourceId: string
childResourceId: string | null
}
export interface ForkMappingUpsert {
resourceType: ForkResourceType
parentResourceId: string
childResourceId: string | null
}
const RESOURCE_TYPE_TO_FORK_KIND: Record<ForkResourceType, ForkRemapKind | null> = {
workflow: null,
oauth_credential: 'credential',
service_account_credential: 'credential',
env_var: 'env-var',
table: 'table',
knowledge_base: 'knowledge-base',
knowledge_document: 'knowledge-document',
file: 'file',
mcp_server: 'mcp-server',
// Identity-only, like `workflow`: nothing in a subblock references a workflow-publishing
// server, so these rows never participate in reference remapping.
workflow_mcp_server: null,
custom_tool: 'custom-tool',
skill: 'skill',
}
/** The remapper kind a stored resource type participates in, or null when it does not remap. */
export function resourceTypeToForkKind(resourceType: ForkResourceType): ForkRemapKind | null {
return RESOURCE_TYPE_TO_FORK_KIND[resourceType]
}
// `as const satisfies` (not a `Record<K, V>` annotation) so each key keeps its precise literal
// value type - the generic accessor below then narrows its return per input kind (a uniform
// Record value type would collapse every key to the full value union).
const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE = {
'env-var': 'env_var',
table: 'table',
'knowledge-base': 'knowledge_base',
'knowledge-document': 'knowledge_document',
file: 'file',
'mcp-server': 'mcp_server',
'custom-tool': 'custom_tool',
skill: 'skill',
} as const satisfies Record<
Exclude<ForkRemapKind, 'credential'>,
Exclude<ForkResourceType, 'workflow'>
>
/**
* Stored resource type for a non-credential remap kind. Credentials are resolved
* separately via `classifyCredentialResourceType` since the type (oauth vs
* service account) depends on the credential row.
*/
export function nonCredentialForkKindToResourceType<K extends Exclude<ForkRemapKind, 'credential'>>(
kind: K
): (typeof NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE)[K] {
return NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE[kind]
}
export async function getEdgeMappingRows(
executor: DbOrTx,
childWorkspaceId: string
): Promise<ForkMappingRow[]> {
const rows = await executor
.select({
id: workspaceForkResourceMap.id,
childWorkspaceId: workspaceForkResourceMap.childWorkspaceId,
resourceType: workspaceForkResourceMap.resourceType,
parentResourceId: workspaceForkResourceMap.parentResourceId,
childResourceId: workspaceForkResourceMap.childResourceId,
})
.from(workspaceForkResourceMap)
.where(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId))
// Deterministic order so resolver/identity construction is stable if duplicates
// ever exist (the push edit + rollback cleanup prevent them, this is defense).
.orderBy(asc(workspaceForkResourceMap.createdAt), asc(workspaceForkResourceMap.id))
return rows as ForkMappingRow[]
}
/**
* Delete workflow-identity mapping rows by the ids on one side (parent or child).
* Used by rollback to dissolve the identity rows a promote created, so a later
* re-promote of the same source converges instead of leaking a second row.
*/
export async function deleteWorkflowIdentityByIds(
tx: DbOrTx,
childWorkspaceId: string,
side: 'parent' | 'child',
ids: string[]
): Promise<void> {
if (ids.length === 0) return
const sideColumn =
side === 'parent'
? workspaceForkResourceMap.parentResourceId
: workspaceForkResourceMap.childResourceId
await tx
.delete(workspaceForkResourceMap)
.where(
and(
eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId),
eq(workspaceForkResourceMap.resourceType, 'workflow'),
inArray(sideColumn, ids)
)
)
}
/**
* Insert mapping rows that don't already exist (used at fork time to seed every
* detected reference as unmapped). Existing rows are left untouched.
*/
export async function seedEdgeMappings(
tx: DbOrTx,
childWorkspaceId: string,
userId: string,
entries: ForkMappingUpsert[]
): Promise<void> {
if (entries.length === 0) return
const now = new Date()
// Chunked so a fork copying many resources stays well under the Postgres bind
// parameter limit (each row binds ~8 params).
for (let i = 0; i < entries.length; i += MAPPING_INSERT_CHUNK) {
const batch = entries.slice(i, i + MAPPING_INSERT_CHUNK)
await tx
.insert(workspaceForkResourceMap)
.values(
batch.map((entry) => ({
id: generateId(),
childWorkspaceId,
resourceType: entry.resourceType,
parentResourceId: entry.parentResourceId,
childResourceId: entry.childResourceId,
createdBy: userId,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoNothing({
target: [
workspaceForkResourceMap.childWorkspaceId,
workspaceForkResourceMap.resourceType,
workspaceForkResourceMap.parentResourceId,
],
})
}
}
/**
* Insert or update mapping rows in batched, chunked multi-row upserts, setting
* `childResourceId` (the chosen target) from the incoming row. Used when a user
* saves a mapping and to persist promote identity rows - one query per chunk
* instead of one per row, so a large save stays a short transaction.
*
* Entries are deduped by the conflict key (resourceType, parentResourceId), keeping
* the last (matching the prior per-row last-write-wins) so a batch can never trip
* Postgres's "ON CONFLICT DO UPDATE cannot affect row a second time".
*/
export async function upsertEdgeMappings(
tx: DbOrTx,
childWorkspaceId: string,
userId: string,
entries: ForkMappingUpsert[]
): Promise<void> {
if (entries.length === 0) return
const now = new Date()
const byConflictKey = new Map<string, ForkMappingUpsert>()
for (const entry of entries) {
byConflictKey.set(`${entry.resourceType}:${entry.parentResourceId}`, entry)
}
const deduped = Array.from(byConflictKey.values())
for (let i = 0; i < deduped.length; i += MAPPING_INSERT_CHUNK) {
const batch = deduped.slice(i, i + MAPPING_INSERT_CHUNK)
await tx
.insert(workspaceForkResourceMap)
.values(
batch.map((entry) => ({
id: generateId(),
childWorkspaceId,
resourceType: entry.resourceType,
parentResourceId: entry.parentResourceId,
childResourceId: entry.childResourceId,
createdBy: userId,
createdAt: now,
updatedAt: now,
}))
)
.onConflictDoUpdate({
target: [
workspaceForkResourceMap.childWorkspaceId,
workspaceForkResourceMap.resourceType,
workspaceForkResourceMap.parentResourceId,
],
set: { childResourceId: sql`excluded.child_resource_id`, updatedAt: now },
})
}
}
/**
* Remove mapping rows matched by their child-side (source) resource id, grouped by
* resource type into a single OR-of-INs - one query for the whole push save (the
* unique key is on the parent side, so a changed push target must drop the old
* (parent, source) row before the new one is inserted).
*/
export async function deleteEdgeMappingsByChildResources(
tx: DbOrTx,
childWorkspaceId: string,
pairs: Array<{ resourceType: ForkResourceType; childResourceId: string }>
): Promise<void> {
if (pairs.length === 0) return
const idsByType = new Map<ForkResourceType, string[]>()
for (const { resourceType, childResourceId } of pairs) {
const list = idsByType.get(resourceType)
if (list) list.push(childResourceId)
else idsByType.set(resourceType, [childResourceId])
}
const conditions = Array.from(idsByType, ([resourceType, ids]) =>
and(
eq(workspaceForkResourceMap.resourceType, resourceType),
inArray(workspaceForkResourceMap.childResourceId, ids)
)
)
await tx
.delete(workspaceForkResourceMap)
.where(and(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId), or(...conditions)))
}
export interface BuildForkResolverOptions {
/** When the source side of the promote is the parent workspace (a pull). */
sourceIsParent: boolean
/**
* Env keys present in the target workspace. A workspace-secret env reference with
* no explicit mapping resolves to itself when the same key exists in the target.
*/
targetEnvKeys?: Set<string>
/**
* Env keys defined at the SOURCE workspace level. Only these are workspace secrets
* that can be mapped; any other `{{KEY}}` is a personal (user-scoped) secret that
* resolves identically in any workspace and is left as-is (never mapped/required).
*/
sourceEnvKeys?: Set<string>
/**
* Target ids that still EXIST in the target workspace, per kind, among the mapped
* targets. When a kind is present, a mapped target NOT in its set is treated as
* unmapped (the target was deleted after the mapping was saved), so a dead id is
* never written into the promoted workflow. Kinds absent here are not existence-
* checked (resolved as before).
*/
validTargetIdsByKind?: Partial<Record<ForkRemapKind, Set<string>>>
}
/**
* Build a reference resolver from persisted mapping rows for the chosen
* direction. Translates a source-space resource id to its mapped target id;
* rows whose `childResourceId` is null (unmapped) are skipped. Env keys fall
* back to an identity mapping when the target workspace already has the key.
*/
export function buildForkResolver(
rows: ForkMappingRow[],
options: BuildForkResolverOptions
): ForkReferenceResolver {
const index = new Map<ForkRemapKind, Map<string, string>>()
for (const row of rows) {
const kind = resourceTypeToForkKind(row.resourceType)
if (!kind) continue
if (row.childResourceId == null) continue
const sourceId = options.sourceIsParent ? row.parentResourceId : row.childResourceId
const targetId = options.sourceIsParent ? row.childResourceId : row.parentResourceId
let kindIndex = index.get(kind)
if (!kindIndex) {
kindIndex = new Map()
index.set(kind, kindIndex)
}
kindIndex.set(sourceId, targetId)
}
return (kind, sourceId) => {
const mapped = index.get(kind)?.get(sourceId)
if (mapped != null) {
const validSet = options.validTargetIdsByKind?.[kind]
if (!validSet || validSet.has(mapped)) return mapped
// The mapped target was deleted from the target workspace after the mapping was
// saved. Fall through so the reference resolves as unmapped (surfaced as required
// / cleared if optional) instead of writing a dead id into the promoted workflow.
}
if (kind === 'env-var') {
// Personal/global env vars (not a source workspace secret) are user-scoped and
// resolve identically in any workspace - leave them as-is, never map them.
if (options.sourceEnvKeys && !options.sourceEnvKeys.has(sourceId)) return sourceId
// Workspace secret already present in the target by the same name → identity.
if (options.targetEnvKeys?.has(sourceId)) return sourceId
}
return null
}
}
@@ -0,0 +1,165 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import {
listForkCopyableSourceResources,
listForkResourceCandidates,
loadForkCopyableResourceLabels,
} from '@/ee/workspace-forking/lib/mapping/resources'
const executor = dbChainMock.db as unknown as DbOrTx
describe('listForkResourceCandidates', () => {
beforeEach(() => {
resetDbChainMock()
})
it('populates file candidates keyed by storage key and leaves knowledge-document empty', async () => {
// The grouped queries resolve in Promise.all array order, each ending in `.limit()`:
// credentials, workspace env, tables, knowledge bases, MCP servers, custom tools, skills,
// files. Queue the eight results in that exact order.
dbChainMockFns.limit
.mockResolvedValueOnce([
{ id: 'cred-1', displayName: 'Cred One', providerId: 'google-email' },
])
.mockResolvedValueOnce([{ variables: { API_KEY: 'secret' } }])
.mockResolvedValueOnce([{ id: 'tbl-1', label: 'Table One' }])
.mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }])
.mockResolvedValueOnce([{ id: 'mcp-1', label: 'MCP One' }])
.mockResolvedValueOnce([{ id: 'ct-1', label: 'Tool One' }])
.mockResolvedValueOnce([{ id: 'sk-1', label: 'Skill One' }])
.mockResolvedValueOnce([
{ id: 'workspace/WS/report.pdf', label: 'report.pdf' },
{ id: 'workspace/WS/notes.md', label: 'notes.md' },
])
const result = await listForkResourceCandidates(executor, 'ws-1')
// Files are mapping targets keyed by storage key (matching how `file-upload` references store
// them) - never a `workspace_files.id`.
expect(result.file).toEqual([
{ id: 'workspace/WS/report.pdf', label: 'report.pdf' },
{ id: 'workspace/WS/notes.md', label: 'notes.md' },
])
// Documents are not a standalone mappable kind - they ride their KB via the reconfigure flow.
expect(result['knowledge-document']).toEqual([])
expect(result['env-var']).toEqual([{ id: 'API_KEY', label: 'API_KEY' }])
})
})
describe('listForkCopyableSourceResources', () => {
beforeEach(() => {
resetDbChainMock()
})
it('lists every sync-copyable kind, files keyed by storage key with folder grouping', async () => {
// The grouped queries resolve in Promise.all array order, each ending in `.limit()`:
// files (with folder), tables, knowledge bases, custom tools, skills.
dbChainMockFns.limit
.mockResolvedValueOnce([
{
id: 'file-row-1',
key: 'workspace/SRC/a.png',
label: 'a.png',
folderId: 'fld-1',
folderName: 'Images',
},
{
id: 'file-row-2',
key: 'workspace/SRC/root.txt',
label: 'root.txt',
folderId: null,
folderName: null,
},
])
.mockResolvedValueOnce([{ id: 'tbl-1', label: 'Table One' }])
.mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }])
.mockResolvedValueOnce([{ id: 'ct-1', label: 'Tool One' }])
.mockResolvedValueOnce([{ id: 'sk-1', label: 'Skill One' }])
const result = await listForkCopyableSourceResources(executor, 'ws-src')
expect(result).toEqual([
// Files are addressed by STORAGE KEY (matching `file-upload` references + the promote copy
// selection), never by `workspace_files.id`, and carry their folder grouping.
{
kind: 'file',
sourceId: 'workspace/SRC/a.png',
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Images',
},
{
kind: 'file',
sourceId: 'workspace/SRC/root.txt',
label: 'root.txt',
parentId: null,
parentLabel: null,
},
{ kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null },
{
kind: 'knowledge-base',
sourceId: 'kb-1',
label: 'KB One',
parentId: null,
parentLabel: null,
},
{
kind: 'custom-tool',
sourceId: 'ct-1',
label: 'Tool One',
parentId: null,
parentLabel: null,
},
{ kind: 'skill', sourceId: 'sk-1', label: 'Skill One', parentId: null, parentLabel: null },
])
})
})
describe('loadForkCopyableResourceLabels', () => {
beforeEach(() => {
resetDbChainMock()
})
it('carries the folder grouping for file entries (id + name, null at the root)', async () => {
// Only the file branch queries (no other kind has ids), so its terminal `.where()` is the
// single chain call.
dbChainMockFns.where.mockResolvedValueOnce([
{ key: 'workspace/SRC/a.png', label: 'a.png', folderId: 'fld-1', folderName: 'Images' },
{ key: 'workspace/SRC/root.txt', label: 'root.txt', folderId: null, folderName: null },
])
const labels = await loadForkCopyableResourceLabels(executor, 'ws-src', {
file: ['workspace/SRC/a.png', 'workspace/SRC/root.txt'],
})
expect(labels.get('file:workspace/SRC/a.png')).toEqual({
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Images',
})
// A file at the workspace root (or whose folder was deleted) carries null folder grouping.
expect(labels.get('file:workspace/SRC/root.txt')).toEqual({
label: 'root.txt',
parentId: null,
parentLabel: null,
})
})
it('returns null folder grouping for non-file kinds (they render flat)', async () => {
dbChainMockFns.where.mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }])
const labels = await loadForkCopyableResourceLabels(executor, 'ws-src', {
'knowledge-base': ['kb-1'],
})
expect(labels.get('knowledge-base:kb-1')).toEqual({
label: 'KB One',
parentId: null,
parentLabel: null,
})
})
})
@@ -0,0 +1,632 @@
import {
credential,
customTools,
document,
knowledgeBase,
mcpServers,
skill,
userTableDefinitions,
workflow,
workflowDeploymentVersion,
workflowMcpServer,
workspaceEnvironment,
workspaceFileFolder,
workspaceFiles,
} from '@sim/db/schema'
import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
import type { ForkCopyableKind } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import type { ForkResourceType } from '@/ee/workspace-forking/lib/mapping/mapping-store'
import type {
ForkMcpServerMeta,
ForkRemapKind,
} from '@/ee/workspace-forking/lib/remap/remap-references'
export interface ForkResourceCandidate {
id: string
label: string
providerId?: string
}
export const CANDIDATE_LIMIT = 1000
/** The set of env-var keys defined in a workspace (for resolver identity + gating). */
export async function getWorkspaceEnvKeys(
executor: DbOrTx,
workspaceId: string
): Promise<Set<string>> {
const [row] = await executor
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const variables = row?.variables
if (!variables || typeof variables !== 'object') return new Set()
return new Set(Object.keys(variables as Record<string, unknown>))
}
// Shared `{ id, label }` candidate queries for the content resource kinds that BOTH the
// mapping-target picker and the fork-copy picker list - one source of the archived/deleted
// filters so the two pickers can never drift apart, and one optional `ids` filter so the picker
// path (unfiltered, capped) and the existence/label path (exact ids) share a single definition
// per kind. When `ids` is given the query is filtered to those exact ids and is NOT capped, so a
// valid target sitting past the candidate cap is never wrongly dropped. Credentials, env vars
// (mapping-only), and files-with-folder (copy-only) keep their own helpers below.
const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
const query = executor
.select({ id: userTableDefinitions.id, label: userTableDefinitions.name })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt),
ids ? inArray(userTableDefinitions.id, ids) : undefined
)
)
return ids ? query : query.limit(CANDIDATE_LIMIT)
}
const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
const query = executor
.select({ id: knowledgeBase.id, label: knowledgeBase.name })
.from(knowledgeBase)
.where(
and(
eq(knowledgeBase.workspaceId, workspaceId),
isNull(knowledgeBase.deletedAt),
ids ? inArray(knowledgeBase.id, ids) : undefined
)
)
return ids ? query : query.limit(CANDIDATE_LIMIT)
}
const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
const query = executor
.select({ id: customTools.id, label: customTools.title })
.from(customTools)
.where(
and(eq(customTools.workspaceId, workspaceId), ids ? inArray(customTools.id, ids) : undefined)
)
return ids ? query : query.limit(CANDIDATE_LIMIT)
}
const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
const query = executor
.select({ id: skill.id, label: skill.name })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), ids ? inArray(skill.id, ids) : undefined))
return ids ? query : query.limit(CANDIDATE_LIMIT)
}
const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
const query = executor
.select({ id: mcpServers.id, label: mcpServers.name })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt),
ids ? inArray(mcpServers.id, ids) : undefined
)
)
return ids ? query : query.limit(CANDIDATE_LIMIT)
}
// Workspace-file mapping candidates are keyed by STORAGE KEY (not `workspace_files.id`): a
// `file-upload` reference stores the storage key, so a mapping target must be a key too. Only
// durable, non-deleted `workspace` files are mappable (chat/copilot uploads are session-scoped).
// An optional `keys` filter shares this definition between the mapping picker (unfiltered, capped)
// and the cap-free existence check.
const fileCandidatesQuery = (executor: DbOrTx, workspaceId: string, keys?: string[]) => {
const query = executor
.select({
id: workspaceFiles.key,
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
})
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt),
keys ? inArray(workspaceFiles.key, keys) : undefined
)
)
return keys ? query : query.limit(CANDIDATE_LIMIT)
}
// Copyable workspace files WITH their folder grouping (LEFT JOIN gated on a live folder, so a file
// whose folder was deleted shows ungrouped). Shared by the fork-copy picker (unfiltered, capped)
// and the promote copyable-label lookup (filtered by exact storage keys, never capped), so the
// file+folder shape and its filters live in one place. Selects both the row id and the storage
// key; the copy picker reads `id`, the key-addressed label lookup reads `key`.
const fileCandidatesWithFolderQuery = (
executor: DbOrTx,
workspaceId: string,
options: { keys?: string[] } = {}
) => {
const { keys } = options
const query = executor
.select({
id: workspaceFiles.id,
key: workspaceFiles.key,
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
folderId: workspaceFiles.folderId,
folderName: workspaceFileFolder.name,
})
.from(workspaceFiles)
.leftJoin(
workspaceFileFolder,
and(
eq(workspaceFiles.folderId, workspaceFileFolder.id),
isNull(workspaceFileFolder.deletedAt)
)
)
.where(
and(
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt),
keys ? inArray(workspaceFiles.key, keys) : undefined
)
)
return keys ? query : query.limit(CANDIDATE_LIMIT)
}
/**
* List the resources in a workspace that can serve as mapping targets, grouped by
* remap kind. Used to populate the mapping UI's target pickers and to label the
* source resources being mapped. `knowledge-document` is intentionally left empty:
* documents are not a standalone mappable kind - they are dependent fields of their
* knowledge base, re-picked in the per-KB reconfigure flow (and auto-remapped when
* their KB is copied). `file` candidates are keyed by storage key.
*/
export async function listForkResourceCandidates(
executor: DbOrTx,
workspaceId: string
): Promise<Record<ForkRemapKind, ForkResourceCandidate[]>> {
const [creds, wsEnvRows, tables, kbs, servers, tools, skills, files] = await Promise.all([
executor
.select({
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
})
.from(credential)
// Only real connections are mappable credentials. `env_workspace`/`env_personal`
// rows live in the same table but are environment variables (surfaced via the
// 'env-var' kind), so they must never appear as credential targets.
.where(
and(
eq(credential.workspaceId, workspaceId),
inArray(credential.type, ['oauth', 'service_account'])
)
)
.limit(CANDIDATE_LIMIT),
executor
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1),
tableCandidatesQuery(executor, workspaceId),
knowledgeBaseCandidatesQuery(executor, workspaceId),
mcpServerCandidatesQuery(executor, workspaceId),
customToolCandidatesQuery(executor, workspaceId),
skillCandidatesQuery(executor, workspaceId),
fileCandidatesQuery(executor, workspaceId),
])
const envVariables = wsEnvRows[0]?.variables
const envKeys =
envVariables && typeof envVariables === 'object'
? Object.keys(envVariables as Record<string, unknown>)
: []
return {
credential: creds.map((c) => ({
id: c.id,
label: c.displayName,
providerId: c.providerId ?? undefined,
})),
'env-var': envKeys.map((key) => ({ id: key, label: key })),
table: tables,
'knowledge-base': kbs,
'mcp-server': servers,
'custom-tool': tools,
skill: skills,
'knowledge-document': [],
file: files,
}
}
/**
* Given mapped target ids grouped by kind, return the subset that still EXISTS in the
* target workspace (same archived/deleted filters as `listForkResourceCandidates`).
* Used at promote time so a mapping whose target was deleted after it was saved
* resolves as unmapped (surfaced/cleared) instead of writing a dead id into the
* promoted workflow. Queries the exact ids (not the capped candidate list) so a valid
* target is never wrongly dropped, and only the DB-backed kinds are checked - env-var
* existence is handled by the resolver's `targetEnvKeys`, and `file`/`workflow` are
* resolved by other paths.
*/
export async function filterExistingForkTargets(
executor: DbOrTx,
workspaceId: string,
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
): Promise<Partial<Record<ForkRemapKind, Set<string>>>> {
const ids = (kind: ForkRemapKind): string[] => {
const set = idsByKind[kind]
return set && set.size > 0 ? Array.from(set) : []
}
const credIds = ids('credential')
const tableIds = ids('table')
const kbIds = ids('knowledge-base')
const docIds = ids('knowledge-document')
const mcpIds = ids('mcp-server')
const toolIds = ids('custom-tool')
const skillIds = ids('skill')
// Files are identified by storage key (not `workspace_files.id`); a copied file's mapping
// target is its child storage key, so existence is checked by key in the target workspace.
const fileKeys = ids('file')
const [creds, tables, kbs, docs, servers, tools, skills, files] = await Promise.all([
credIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: credential.id })
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
inArray(credential.type, ['oauth', 'service_account']),
inArray(credential.id, credIds)
)
),
tableIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: tableCandidatesQuery(executor, workspaceId, tableIds),
kbIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: knowledgeBaseCandidatesQuery(executor, workspaceId, kbIds),
// Documents are validated through a KB join (they are not a standalone candidate kind), so
// this existence check stays inline rather than sharing a per-kind candidate query.
docIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: document.id })
.from(document)
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
.where(
and(
eq(knowledgeBase.workspaceId, workspaceId),
isNull(knowledgeBase.deletedAt),
isNull(document.deletedAt),
isNull(document.archivedAt),
inArray(document.id, docIds)
)
),
mcpIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: mcpServerCandidatesQuery(executor, workspaceId, mcpIds),
toolIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: customToolCandidatesQuery(executor, workspaceId, toolIds),
skillIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: skillCandidatesQuery(executor, workspaceId, skillIds),
fileKeys.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: fileCandidatesQuery(executor, workspaceId, fileKeys),
])
const result: Partial<Record<ForkRemapKind, Set<string>>> = {}
if (credIds.length > 0) result.credential = new Set(creds.map((r) => r.id))
if (tableIds.length > 0) result.table = new Set(tables.map((r) => r.id))
if (kbIds.length > 0) result['knowledge-base'] = new Set(kbs.map((r) => r.id))
if (docIds.length > 0) result['knowledge-document'] = new Set(docs.map((r) => r.id))
if (mcpIds.length > 0) result['mcp-server'] = new Set(servers.map((r) => r.id))
if (toolIds.length > 0) result['custom-tool'] = new Set(tools.map((r) => r.id))
if (skillIds.length > 0) result.skill = new Set(skills.map((r) => r.id))
// `fileCandidatesQuery` exposes the storage key under `id`, so file existence keys by `r.id`.
if (fileKeys.length > 0) result.file = new Set(files.map((r) => r.id))
return result
}
/**
* Identity metadata (`name`/`url`) for the given MCP server ids in a workspace, looked up by
* exact id (no candidate cap, same deleted filter as the candidates). Promote uses it for the
* MAPPED TARGET servers so remapped tool-input entries rewrite their embedded server metadata
* from the target row (see {@link ForkMcpServerMeta}) - one bounded `inArray` read per sync,
* never per-entry. An id absent from the map no longer exists; its entries are left as-is.
*/
export async function getMcpServerMetaByIds(
executor: DbOrTx,
workspaceId: string,
ids: string[]
): Promise<Map<string, ForkMcpServerMeta>> {
if (ids.length === 0) return new Map()
const rows = await executor
.select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt),
inArray(mcpServers.id, ids)
)
)
return new Map(rows.map((row) => [row.id, { name: row.name, url: row.url ?? null }]))
}
/**
* Provider id for each given credential id in a workspace, looked up by exact id (no
* candidate cap). Presence in the returned map means the credential exists in the
* workspace, so this doubles as a cap-free existence + provider check for validation.
*/
export async function getCredentialProvidersByIds(
executor: DbOrTx,
workspaceId: string,
ids: string[]
): Promise<Map<string, string | null>> {
if (ids.length === 0) return new Map()
const rows = await executor
.select({ id: credential.id, providerId: credential.providerId })
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
inArray(credential.type, ['oauth', 'service_account']),
inArray(credential.id, ids)
)
)
return new Map(rows.map((row) => [row.id, row.providerId ?? null]))
}
/** A copyable workspace file plus its folder grouping (null folder = workspace root). */
export interface ForkCopyableFileResource extends ForkResourceCandidate {
folderId: string | null
folderName: string | null
}
export interface ForkCopyableResources {
files: ForkCopyableFileResource[]
tables: ForkResourceCandidate[]
knowledgeBases: ForkResourceCandidate[]
customTools: ForkResourceCandidate[]
skills: ForkResourceCandidate[]
/** External MCP servers, copied as config rows (OAuth tokens never copied - re-auth in child). */
mcpServers: ForkResourceCandidate[]
/** Workflow-publishing MCP servers, copied as config-only shells with no workflows attached. */
workflowMcpServers: ForkResourceCandidate[]
/**
* Count of deployed workflows that the fork would copy. When 0, the fork modal shows an
* informational note (forking is never blocked) - create-fork seeds a blank starter
* workflow so the child is still a usable workspace.
*/
deployedWorkflowCount: number
}
/**
* List the resources in a workspace that can be selected for copy at fork time
* (the content kinds — never credentials or env vars). Powers the fork modal's
* resource picker.
*/
export async function listForkCopyableResources(
executor: DbOrTx,
workspaceId: string
): Promise<ForkCopyableResources> {
const [files, tables, kbs, tools, skills, externalServers, servers, deployed] = await Promise.all(
[
fileCandidatesWithFolderQuery(executor, workspaceId),
tableCandidatesQuery(executor, workspaceId),
knowledgeBaseCandidatesQuery(executor, workspaceId),
customToolCandidatesQuery(executor, workspaceId),
skillCandidatesQuery(executor, workspaceId),
// External MCP servers copy as config rows (same filter as the mapping candidates).
mcpServerCandidatesQuery(executor, workspaceId),
executor
.select({ id: workflowMcpServer.id, label: workflowMcpServer.name })
.from(workflowMcpServer)
.where(
and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt))
)
.limit(CANDIDATE_LIMIT),
executor
.select({ value: count() })
.from(workflow)
// Match listDeployedWorkflows: a workflow only counts as copyable when it has an
// actually-active deployment version, not just the isDeployed flag, so the fork
// modal's preflight count never over-reports "ghost" deployed workflows.
.where(
and(
eq(workflow.workspaceId, workspaceId),
eq(workflow.isDeployed, true),
isNull(workflow.archivedAt),
exists(
executor
.select({ one: sql`1` })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
)
)
),
]
)
return {
// The shared folder query also selects the storage key (for the label lookup); the copy
// picker addresses files by `workspace_files.id`, so drop the key here.
files: files.map((row) => ({
id: row.id,
label: row.label,
folderId: row.folderId,
folderName: row.folderName,
})),
tables,
knowledgeBases: kbs,
customTools: tools,
skills,
mcpServers: externalServers,
workflowMcpServers: servers,
deployedWorkflowCount: deployed[0]?.value ?? 0,
}
}
/**
* A copyable reference's display label plus its folder grouping. `parentId`/`parentLabel` are
* populated only for files (their folder id + name; null at the workspace root) and are null for
* every other copyable kind, which the picker renders flat.
*/
export interface ForkCopyableLabel {
label: string
parentId: string | null
parentLabel: string | null
}
/**
* One copyable resource in the sync SOURCE workspace, keyed the way the promote copy addresses
* it: files by STORAGE KEY (matching `file-upload` references + `planForkFileCopies`), every
* other kind by row id. `parentId`/`parentLabel` carry a file's folder grouping (null for
* non-file kinds and root files).
*/
export interface ForkCopyableSourceResource {
kind: ForkCopyableKind
sourceId: string
label: string
parentId: string | null
parentLabel: string | null
}
/**
* Every copyable-kind resource in the sync source workspace (same archived/deleted filters and
* per-kind {@link CANDIDATE_LIMIT} cap as the copy picker), as sync-copy candidate entries. The
* promote plan filters these down to the UNREFERENCED-and-unmapped set it offers for copy
* alongside the referenced candidates. Covers exactly the sync-copyable kinds
* (`forkCopyableKindSchema`): workflow-publishing MCP servers are fork-copy-only shells, and
* credentials / env vars are never copied.
*/
export async function listForkCopyableSourceResources(
executor: DbOrTx,
sourceWorkspaceId: string
): Promise<ForkCopyableSourceResource[]> {
const [files, tables, kbs, tools, skills, mcp] = await Promise.all([
fileCandidatesWithFolderQuery(executor, sourceWorkspaceId),
tableCandidatesQuery(executor, sourceWorkspaceId),
knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId),
customToolCandidatesQuery(executor, sourceWorkspaceId),
skillCandidatesQuery(executor, sourceWorkspaceId),
mcpServerCandidatesQuery(executor, sourceWorkspaceId),
])
const flat = (
kind: ForkCopyableKind,
rows: Array<{ id: string; label: string }>
): ForkCopyableSourceResource[] =>
rows.map((row) => ({
kind,
sourceId: row.id,
label: row.label,
parentId: null,
parentLabel: null,
}))
return [
...files.map((row) => ({
kind: 'file' as const,
sourceId: row.key,
label: row.label,
parentId: row.folderId,
parentLabel: row.folderName,
})),
...flat('table', tables),
...flat('knowledge-base', kbs),
...flat('custom-tool', tools),
...flat('skill', skills),
...flat('mcp-server', mcp),
]
}
/**
* Labels (by exact id) for the copyable resource kinds referenced-but-unmapped at promote time,
* scoped to the source workspace and the same archived/deleted filters as the copy picker. A
* resource absent from the result no longer exists in the source, so it can't be copied and is
* dropped from the sync copy candidates. Keyed `${kind}:${id}` so callers can look a reference up
* directly; file entries additionally carry their folder grouping. Only kinds with ids are queried.
*/
export async function loadForkCopyableResourceLabels(
executor: DbOrTx,
sourceWorkspaceId: string,
idsByKind: Partial<Record<ForkCopyableKind, string[]>>
): Promise<Map<string, ForkCopyableLabel>> {
const labels = new Map<string, ForkCopyableLabel>()
const ids = (kind: ForkCopyableKind): string[] => {
const list = idsByKind[kind]
return list && list.length > 0 ? list : []
}
const kbIds = ids('knowledge-base')
const tableIds = ids('table')
const toolIds = ids('custom-tool')
const skillIds = ids('skill')
const mcpIds = ids('mcp-server')
// Files are keyed by storage key (not `workspace_files.id`), so they label by key.
const fileKeys = ids('file')
const [kbs, tables, tools, skills, mcp, files] = await Promise.all([
kbIds.length === 0
? Promise.resolve([] as Array<{ id: string; label: string }>)
: knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId, kbIds),
tableIds.length === 0
? Promise.resolve([] as Array<{ id: string; label: string }>)
: tableCandidatesQuery(executor, sourceWorkspaceId, tableIds),
toolIds.length === 0
? Promise.resolve([] as Array<{ id: string; label: string }>)
: customToolCandidatesQuery(executor, sourceWorkspaceId, toolIds),
skillIds.length === 0
? Promise.resolve([] as Array<{ id: string; label: string }>)
: skillCandidatesQuery(executor, sourceWorkspaceId, skillIds),
mcpIds.length === 0
? Promise.resolve([] as Array<{ id: string; label: string }>)
: mcpServerCandidatesQuery(executor, sourceWorkspaceId, mcpIds),
fileKeys.length === 0
? Promise.resolve(
[] as Array<{
key: string
label: string
folderId: string | null
folderName: string | null
}>
)
: fileCandidatesWithFolderQuery(executor, sourceWorkspaceId, { keys: fileKeys }),
])
const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null })
for (const row of kbs) labels.set(`knowledge-base:${row.id}`, flat(row.label))
for (const row of tables) labels.set(`table:${row.id}`, flat(row.label))
for (const row of tools) labels.set(`custom-tool:${row.id}`, flat(row.label))
for (const row of skills) labels.set(`skill:${row.id}`, flat(row.label))
for (const row of mcp) labels.set(`mcp-server:${row.id}`, flat(row.label))
for (const row of files) {
labels.set(`file:${row.key}`, {
label: row.label,
parentId: row.folderId,
parentLabel: row.folderName,
})
}
return labels
}
/** Resolve a credential id to its stored mapping resource type. */
export async function classifyCredentialResourceType(
executor: DbOrTx,
credentialId: string,
workspaceId: string
): Promise<Extract<ForkResourceType, 'oauth_credential' | 'service_account_credential'>> {
const [row] = await executor
.select({ type: credential.type })
.from(credential)
.where(and(eq(credential.id, credentialId), eq(credential.workspaceId, workspaceId)))
.limit(1)
return row?.type === 'service_account' ? 'service_account_credential' : 'oauth_credential'
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,440 @@
import { mcpServers, workflow } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import type {
ForkClearedRef,
ForkCopyableKind,
ForkSyncBlocker,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
coerceObjectArray,
isRecord,
type SubBlockRecord,
} from '@/lib/workflows/persistence/remap-internal-ids'
import {
buildSubBlockValues,
type CanonicalModeOverrides,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import { collectForkDependentReconfigs } from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
import {
filterExistingForkTargets,
loadForkCopyableResourceLabels,
} from '@/ee/workspace-forking/lib/mapping/resources'
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
import {
selectForkSyncBlockingRefs,
toForkSyncBlockers,
} from '@/ee/workspace-forking/lib/promote/sync-blockers'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import {
createCanonicalModeGates,
type ForkReference,
type ForkReferenceResolver,
type ForkRemapKind,
REQUIRED_KINDS,
remapForkSubBlocks,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/**
* Remappable kinds excluded from the `reference` cleared-ref list. REQUIRED kinds (credential,
* env-var) gate Sync through the kind-level required gate with their own messaging, so they must
* not double-report here (a credential is also preserved by name once mapped, an env-var always).
* `knowledge-document` follows its parent KB - a document under an unmapped KB is implied by the
* KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. Every other kind's
* entry IS a sync blocker (cause `reference`/`workflow`): a sync proceeds only when zero
* references would clear.
*/
const CLEARED_REF_EXCLUDED_KINDS = new Set<ForkRemapKind>([...REQUIRED_KINDS, 'knowledge-document'])
interface ClearedRefItem {
sourceWorkflowId: string
targetWorkflowId: string
mode: 'create' | 'replace'
sourceMeta: { name: string }
}
export interface CollectForkClearedRefsParams {
items: ClearedRefItem[]
sourceStates: Map<string, WorkflowState>
/** Plan resolver (persisted mappings + env identity), to detect which refs are currently unmapped. */
resolver: ForkReferenceResolver
/** Source workflow id -> target id for THIS sync; a ref to a workflow absent here is cleared. */
workflowIdMap: Map<string, string>
/** Same block-id resolver the sync uses, so a candidate's blockId matches the written block. */
resolveBlockId: ForkBlockIdResolver
/** `${kind}:${sourceId}` -> source resource label, for the `sourceLabel` display. */
sourceLabels: Map<string, string>
/** Source workflow id -> name, for `workflow`-kind candidate labels. */
sourceWorkflowNames: Map<string, string>
}
/** Strip an advanced-mode `_N` suffix so a subblock key matches its config id. */
function baseSubBlockId(key: string): string {
return key.replace(/_\d+$/, '')
}
/**
* Cross-workflow references (`workflow-selector`, multi-select `workflowSelector`, the
* workspace-event trigger's multi-select `workflowIds` dropdown, nested `workflow_input` tools)
* in a block's subBlocks. Mirrors the detection in
* {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that
* remap would clear - the free-form manual fields (`manualWorkflowId`, `manualWorkflowIds`) are
* user-owned and never remapped/cleared, so they are intentionally excluded (the `workflowIds`
* branch is gated on TYPE `dropdown` because the logs block's `workflowIds` is a manual
* `short-input`). Returns one entry per referenced workflow id with its owning subblock key.
*/
function collectForkWorkflowReferences(
subBlocks: SubBlockRecord,
config: ReturnType<typeof getBlock>,
canonicalModes: CanonicalModeOverrides | undefined
): Array<{ workflowId: string; subBlockKey: string }> {
const out: Array<{ workflowId: string; subBlockKey: string }> = []
// Collapse each canonical pair to its ACTIVE member and skip condition-hidden fields: only a
// value that serializes is a ref that a sync would clear (the advanced
// `manualWorkflowId`/`manualWorkflowIds` are user-owned and preserved verbatim, an inactive
// operation's selector never executes) - neither may become an unresolvable sync blocker.
// Shares {@link createCanonicalModeGates} with the reference scan, so the scalar `workflowId`
// pair, the deployments block's scalar `workflowSelector` pair, and the logs block's
// multi-select `workflowSelector` (`workflowIds` group) all resolve through their OWN group. A
// missing config or a non-pair member is never skipped (no-pair states keep emitting).
const gates = createCanonicalModeGates(
config?.subBlocks,
buildSubBlockValues(subBlocks),
canonicalModes
)
const detectionSkipped = (key: string) =>
gates.isDormantMember(key) || gates.isConditionHidden(key)
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (!subBlock || typeof subBlock !== 'object') continue
const baseKey = baseSubBlockId(key)
if (
subBlock.type === 'workflow-selector' &&
typeof subBlock.value === 'string' &&
subBlock.value
) {
// Only the SELECTOR is remapped/cleared; the manual member is user-owned and preserved
// verbatim, so skip the dormant selector when advanced/manual mode is active.
if (detectionSkipped(key)) continue
out.push({ workflowId: subBlock.value, subBlockKey: key })
} else if (
baseKey === 'workflowSelector' ||
(subBlock.type === 'dropdown' && baseKey === 'workflowIds')
) {
if (detectionSkipped(key)) continue
const ids = Array.isArray(subBlock.value)
? subBlock.value
: typeof subBlock.value === 'string'
? subBlock.value.split(',').map((entry) => entry.trim())
: []
for (const id of ids) {
if (typeof id === 'string' && id) out.push({ workflowId: id, subBlockKey: key })
}
} else if (subBlock.type === 'tool-input') {
const { array } = coerceObjectArray(subBlock.value)
if (!array) continue
for (const tool of array) {
if (
isRecord(tool) &&
tool.type === 'workflow_input' &&
isRecord(tool.params) &&
typeof tool.params.workflowId === 'string' &&
tool.params.workflowId
) {
out.push({ workflowId: tool.params.workflowId, subBlockKey: key })
}
}
}
}
return out
}
/**
* Compute the per-block/field references this sync WILL blank in the target, for the pre-sync
* "what will be cleared" list. Three causes (see {@link ForkClearedRef}):
* - `reference`: an unmapped remappable resource (credential / KB / table / file / MCP server /
* custom tool / skill). The client filters these against the live mapping + copy selection, so an
* item disappears once mapped or selected for copy. Env vars (preserved) and documents (follow
* their KB) are excluded.
* - `workflow`: a cross-workflow reference to a workflow not carried into the target - always cleared.
* - `dependent`: a create-target dependent selector the source configured that a remapped parent
* clears. Carries `parentKind`/`parentSourceId` so the client can drop it once a KB parent is
* mapped or copied (the document follows its KB); a credential's label or a table's column is
* cleared on any parent remap, so it stays.
*
* Pure (no DB): the caller supplies the plan, source states, resolver, block-id resolver, and the
* source label maps. Block + field labels come from the block registry / block state.
*/
export function collectForkClearedRefCandidates(
params: CollectForkClearedRefsParams
): ForkClearedRef[] {
const { items, sourceStates, resolver, workflowIdMap, resolveBlockId, sourceLabels } = params
const out: ForkClearedRef[] = []
const labelFor = (kind: string, sourceId: string) =>
sourceLabels.get(`${kind}:${sourceId}`) ?? sourceId
for (const item of items) {
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
const config = getBlock(block.type)
const blockLabel = block.name
const targetBlockId = resolveBlockId(item.targetWorkflowId, sourceBlockId)
// double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally
// SubBlockRecord entries but lack the open index signature SubBlockRecord declares
const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const fieldLabel = (subBlockKey: string) =>
config?.subBlocks.find((cfg) => cfg.id === baseSubBlockId(subBlockKey))?.title ??
subBlockKey
// Cause `reference`: unmapped remappable resource refs (per block/field). `blockType` +
// `canonicalModes` gate detection to the ACTIVE canonical member, matching the plan's
// reference scan - a dormant member's stale value is not a real reference, so it must not
// become a blocker with no mapping entry to resolve it. `sourceDeleted` starts false; the
// caller annotates it via {@link annotateForkClearedRefSourceLiveness} (DB check).
const scan = remapForkSubBlocks(subBlocks, resolver, 'promote', {
blockId: targetBlockId,
blockName: blockLabel,
blockType: block.type,
canonicalModes: block.data?.canonicalModes,
})
for (const ref of scan.unmapped) {
if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue
out.push({
targetWorkflowId: item.targetWorkflowId,
workflowName: item.sourceMeta.name,
blockId: targetBlockId,
blockLabel,
fieldLabel: fieldLabel(ref.subBlockKey),
kind: ref.kind,
sourceId: ref.sourceId,
sourceLabel: labelFor(ref.kind, ref.sourceId),
cause: 'reference',
sourceDeleted: false,
})
}
// Cause `workflow`: refs to a workflow not carried into the target.
for (const wfRef of collectForkWorkflowReferences(
subBlocks,
config,
block.data?.canonicalModes
)) {
if (workflowIdMap.has(wfRef.workflowId)) continue
out.push({
targetWorkflowId: item.targetWorkflowId,
workflowName: item.sourceMeta.name,
blockId: targetBlockId,
blockLabel,
fieldLabel: fieldLabel(wfRef.subBlockKey),
kind: 'workflow',
sourceId: wfRef.workflowId,
sourceLabel: params.sourceWorkflowNames.get(wfRef.workflowId) ?? wfRef.workflowId,
cause: 'workflow',
})
}
}
}
// Cause `dependent`: create-target dependent selectors the source configured that a remapped
// parent clears. Only `replace` targets get the in-place reconfigure flow; a created target has
// no draft to re-pick against, so these would clear silently - surface them here.
const workflowNameByTarget = new Map(items.map((i) => [i.targetWorkflowId, i.sourceMeta.name]))
for (const dependent of collectForkDependentReconfigs(
items,
sourceStates,
resolveBlockId,
'create'
)) {
if (dependent.currentValue === '') continue
out.push({
targetWorkflowId: dependent.targetWorkflowId,
workflowName: workflowNameByTarget.get(dependent.targetWorkflowId) ?? '',
blockId: dependent.targetBlockId,
blockLabel: dependent.blockName,
// Nested tool fields keep a plain `title` for the reconfigure UI; warnings need the
// tool disambiguator so two tools with the same field name stay distinguishable.
fieldLabel: dependent.toolName
? `${dependent.toolName}: ${dependent.title}`
: dependent.title,
kind: dependent.parentKind,
sourceId: dependent.parentSourceId,
sourceLabel: labelFor(dependent.parentKind, dependent.parentSourceId),
cause: 'dependent',
// The dependsOn parent (its KB/credential/table). The client drops this entry once the parent
// is mapped or copied ONLY when the child follows it (a document under a KB); a credential's
// label or a table's column is cleared on any parent remap, so it stays.
parentKind: dependent.parentKind,
parentSourceId: dependent.parentSourceId,
})
}
return out
}
/**
* Fill each `reference`-cause entry's `sourceDeleted` flag by checking whether its resource still
* exists (not deleted/archived) in the SOURCE workspace. Reuses {@link filterExistingForkTargets}
* - a per-kind, exact-id (cap-free) liveness check with the canonical archived/deleted filters -
* pointed at the source workspace instead of a target. One batched round per kind present; a
* no-op (zero queries) when no reference-cause entries exist. Files check by storage key, matching
* how `file` references are recorded.
*/
export async function annotateForkClearedRefSourceLiveness(
executor: DbOrTx,
sourceWorkspaceId: string,
clearedRefs: ForkClearedRef[]
): Promise<ForkClearedRef[]> {
const idsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const ref of clearedRefs) {
if (ref.cause !== 'reference') continue
;(idsByKind[ref.kind] ??= new Set()).add(ref.sourceId)
}
if (Object.keys(idsByKind).length === 0) return clearedRefs
const liveByKind = await filterExistingForkTargets(executor, sourceWorkspaceId, idsByKind)
return clearedRefs.map((ref) =>
ref.cause === 'reference'
? { ...ref, sourceDeleted: !(liveByKind[ref.kind]?.has(ref.sourceId) ?? false) }
: ref
)
}
/** Upper bound on the blockers a gate failure reports, so the error body stays sane. */
const FORK_SYNC_BLOCKER_LIMIT = 100
/**
* Cheap existence check for blocking gate candidates, reusing the plan's already-computed scan
* output instead of re-running the full per-block reference scan:
* - `reference` cause: the collector detects references with the same per-block scan
* ({@link remapForkSubBlocks}) over the same source states the plan already ran, so a
* candidate exists iff some plan-unmapped reference of a non-excluded kind still resolves to
* null through the gate resolver. The gate resolver only ADDS resolutions on top of the plan
* resolver (promote's copy-selection overlay), so filtering the plan's unmapped set through it
* yields exactly the gate's unmapped set. The plan's cascade-only additions (env-var /
* credential) are excluded kinds and never contribute.
* - `workflow` cause: cross-workflow refs are not part of the plan's scan, so walk the blocks
* with the (much lighter) workflow-reference detection only, against the same workflowIdMap
* predicate the collector applies.
* `dependent`-cause candidates never block (see {@link forkSyncBlockerReasonFor}), so they are
* not checked.
*/
function hasForkSyncBlockerCandidates(
planUnmapped: ReadonlyArray<Pick<ForkReference, 'kind' | 'sourceId'>>,
params: Pick<
CollectForkClearedRefsParams,
'items' | 'sourceStates' | 'resolver' | 'workflowIdMap'
>
): boolean {
const { items, sourceStates, resolver, workflowIdMap } = params
const hasReferenceCandidate = planUnmapped.some(
(reference) =>
!CLEARED_REF_EXCLUDED_KINDS.has(reference.kind) &&
resolver(reference.kind, reference.sourceId) == null
)
if (hasReferenceCandidate) return true
for (const item of items) {
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
for (const block of Object.values(state.blocks)) {
// double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally
// SubBlockRecord entries but lack the open index signature SubBlockRecord declares
const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const workflowRefs = collectForkWorkflowReferences(
subBlocks,
getBlock(block.type),
block.data?.canonicalModes
)
if (workflowRefs.some((ref) => !workflowIdMap.has(ref.workflowId))) return true
}
}
return false
}
/**
* The authoritative would-clear gate input for a promote: collect the cleared-ref candidates for
* the sync (against the caller's resolver, which must already account for the copy selection),
* keep the blocking causes (`reference` / `workflow` - dependents stay with the reconfigure
* flow), annotate source liveness, and return them as wire {@link ForkSyncBlocker}s with
* best-effort labels. The happy path (nothing would clear) costs ZERO queries - the collection is
* pure over the pre-read source states - and, when `planUnmapped` is supplied, ZERO re-scans of
* the blocks the plan already scanned; liveness + label reads (and the full candidate collection,
* for identical per-block/field blocker rows) run only when something blocks. Truncated to
* {@link FORK_SYNC_BLOCKER_LIMIT} entries.
*/
export async function collectForkSyncBlockers(
params: Omit<CollectForkClearedRefsParams, 'sourceLabels' | 'sourceWorkflowNames'> & {
executor: DbOrTx
sourceWorkspaceId: string
/**
* The plan's unmapped references (`unmappedRequired` + `unmappedOptional`), when the caller
* computed the plan over the SAME `items`/`sourceStates` inside the same transaction AND the
* gate `resolver` only augments the plan's resolver (never un-resolves a plan-mapped ref) -
* promote's copy-selection overlay satisfies both. Enables the happy-path shortcut via
* {@link hasForkSyncBlockerCandidates}: the full per-block reference scan the plan already
* ran is skipped when no blocking candidate can exist, and re-run (for byte-identical blocker
* rows) when one does. Omit to always collect from scratch.
*/
planUnmapped?: ReadonlyArray<Pick<ForkReference, 'kind' | 'sourceId'>>
}
): Promise<ForkSyncBlocker[]> {
const { executor, sourceWorkspaceId, planUnmapped, ...collectParams } = params
if (planUnmapped && !hasForkSyncBlockerCandidates(planUnmapped, collectParams)) return []
const candidates = collectForkClearedRefCandidates({
...collectParams,
sourceLabels: new Map(),
sourceWorkflowNames: new Map(),
})
if (!candidates.some((ref) => ref.cause === 'reference' || ref.cause === 'workflow')) return []
const annotated = await annotateForkClearedRefSourceLiveness(
executor,
sourceWorkspaceId,
candidates
)
const blocking = selectForkSyncBlockingRefs(annotated).slice(0, FORK_SYNC_BLOCKER_LIMIT)
if (blocking.length === 0) return []
// Best-effort display labels (failure path only). Copyable kinds go through the shared label
// loader (live rows only - a deleted source keeps its id label); MCP servers are read without
// the deleted filter so a source-deleted server still names itself; workflow names label the
// `workflow`-cause entries.
const copyableIdsByKind: Partial<Record<ForkCopyableKind, string[]>> = {}
const mcpIds: string[] = []
const workflowIds: string[] = []
for (const { ref } of blocking) {
if (ref.cause === 'workflow') workflowIds.push(ref.sourceId)
else if (ref.kind === 'mcp-server') mcpIds.push(ref.sourceId)
else if (isForkCopyableKind(ref.kind)) (copyableIdsByKind[ref.kind] ??= []).push(ref.sourceId)
}
const [copyableLabels, mcpRows, workflowRows] = await Promise.all([
loadForkCopyableResourceLabels(executor, sourceWorkspaceId, copyableIdsByKind),
mcpIds.length === 0
? Promise.resolve([] as Array<{ id: string; name: string }>)
: executor
.select({ id: mcpServers.id, name: mcpServers.name })
.from(mcpServers)
.where(
and(eq(mcpServers.workspaceId, sourceWorkspaceId), inArray(mcpServers.id, mcpIds))
),
workflowIds.length === 0
? Promise.resolve([] as Array<{ id: string; name: string }>)
: executor
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(
and(eq(workflow.workspaceId, sourceWorkspaceId), inArray(workflow.id, workflowIds))
),
])
const mcpNames = new Map(mcpRows.map((row) => [row.id, row.name]))
const workflowNames = new Map(workflowRows.map((row) => [row.id, row.name]))
const labelFor = (ref: ForkClearedRef): string => {
if (ref.cause === 'workflow') return workflowNames.get(ref.sourceId) ?? ref.sourceLabel
if (ref.kind === 'mcp-server') return mcpNames.get(ref.sourceId) ?? ref.sourceLabel
return copyableLabels.get(`${ref.kind}:${ref.sourceId}`)?.label ?? ref.sourceLabel
}
return toForkSyncBlockers(
blocking.map(({ ref, reason }) => ({ ref: { ...ref, sourceLabel: labelFor(ref) }, reason }))
)
}
@@ -0,0 +1,505 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
type ForkCopyableUnmapped,
forkCopyableKindSchema,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
const {
mockUpsertEdgeMappings,
mockDeleteEdgeMappingsByChildResources,
mockCopyForkResourceContainers,
mockPlanForkMappedKbDocumentCopies,
mockPlanForkFileCopies,
} = vi.hoisted(() => ({
mockUpsertEdgeMappings: vi.fn(),
mockDeleteEdgeMappingsByChildResources: vi.fn(),
mockCopyForkResourceContainers: vi.fn(),
mockPlanForkMappedKbDocumentCopies: vi.fn(),
mockPlanForkFileCopies: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
upsertEdgeMappings: mockUpsertEdgeMappings,
deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources,
resourceTypeToForkKind: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-resources', () => ({
copyForkResourceContainers: mockCopyForkResourceContainers,
planForkMappedKbDocumentCopies: mockPlanForkMappedKbDocumentCopies,
copyForkResourceContent: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
planForkFileCopies: mockPlanForkFileCopies,
executeForkFileBlobCopies: vi.fn(),
}))
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import type { ForkMappingUpsert } from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
augmentForkResolver,
buildPromoteCopySelection,
copyPromoteUnmappedResources,
FORK_COPYABLE_KIND_TO_SELECTION_KEY,
hasPromoteCopySelection,
persistPromoteCopiedMappings,
} from '@/ee/workspace-forking/lib/promote/copy-unmapped'
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
const candidates: ForkCopyableUnmapped[] = [
{
kind: 'knowledge-base',
sourceId: 'kb-1',
label: 'KB One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'table',
sourceId: 'tbl-1',
label: 'Table One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'custom-tool',
sourceId: 'ct-1',
label: 'Tool One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'skill',
sourceId: 'sk-1',
label: 'Skill One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'file',
sourceId: 'workspace/SRC/a.png',
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Images',
referenced: true,
},
// An UNREFERENCED candidate (new in the source, used by no synced workflow): selectable for
// copy exactly like a referenced one - the server treats the two identically.
{
kind: 'table',
sourceId: 'tbl-unref',
label: 'Scratch table',
parentId: null,
parentLabel: null,
referenced: false,
},
]
describe('buildPromoteCopySelection', () => {
it('groups requested ids into the selection by kind and records willResolve keys', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ knowledgeBases: ['kb-1'], tables: ['tbl-1'], customTools: ['ct-1'], skills: ['sk-1'] },
candidates
)
expect(selection.knowledgeBases).toEqual(['kb-1'])
expect(selection.tables).toEqual(['tbl-1'])
expect(selection.customTools).toEqual(['ct-1'])
expect(selection.skills).toEqual(['sk-1'])
expect(willResolve.has('knowledge-base:kb-1')).toBe(true)
expect(willResolve.has('skill:sk-1')).toBe(true)
})
it('ignores a requested id that is not an actual copy candidate (security)', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ knowledgeBases: ['kb-1', 'kb-not-a-candidate'] },
candidates
)
expect(selection.knowledgeBases).toEqual(['kb-1'])
expect(willResolve.has('knowledge-base:kb-not-a-candidate')).toBe(false)
})
it('groups requested file storage keys (security: only actual candidates)', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ files: ['workspace/SRC/a.png', 'workspace/SRC/not-referenced.png'] },
candidates
)
expect(selection.files).toEqual(['workspace/SRC/a.png'])
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(true)
expect(willResolve.has('file:workspace/SRC/not-referenced.png')).toBe(false)
})
it('returns an empty selection when nothing is requested', () => {
const { selection, willResolve } = buildPromoteCopySelection(undefined, candidates)
expect(hasPromoteCopySelection(selection)).toBe(false)
expect(willResolve.size).toBe(0)
})
it('accepts an UNREFERENCED candidate exactly like a referenced one', () => {
// The client keeps unreferenced candidates default-unselected, but once the user opts in the
// server validates + copies them through the same path. Its willResolve key matches no
// unmapped reference (nothing references it), so the pre-copy gate is unaffected.
const { selection, willResolve } = buildPromoteCopySelection(
{ tables: ['tbl-unref'] },
candidates
)
expect(selection.tables).toEqual(['tbl-unref'])
expect(willResolve.has('table:tbl-unref')).toBe(true)
})
it('copy-vs-map: maps win - a mapped resource is absent from the candidates, so a copy request for it is dropped', () => {
// Reconciliation precedence at the server boundary: a resource the user mapped resolves to a
// target, so the plan never lists it in `copyableUnmapped`. Even if a (stale) client still
// requests it for copy, only the genuinely-unmapped candidates survive - the map wins.
const onlyTableUnmapped: ForkCopyableUnmapped[] = [
{
kind: 'table',
sourceId: 'tbl-1',
label: 'Table One',
parentId: null,
parentLabel: null,
referenced: true,
},
]
const { selection, willResolve } = buildPromoteCopySelection(
// kb-1 + the file were mapped (so absent from candidates); only the table remains copyable.
{
knowledgeBases: ['kb-1'],
tables: ['tbl-1'],
files: ['workspace/SRC/a.png'],
},
onlyTableUnmapped
)
expect(selection.knowledgeBases).toEqual([])
expect(selection.files).toEqual([])
expect(selection.tables).toEqual(['tbl-1'])
expect(willResolve.has('knowledge-base:kb-1')).toBe(false)
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(false)
expect(willResolve.has('table:tbl-1')).toBe(true)
})
})
describe('hasPromoteCopySelection', () => {
it('is true only when at least one copyable kind has ids', () => {
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
mcpServers: [],
})
).toBe(true)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
mcpServers: [],
})
).toBe(false)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/file.png'],
mcpServers: [],
})
).toBe(true)
})
})
describe('augmentForkResolver', () => {
it('resolves a just-copied resource via the extra map, else falls through to the base', () => {
const base = (kind: ForkRemapKind, id: string) =>
kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null
const extra = new Map<ForkRemapKind, Map<string, string>>([
['knowledge-base', new Map([['kb-src', 'kb-dst']])],
])
const resolver = augmentForkResolver(base, extra)
expect(resolver('knowledge-base', 'kb-src')).toBe('kb-dst')
expect(resolver('credential', 'cred-src')).toBe('cred-dst')
expect(resolver('table', 'tbl-x')).toBeNull()
})
})
describe('persistPromoteCopiedMappings', () => {
const tx = {} as DbOrTx
const entry: ForkMappingUpsert = {
resourceType: 'knowledge_base',
parentResourceId: 'src-kb',
childResourceId: 'dst-kb',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('pull keeps the source(parent)->target(child) orientation as-is', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'pull', [entry])
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [entry])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
})
it('push swaps to target(parent)->source(child) and deletes the prior row keyed on the source child', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [entry])
// Delete keys on the source child resource (the swapped child id = the original parent id).
expect(mockDeleteEdgeMappingsByChildResources).toHaveBeenCalledWith(tx, 'edge-child', [
{ resourceType: 'knowledge_base', childResourceId: 'src-kb' },
])
// The swap flips parent/child: the new copy (dst) becomes the parent side on push.
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{ resourceType: 'knowledge_base', parentResourceId: 'dst-kb', childResourceId: 'src-kb' },
])
})
it('push skips an entry with a null child id (the narrowing guard, no bogus mapping)', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [
{ resourceType: 'knowledge_base', parentResourceId: 'src-kb', childResourceId: null },
])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
})
it('returns without writing when there are no entries', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
})
})
describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
const tx = {} as DbOrTx
// Only edge.childWorkspaceId is read by the copy path.
const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge
// The promote-built persisted-pair resolver; the copy must forward it verbatim so copied
// tables' workflow-group outputs land on the same block ids the workflow writes assign.
const resolveBlockId = (workflowId: string, blockId: string) => `${workflowId}:${blockId}`
beforeEach(() => {
vi.clearAllMocks()
mockCopyForkResourceContainers.mockResolvedValue({
idMap: new Map(),
mappingEntries: [],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'target-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockPlanForkMappedKbDocumentCopies.mockResolvedValue({
documents: [],
docIdMap: new Map(),
mappingEntries: [],
})
})
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']]),
idMap: new Map([['file-src', 'file-dst']]),
blobTasks: [
{
sourceKey: 'workspace/SRC/a.png',
targetKey: 'workspace/DST/a.png',
context: 'workspace',
fileName: 'a.png',
contentType: 'image/png',
userId: 'user-1',
workspaceId: 'target-ws',
},
],
})
const result = await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/a.png'],
},
workflowIdMap: new Map(),
folderIdMap: new Map([['fld-src', 'fld-dst']]),
resolver: () => null,
resolveBlockId,
referencedDocumentIds: [],
})
// planForkFileCopies is invoked by storage key (the sync references key files by key).
expect(mockPlanForkFileCopies).toHaveBeenCalledWith(
expect.objectContaining({ fileKeys: ['workspace/SRC/a.png'] })
)
// blobTasks bubble up for the post-commit blob duplication.
expect(result.blobTasks).toHaveLength(1)
// The copied file resolves by storage key for the subblock remap.
expect(result.copyIdMapByKind.get('file')).toEqual(
new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']])
)
// The file mapping is persisted (pull keeps source(parent)->target(child) orientation) so a
// re-sync resolves the copy instead of re-copying it.
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{
resourceType: 'file',
parentResourceId: 'workspace/SRC/a.png',
childResourceId: 'workspace/DST/a.png',
},
])
// The folder map AND the file key/id maps reach the in-content rewriter.
expect(result.contentRefMaps.folders).toEqual({ 'fld-src': 'fld-dst' })
expect(result.contentRefMaps.fileKeys).toEqual({ 'workspace/SRC/a.png': 'workspace/DST/a.png' })
expect(result.contentRefMaps.fileIds).toEqual({ 'file-src': 'file-dst' })
})
it('persists container mapping entries for copied resources (idempotency for unreferenced copies)', async () => {
// An UNREFERENCED table selected for copy flows through the same container pipeline; its
// 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']])]]),
mappingEntries: [
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'target-ws',
userId: 'user-1',
tables: [{ sourceId: 'tbl-unref', childId: 'tbl-copy' }],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: ['Scratch table'],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map<string, string>(),
idMap: new Map<string, string>(),
blobTasks: [],
})
await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: ['tbl-unref'],
knowledgeBases: [],
files: [],
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
resolver: () => null,
resolveBlockId,
referencedDocumentIds: [],
})
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
])
})
it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => {
await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
mcpServers: [],
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
resolver: () => null,
resolveBlockId,
// The doc ids come straight from the promote plan's references; the copy must forward them,
// not re-scan every source workflow state inside the locked tx.
referencedDocumentIds: ['doc-1', 'doc-2'],
})
expect(mockCopyForkResourceContainers).toHaveBeenCalledWith(
expect.objectContaining({
referencedDocumentIds: ['doc-1', 'doc-2'],
// Workflow-publishing MCP servers are fork-create-only; a sync always passes the
// shared pipeline's slot empty. External MCP servers flow through the selection.
selection: expect.objectContaining({ mcpServers: [], workflowMcpServers: [] }),
// The promote-built block-id resolver reaches the table remap unchanged, so copied
// tables' workflow-group outputs use the persisted-pair ids, not the derive.
resolveBlockId,
})
)
expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith(
expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] })
)
})
})
describe('fork copyable kind drift', () => {
it('FORK_COPYABLE_KIND_TO_SELECTION_KEY covers exactly the contract copyable kinds', () => {
expect(Object.keys(FORK_COPYABLE_KIND_TO_SELECTION_KEY).sort()).toEqual(
[...forkCopyableKindSchema.options].sort()
)
})
it('isForkCopyableKind matches the contract copyable kinds and excludes the rest', () => {
for (const kind of forkCopyableKindSchema.options) {
expect(isForkCopyableKind(kind)).toBe(true)
}
const nonCopyable: ForkRemapKind[] = ['credential', 'env-var', 'knowledge-document']
for (const kind of nonCopyable) {
expect(isForkCopyableKind(kind)).toBe(false)
}
})
})
@@ -0,0 +1,356 @@
import type {
ForkCopyableKind,
ForkCopyableUnmapped,
PromoteCopyResources,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
type SerializableForkContentRefMaps,
serializeContentRefMaps,
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
import { type BlobCopyTask, planForkFileCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources'
import {
copyForkResourceContainers,
planForkMappedKbDocumentCopies,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import {
deleteEdgeMappingsByChildResources,
type ForkMappingUpsert,
resourceTypeToForkKind,
upsertEdgeMappings,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import type {
ForkReferenceResolver,
ForkRemapKind,
} from '@/ee/workspace-forking/lib/remap/remap-references'
/**
* The source ids selected for copy at promote, validated against the plan's copyable
* candidates. Exactly the sync-copyable kinds (`forkCopyableKindSchema`): workflow-publishing
* MCP servers are fork-create-only (never a promote copy candidate), so they have no slot here.
*/
export interface PromoteCopySelection {
customTools: string[]
skills: string[]
tables: string[]
knowledgeBases: string[]
/** Workspace files to copy, identified by storage key (not `workspace_files.id`). */
files: string[]
/** External MCP servers to copy as config rows (OAuth tokens never copied - re-auth). */
mcpServers: string[]
}
/**
* Each copyable kind to its key in {@link PromoteCopySelection}. Keyed on `ForkCopyableKind`
* (the wire contract enum) so TS fails to compile if the copyable enum grows a kind without a
* selection key here, keeping the two in lockstep.
*/
export const FORK_COPYABLE_KIND_TO_SELECTION_KEY: Record<
ForkCopyableKind,
keyof PromoteCopySelection
> = {
'knowledge-base': 'knowledgeBases',
table: 'tables',
'custom-tool': 'customTools',
skill: 'skills',
file: 'files',
'mcp-server': 'mcpServers',
}
/**
* Intersect the user's requested copy with the plan's actual copyable candidates (referenced or
* not, always unmapped + still existing in the source), so a crafted request can never copy an
* arbitrary resource. Returns the validated selection plus the set of `${kind}:${sourceId}`
* references the copy will resolve, for the pre-copy sync gate - an unreferenced candidate's key
* simply matches no reference there, which is harmless.
*/
export function buildPromoteCopySelection(
requested: PromoteCopyResources | undefined,
copyableUnmapped: ForkCopyableUnmapped[]
): { selection: PromoteCopySelection; willResolve: Set<string> } {
const allowed = new Map<string, Set<string>>()
for (const candidate of copyableUnmapped) {
const set = allowed.get(candidate.kind)
if (set) set.add(candidate.sourceId)
else allowed.set(candidate.kind, new Set([candidate.sourceId]))
}
const selection: PromoteCopySelection = {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
mcpServers: [],
}
const willResolve = new Set<string>()
const apply = (
kind: keyof typeof FORK_COPYABLE_KIND_TO_SELECTION_KEY,
ids: string[] | undefined
) => {
const allowedIds = allowed.get(kind)
if (!allowedIds || !ids) return
const key = FORK_COPYABLE_KIND_TO_SELECTION_KEY[kind]
for (const id of ids) {
if (!allowedIds.has(id)) continue
selection[key].push(id)
willResolve.add(`${kind}:${id}`)
}
}
apply('knowledge-base', requested?.knowledgeBases)
apply('table', requested?.tables)
apply('custom-tool', requested?.customTools)
apply('skill', requested?.skills)
apply('file', requested?.files)
apply('mcp-server', requested?.mcpServers)
return { selection, willResolve }
}
/** Whether any resource is selected for copy. */
export function hasPromoteCopySelection(selection: PromoteCopySelection): boolean {
return (
selection.customTools.length > 0 ||
selection.skills.length > 0 ||
selection.tables.length > 0 ||
selection.knowledgeBases.length > 0 ||
selection.files.length > 0 ||
selection.mcpServers.length > 0
)
}
/**
* Layer the just-copied resources' source->target ids on top of the plan's resolver, so the
* synced workflows' references to those resources resolve to the new copies. The base resolver
* (persisted mappings + env identity) is consulted for everything else.
*/
export function augmentForkResolver(
base: ForkReferenceResolver,
extra: Map<ForkRemapKind, Map<string, string>>
): ForkReferenceResolver {
return (kind, sourceId) => extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
}
export interface PromoteCopyResult {
contentPlan: ForkContentPlan
/** Copied source resource id -> new target id, by remap kind, for resolver augmentation. */
copyIdMapByKind: Map<ForkRemapKind, Map<string, string>>
/**
* Serialized in-content reference maps for the post-commit copy to rewrite copied skill bodies
* (their `sim:` links + embedded URLs) off the locked promote tx - carried through the durable
* content-copy payload, mirroring fork.
*/
contentRefMaps: SerializableForkContentRefMaps
/**
* File blob duplications for copied workspace files, run post-commit by the durable content-copy
* runner (no object-storage I/O inside the locked promote tx). Empty when no files were copied.
*/
blobTasks: BlobCopyTask[]
}
/**
* Copy the selected unmapped resources (referenced or not) a sync brings into the target (reusing
* the fork copy pipeline), then persist the source<->target id map in the direction the edge expects: a pull
* fills the existing `(parent, child=null)` row (fill-null), a push replaces any prior
* `(parent, child)` row keyed on the source child resource (delete-then-insert). This covers:
* - the user-selected copyable containers (KB / table / custom-tool / skill) and workspace files,
* - documents referenced under a copied knowledge base (auto-placed under that copied KB),
* - documents referenced under an ALREADY-mapped (existing) KB - copied into that existing KB so
* the `document-selector` reference remaps instead of being cleared.
*
* The heavy content (table rows, KB documents + embeddings, file blobs) is returned as a content
* plan + blob tasks for a post-commit, best-effort fill; no object-storage I/O runs inside the
* locked promote tx. Always safe to call (a no-op when nothing is selected and nothing references
* a mapped-KB document).
*/
export async function copyPromoteUnmappedResources(params: {
tx: DbOrTx
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
userId: string
now: Date
selection: PromoteCopySelection
workflowIdMap: Map<string, string>
/** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/<id>`. */
folderIdMap: Map<string, string>
/** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */
resolver: ForkReferenceResolver
/**
* The SAME block-id resolver the sync's workflow writes use (persisted pairs preferred over
* derive), so copied tables' workflow-group `outputs[].blockId` point at the blocks the sync
* actually writes - on push the parent keeps its ORIGINAL block ids, never the derive.
*/
resolveBlockId: ForkBlockIdResolver
/**
* Knowledge-document ids the synced workflows reference, already scanned once in the promote
* plan and threaded in so the copy doesn't re-scan every source state inside the locked tx.
* `copyForkResourceContainers` / `planForkMappedKbDocumentCopies` place only those whose parent
* KB is in this copy (or already mapped), so an extra id is FK-safe and simply skipped.
*/
referencedDocumentIds: string[]
}): Promise<PromoteCopyResult> {
const {
tx,
edge,
sourceWorkspaceId,
targetWorkspaceId,
direction,
userId,
now,
selection,
workflowIdMap,
folderIdMap,
resolver,
resolveBlockId,
referencedDocumentIds,
} = params
const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId,
childWorkspaceId: targetWorkspaceId,
userId,
now,
selection: {
customTools: selection.customTools,
skills: selection.skills,
// External MCP servers copy as config rows (like fork); workflow-publishing MCP servers
// are fork-create-only shells (the shared pipeline still takes the slot - pass it empty).
mcpServers: selection.mcpServers,
workflowMcpServers: [],
tables: selection.tables,
knowledgeBases: selection.knowledgeBases,
},
workflowIdMap,
referencedDocumentIds,
// A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs
// rewritten through the same plan resolver that remaps subblock-value env refs.
resolveEnvName: (key) => resolver('env-var', key),
resolveBlockId,
})
// Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob
// duplications deferred to the post-commit runner.
const fileResult =
selection.files.length > 0
? await planForkFileCopies({
tx,
sourceWorkspaceId,
childWorkspaceId: targetWorkspaceId,
userId,
fileKeys: selection.files,
now,
})
: {
keyMap: new Map<string, string>(),
idMap: new Map<string, string>(),
blobTasks: [] as BlobCopyTask[],
}
// U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc
// already placed under a copied KB above (its parent KB is in this copy), so a doc is never
// copied twice.
const containerDocMap = result.idMap.get('knowledge_document') ?? new Map<string, string>()
const mappedKbDocs = await planForkMappedKbDocumentCopies({
tx,
resolver,
referencedDocumentIds,
alreadyCopiedSourceDocIds: new Set(containerDocMap.keys()),
})
result.contentPlan.documents.push(...mappedKbDocs.documents)
// Persist every copied resource's mapping (containers + files + U-docs) so a re-sync resolves
// the copy instead of re-copying it. Files map by storage key; U-docs add knowledge_document rows.
const fileMappingEntries: ForkMappingUpsert[] = Array.from(
fileResult.keyMap,
([source, child]) => ({
resourceType: 'file' as const,
parentResourceId: source,
childResourceId: child,
})
)
await persistPromoteCopiedMappings(tx, edge.childWorkspaceId, userId, direction, [
...result.mappingEntries,
...fileMappingEntries,
...mappedKbDocs.mappingEntries,
])
const copyIdMapByKind = new Map<ForkRemapKind, Map<string, string>>()
for (const [resourceType, sourceToTarget] of result.idMap) {
const kind = resourceTypeToForkKind(resourceType)
if (!kind) continue
copyIdMapByKind.set(kind, sourceToTarget)
}
if (fileResult.keyMap.size > 0) copyIdMapByKind.set('file', fileResult.keyMap)
// Merge the container's copied-KB document map with the U-docs map so every copied document
// (under a copied KB or into an existing one) remaps its `document-selector` reference.
const documentIdMap = new Map<string, string>([...containerDocMap, ...mappedKbDocs.docIdMap])
if (documentIdMap.size > 0) copyIdMapByKind.set('knowledge-document', documentIdMap)
// Serialized maps for the post-commit content rewrite (run off the locked promote tx). Mirrors
// fork: workspace + workflow + folder ids plus this copy's own file/skill/table/KB maps, so a
// copied skill body / markdown blob's `sim:` links + embedded file URLs resolve to the new target
// copies instead of the source.
const contentRefMaps = serializeContentRefMaps({
workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId },
workflows: workflowIdMap,
folders: folderIdMap,
fileKeys: fileResult.keyMap,
fileIds: fileResult.idMap,
skills: result.idMap.get('skill'),
tables: result.idMap.get('table'),
knowledgeBases: result.idMap.get('knowledge_base'),
})
return {
contentPlan: result.contentPlan,
copyIdMapByKind,
contentRefMaps,
blobTasks: fileResult.blobTasks,
}
}
/**
* Persist the copied resources' id mappings for the edge. The copy returns entries oriented
* source(parent)->target(child); a pull matches that orientation directly (fill-null upsert), a
* push swaps it (the parent side is the new TARGET) and first drops any prior row keyed on the
* source child resource so a changed target can't leak a second mapping.
*/
export async function persistPromoteCopiedMappings(
tx: DbOrTx,
childWorkspaceId: string,
userId: string,
direction: 'push' | 'pull',
entries: ForkMappingUpsert[]
): Promise<void> {
if (entries.length === 0) return
if (direction === 'pull') {
await upsertEdgeMappings(tx, childWorkspaceId, userId, entries)
return
}
// Push: re-key on the source child resource. Skip any entry with a null child id (copy entries
// always carry one; the guard narrows the type so neither the swap nor the delete needs a cast).
// After the swap every childResourceId is the original (non-null) parent id, keyed for the
// delete-then-insert that prevents a changed target from leaking a second mapping.
const swapped: ForkMappingUpsert[] = []
const deleteKeys: Array<{
resourceType: ForkMappingUpsert['resourceType']
childResourceId: string
}> = []
for (const entry of entries) {
if (entry.childResourceId == null) continue
swapped.push({
resourceType: entry.resourceType,
parentResourceId: entry.childResourceId,
childResourceId: entry.parentResourceId,
})
deleteKeys.push({ resourceType: entry.resourceType, childResourceId: entry.parentResourceId })
}
if (swapped.length === 0) return
await deleteEdgeMappingsByChildResources(tx, childWorkspaceId, deleteKeys)
await upsertEdgeMappings(tx, childWorkspaceId, userId, swapped)
}
@@ -0,0 +1,256 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type {
ForkCopyableLabel,
ForkCopyableSourceResource,
} from '@/ee/workspace-forking/lib/mapping/resources'
import {
assembleForkCopyableUnmapped,
buildPromoteWorkflowIdMap,
collectForkCopyableIdsByKind,
collectForkUnreferencedCopyables,
} from '@/ee/workspace-forking/lib/promote/promote-plan'
import type { ForkReference } from '@/ee/workspace-forking/lib/remap/remap-references'
const ref = (kind: ForkReference['kind'], sourceId: string): ForkReference => ({
kind,
sourceId,
subBlockKey: 'sb',
required: false,
})
/**
* `buildPromoteWorkflowIdMap` decides which cross-workflow references survive a
* promote: the resulting map is handed to `remapWorkflowReferencesInSubBlocks`,
* where a hit repoints the reference and a miss (with `clearUnmapped`) blanks it.
* These cases lock in the seed/overlay matrix so the "mapped sibling not in this
* push" repoint and the "deleted / archived / never-mapped" clears can't drift.
*/
describe('buildPromoteWorkflowIdMap', () => {
it("overlays this push's items (replace + create)", () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map(),
existingSourceIds: new Set(),
targetActiveIds: new Set(),
items: [
{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' },
{ sourceWorkflowId: 'b-src', targetWorkflowId: 'b-new' },
],
})
expect(map.get('a-src')).toBe('a-tgt')
expect(map.get('b-src')).toBe('b-new')
expect(map.size).toBe(2)
})
it('repoints a mapped sibling that is not in this push when source exists and target is active', () => {
// B is mapped + still deployed in the target but undeployed in the source, so it
// is not an item this push. A references B and must keep pointing at target-B.
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(['b-src']),
targetActiveIds: new Set(['b-tgt']),
items: [{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' }],
})
expect(map.get('b-src')).toBe('b-tgt')
expect(map.get('a-src')).toBe('a-tgt')
})
it('does not seed a mapped pair whose source was deleted (reference clears)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(), // b-src deleted in the source
targetActiveIds: new Set(['b-tgt']),
items: [],
})
expect(map.has('b-src')).toBe(false)
})
it('does not seed a mapped pair whose target was archived (reference clears)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(['b-src']),
targetActiveIds: new Set(), // b-tgt archived by a prior push
items: [],
})
expect(map.has('b-src')).toBe(false)
})
it('does not map a workflow that was never mapped (reference clears)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['b-src', 'b-tgt']]),
existingSourceIds: new Set(['b-src', 'c-src']),
targetActiveIds: new Set(['b-tgt']),
items: [],
})
expect(map.has('c-src')).toBe(false)
})
it('lets this push override a stale identity mapping (re-created target wins)', () => {
const map = buildPromoteWorkflowIdMap({
identityMap: new Map([['s', 't-old']]),
existingSourceIds: new Set(['s']),
targetActiveIds: new Set(['t-old']),
items: [{ sourceWorkflowId: 's', targetWorkflowId: 't-new' }],
})
expect(map.get('s')).toBe('t-new')
})
})
describe('collectForkCopyableIdsByKind', () => {
it('groups copyable kinds and ignores non-copyable kinds (credential / env-var)', () => {
const byKind = collectForkCopyableIdsByKind([
ref('knowledge-base', 'kb-1'),
ref('knowledge-base', 'kb-2'),
ref('table', 'tbl-1'),
ref('credential', 'cred-1'),
ref('env-var', 'API_KEY'),
ref('custom-tool', 'ct-1'),
ref('skill', 'sk-1'),
ref('file', 'fk-1'),
])
expect(byKind).toEqual({
'knowledge-base': ['kb-1', 'kb-2'],
table: ['tbl-1'],
'custom-tool': ['ct-1'],
skill: ['sk-1'],
file: ['fk-1'],
})
})
})
describe('assembleForkCopyableUnmapped', () => {
const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null })
it('emits a candidate per copyable ref whose label resolved, carrying its labels', () => {
const labels = new Map<string, ForkCopyableLabel>([
['knowledge-base:kb-1', flat('Docs KB')],
['file:fk-1', { label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' }],
])
const result = assembleForkCopyableUnmapped(
[ref('knowledge-base', 'kb-1'), ref('file', 'fk-1'), ref('credential', 'cred-1')],
labels
)
expect(result).toEqual([
{
kind: 'knowledge-base',
sourceId: 'kb-1',
label: 'Docs KB',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'file',
sourceId: 'fk-1',
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Folder',
referenced: true,
},
])
})
it('drops a copyable ref whose label is missing (no longer exists in the source)', () => {
expect(assembleForkCopyableUnmapped([ref('knowledge-base', 'kb-gone')], new Map())).toEqual([])
})
it('ignores non-copyable kinds entirely (credential / env-var)', () => {
const result = assembleForkCopyableUnmapped(
[ref('credential', 'cred-1'), ref('env-var', 'API_KEY')],
new Map([['credential:cred-1', flat('X')]])
)
expect(result).toEqual([])
})
})
describe('collectForkUnreferencedCopyables', () => {
const source = (
kind: ForkCopyableSourceResource['kind'],
sourceId: string,
label = sourceId
): ForkCopyableSourceResource => ({ kind, sourceId, label, parentId: null, parentLabel: null })
const referencedCandidate = (kind: ForkCopyableSourceResource['kind'], sourceId: string) => ({
kind,
sourceId,
label: sourceId,
parentId: null,
parentLabel: null,
referenced: true,
})
it('emits an unmapped source resource no synced workflow references, flagged referenced: false', () => {
const result = collectForkUnreferencedCopyables(
[source('table', 'tbl-new', 'Scratch table')],
[],
() => null
)
expect(result).toEqual([
{
kind: 'table',
sourceId: 'tbl-new',
label: 'Scratch table',
parentId: null,
parentLabel: null,
referenced: false,
},
])
})
it('dedupes against the referenced candidate set (a referenced resource is never double-listed)', () => {
const result = collectForkUnreferencedCopyables(
[source('knowledge-base', 'kb-1'), source('knowledge-base', 'kb-new')],
[referencedCandidate('knowledge-base', 'kb-1')],
() => null
)
expect(result.map((candidate) => candidate.sourceId)).toEqual(['kb-new'])
})
it('excludes a resource with a persisted mapping (idempotency: a prior copy is never re-offered)', () => {
// A resource copied by a prior sync resolves through its workspace_fork_resource_map row.
const result = collectForkUnreferencedCopyables(
[source('skill', 'sk-copied'), source('skill', 'sk-new')],
[],
(kind, sourceId) => (kind === 'skill' && sourceId === 'sk-copied' ? 'sk-target' : null)
)
expect(result.map((candidate) => candidate.sourceId)).toEqual(['sk-new'])
})
it('does not confuse the same id across kinds when deduping or resolving', () => {
const result = collectForkUnreferencedCopyables(
[source('table', 'shared-id'), source('skill', 'shared-id')],
[referencedCandidate('table', 'shared-id')],
() => null
)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ kind: 'skill', sourceId: 'shared-id', referenced: false })
})
it('carries a file candidate keyed by storage key with its folder grouping', () => {
const result = collectForkUnreferencedCopyables(
[
{
kind: 'file',
sourceId: 'workspace/SRC/new.png',
label: 'new.png',
parentId: 'fld-1',
parentLabel: 'Images',
},
],
[],
() => null
)
expect(result).toEqual([
{
kind: 'file',
sourceId: 'workspace/SRC/new.png',
label: 'new.png',
parentId: 'fld-1',
parentLabel: 'Images',
referenced: false,
},
])
})
})
@@ -0,0 +1,399 @@
import { workflow } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { type ForkCopyableKind, forkCopyableKindSchema } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
import {
buildForkResolver,
getEdgeMappingRows,
resourceTypeToForkKind,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
type ForkCopyableLabel,
type ForkCopyableSourceResource,
filterExistingForkTargets,
getWorkspaceEnvKeys,
listForkCopyableSourceResources,
loadForkCopyableResourceLabels,
} from '@/ee/workspace-forking/lib/mapping/resources'
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
import {
type ForkReference,
type ForkReferenceResolver,
type ForkRemapKind,
scanWorkflowReferences,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
export interface ForkPromotePlanItem {
sourceWorkflowId: string
targetWorkflowId: string
/** The matched target workflow's current name (for rename-aware mapping), null when creating. */
targetName: string | null
mode: 'create' | 'replace'
sourceMeta: {
name: string
description: string | null
folderId: string | null
sortOrder: number
/** Source's public-API flag, carried onto the written target (see copyWorkflowStateIntoTarget). */
isPublicApi: boolean
}
}
export interface ForkPromotePlan {
childWorkspaceId: string
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
resolver: ForkReferenceResolver
items: ForkPromotePlanItem[]
workflowIdMap: Map<string, string>
/** Previously-mapped target workflows whose source no longer exists (to remove). */
archivedTargetIds: string[]
/** Same as `archivedTargetIds`, with the target workflow name for the preview. */
archivedTargets: Array<{ id: string; name: string }>
references: ForkReference[]
unmappedRequired: ForkReference[]
unmappedOptional: ForkReference[]
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
mcpReauthServerIds: string[]
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
inlineSecretSources: string[]
/**
* Unmapped resources of copyable kinds that still exist in the source, so a sync can copy
* them into the target instead of requiring a manual mapping (U15). `referenced: true`
* entries are referenced by the synced workflows (default-selected in the modal - skipping
* one clears its references); `referenced: false` entries are used by no synced workflow
* (default-unselected - skipping one breaks nothing). Documents are auto-copied with their
* parent KB and are not listed here. `parentId`/`parentLabel` carry a file's folder grouping
* (null for non-file kinds and root files), for the nested picker.
*/
copyableUnmapped: Array<{
kind: ForkCopyableKind
sourceId: string
label: string
parentId: string | null
parentLabel: string | null
referenced: boolean
}>
willUpdate: number
willCreate: number
willArchive: number
}
/**
* Copyable promote kinds, derived from the wire contract (`forkCopyableKindSchema`) so this
* guard can never drift from the single source of truth: growing the schema automatically
* grows the set. Typed as `ForkRemapKind` so `.has` accepts a broad scan-reference kind.
*/
const COPYABLE_PROMOTE_KINDS = new Set<ForkRemapKind>(forkCopyableKindSchema.options)
export function isForkCopyableKind(kind: ForkRemapKind): kind is ForkCopyableKind {
return COPYABLE_PROMOTE_KINDS.has(kind)
}
/**
* Build the cross-workflow reference map used to rewrite `workflow-selector`,
* `manualWorkflowId`, and `workflow_input` references inside promoted workflows.
*
* Seeded from the persistent identity mappings - not just the workflows in THIS
* push - so a reference to a mapped sibling that isn't part of the current push
* (e.g. a workflow undeployed in the source but still existing and already
* deployed in the target) repoints at the existing target instead of clearing.
* Only pairs whose source still EXISTS and whose target is still ACTIVE are
* seeded: a deleted source (whose target is archived this push) stays unmapped so
* its inbound references clear, and a target archived by a prior push is never
* re-pointed at. The push's own items are overlaid last, so a created workflow
* contributes its fresh target id and a replaced one re-sets the same id.
*/
export function buildPromoteWorkflowIdMap(params: {
identityMap: Map<string, string>
existingSourceIds: Set<string>
targetActiveIds: Set<string>
items: Array<{ sourceWorkflowId: string; targetWorkflowId: string }>
}): Map<string, string> {
const { identityMap, existingSourceIds, targetActiveIds, items } = params
const workflowIdMap = new Map<string, string>()
for (const [sourceId, targetId] of identityMap) {
if (existingSourceIds.has(sourceId) && targetActiveIds.has(targetId)) {
workflowIdMap.set(sourceId, targetId)
}
}
for (const item of items) workflowIdMap.set(item.sourceWorkflowId, item.targetWorkflowId)
return workflowIdMap
}
/**
* Collect the source ids of referenced-but-unmapped copyable resources, grouped by kind - the input
* to the source-label lookup that builds {@link ForkPromotePlan.copyableUnmapped}. Pure.
*/
export function collectForkCopyableIdsByKind(
unmappedReferences: ForkReference[]
): Partial<Record<ForkCopyableKind, string[]>> {
const byKind: Partial<Record<ForkCopyableKind, string[]>> = {}
for (const reference of unmappedReferences) {
if (!isForkCopyableKind(reference.kind)) continue
;(byKind[reference.kind] ??= []).push(reference.sourceId)
}
return byKind
}
/**
* Assemble the REFERENCED slice of {@link ForkPromotePlan.copyableUnmapped} from the unmapped
* references and the loaded source labels: each copyable reference whose label resolved becomes a
* copy candidate; one whose label is missing (the resource no longer exists in the source) is
* dropped. Pure - split from the DB label load so it is unit-testable.
*/
export function assembleForkCopyableUnmapped(
unmappedReferences: ForkReference[],
copyableLabels: Map<string, ForkCopyableLabel>
): ForkPromotePlan['copyableUnmapped'] {
return unmappedReferences.flatMap((reference) => {
if (!isForkCopyableKind(reference.kind)) return []
const entry = copyableLabels.get(`${reference.kind}:${reference.sourceId}`)
return entry
? [
{
kind: reference.kind,
sourceId: reference.sourceId,
label: entry.label,
parentId: entry.parentId,
parentLabel: entry.parentLabel,
referenced: true,
},
]
: []
})
}
/**
* Assemble the UNREFERENCED slice of {@link ForkPromotePlan.copyableUnmapped}: every copyable
* resource in the source workspace that no synced workflow references (not in the referenced
* candidate set) and that has no target mapping for this edge (the resolver returns null). A
* previously-copied resource resolves through its persisted `workspace_fork_resource_map` row,
* so a re-sync never re-offers it (idempotency). Pure - split from the DB source listing so it
* is unit-testable.
*/
export function collectForkUnreferencedCopyables(
sourceResources: ForkCopyableSourceResource[],
referencedCopyables: ForkPromotePlan['copyableUnmapped'],
resolver: ForkReferenceResolver
): ForkPromotePlan['copyableUnmapped'] {
const referencedKeys = new Set(
referencedCopyables.map((candidate) => `${candidate.kind}:${candidate.sourceId}`)
)
return sourceResources.flatMap((resource) => {
if (referencedKeys.has(`${resource.kind}:${resource.sourceId}`)) return []
if (resolver(resource.kind, resource.sourceId) != null) return []
return [{ ...resource, referenced: false }]
})
}
/**
* Compute everything a promote needs without mutating. Only the source's
* **deployed** workflows participate; each plan item carries the source's active
* deployed state. Targets matched by the persisted workflow identity map are
* replaced; unmatched deployed sources create new targets. A target is archived
* only when it was previously mapped and its source is no longer deployed -
* target-native workflows are never touched. Shared by the diff preview and the
* promote orchestrator.
*/
export async function computeForkPromotePlan(params: {
executor: DbOrTx
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
/**
* Source deployed workflows + their states, read by the caller BEFORE its
* transaction (see `loadSourceDeployedStates`) so the plan never checks out a
* second pooled connection from inside a tx.
*/
deployedSourceWorkflows: DeployedWorkflowSummary[]
sourceStates: Map<string, WorkflowState>
}): Promise<ForkPromotePlan> {
const {
executor,
edge,
sourceWorkspaceId,
targetWorkspaceId,
direction,
deployedSourceWorkflows,
sourceStates,
} = params
const mappingRows = await getEdgeMappingRows(executor, edge.childWorkspaceId)
const [targetEnvKeys, sourceEnvKeys] = await Promise.all([
getWorkspaceEnvKeys(executor, targetWorkspaceId),
getWorkspaceEnvKeys(executor, sourceWorkspaceId),
])
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
// Collect each mapping's chosen target id (per kind) and keep only those that still
// exist in the target workspace, so a target deleted after the mapping was saved
// resolves as unmapped instead of writing a dead id into the promoted workflow.
const mappedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const row of mappingRows) {
const kind = resourceTypeToForkKind(row.resourceType)
if (!kind) continue
const targetId = sourceIsParent ? row.childResourceId : row.parentResourceId
if (targetId == null) continue
const set = mappedTargetIdsByKind[kind] ?? new Set<string>()
set.add(targetId)
mappedTargetIdsByKind[kind] = set
}
const validTargetIdsByKind = await filterExistingForkTargets(
executor,
targetWorkspaceId,
mappedTargetIdsByKind
)
const resolver = buildForkResolver(mappingRows, {
sourceIsParent,
targetEnvKeys,
sourceEnvKeys,
validTargetIdsByKind,
})
const identityMap = new Map<string, string>()
for (const row of mappingRows) {
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
if (sourceIsParent) identityMap.set(row.parentResourceId, row.childResourceId)
else identityMap.set(row.childResourceId, row.parentResourceId)
}
const [targetWorkflows, sourceWorkflowRows] = await Promise.all([
executor
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), isNull(workflow.archivedAt))),
executor
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, sourceWorkspaceId), isNull(workflow.archivedAt))),
])
const targetActiveIds = new Set(targetWorkflows.map((w) => w.id))
const targetNameById = new Map(targetWorkflows.map((w) => [w.id, w.name]))
// Every source workflow that still EXISTS (deployed or not). A mapped target is
// archived only when its source was DELETED - not merely undeployed. A fresh fork
// leaves the child's workflows undeployed, so pushing back must not archive the
// parent's originals; undeployed sources are simply skipped (target left as-is).
const existingSourceIds = new Set(sourceWorkflowRows.map((w) => w.id))
// Build the items and scan references in one pass from the pre-read source states
// (loaded before the caller's transaction; see loadSourceDeployedStates).
const items: ForkPromotePlanItem[] = []
const referenceByKey = new Map<string, ForkReference>()
for (const source of deployedSourceWorkflows) {
const sourceState = sourceStates.get(source.id)
if (!sourceState) continue
const mappedTargetId = identityMap.get(source.id)
const isReplace = Boolean(mappedTargetId && targetActiveIds.has(mappedTargetId))
const targetWorkflowId = isReplace ? (mappedTargetId as string) : generateId()
items.push({
sourceWorkflowId: source.id,
targetWorkflowId,
targetName: isReplace ? (targetNameById.get(targetWorkflowId) ?? null) : null,
mode: isReplace ? 'replace' : 'create',
sourceMeta: {
name: source.name,
description: source.description,
folderId: source.folderId,
sortOrder: source.sortOrder,
isPublicApi: source.isPublicApi,
},
})
for (const reference of scanWorkflowReferences(toScannerBlocks(sourceState), resolver)
.references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
}
const workflowIdMap = buildPromoteWorkflowIdMap({
identityMap,
existingSourceIds,
targetActiveIds,
items,
})
const writtenTargetIds = new Set(items.map((item) => item.targetWorkflowId))
const archivedTargetIds: string[] = []
for (const row of mappingRows) {
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
const mappedSourceId = sourceIsParent ? row.parentResourceId : row.childResourceId
const mappedTargetId = sourceIsParent ? row.childResourceId : row.parentResourceId
if (existingSourceIds.has(mappedSourceId)) continue
if (writtenTargetIds.has(mappedTargetId)) continue
if (targetActiveIds.has(mappedTargetId)) archivedTargetIds.push(mappedTargetId)
}
const archivedTargets = archivedTargetIds.map((id) => ({
id,
name: targetNameById.get(id) ?? id,
}))
const cascade = await detectForkCascadeReferences({
executor,
sourceWorkspaceId,
references: Array.from(referenceByKey.values()),
resolve: resolver,
})
for (const reference of cascade.references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
const allReferences = Array.from(referenceByKey.values())
const allUnmapped = allReferences.filter(
(reference) => resolver(reference.kind, reference.sourceId) == null
)
const unmappedRequired = allUnmapped.filter((reference) => reference.required)
const unmappedOptional = allUnmapped.filter((reference) => !reference.required)
// Referenced-but-unmapped resources of copyable kinds that still exist in the source, so the
// sync modal can offer to copy them into the target (fork-style) instead of mapping by hand.
const copyableLabels = await loadForkCopyableResourceLabels(
executor,
sourceWorkspaceId,
collectForkCopyableIdsByKind(allUnmapped)
)
const referencedCopyables = assembleForkCopyableUnmapped(allUnmapped, copyableLabels)
// Also offer the source's UNREFERENCED copyable resources with no target mapping (e.g. newly
// created since the fork), default-unselected in the modal. Mapped ones (including everything
// a prior sync copied) resolve non-null and drop out, so a re-sync never re-offers a copy.
const sourceCopyables = await listForkCopyableSourceResources(executor, sourceWorkspaceId)
const copyableUnmapped = [
...referencedCopyables,
...collectForkUnreferencedCopyables(sourceCopyables, referencedCopyables, resolver),
]
const willUpdate = items.filter((i) => i.mode === 'replace').length
const willCreate = items.filter((i) => i.mode === 'create').length
return {
childWorkspaceId: edge.childWorkspaceId,
sourceWorkspaceId,
targetWorkspaceId,
direction,
resolver,
items,
workflowIdMap,
archivedTargetIds,
archivedTargets,
references: allReferences,
unmappedRequired,
unmappedOptional,
mcpReauthServerIds: cascade.mcpReauthServerIds,
inlineSecretSources: cascade.inlineSecretSources,
copyableUnmapped,
willUpdate,
willCreate,
willArchive: archivedTargetIds.length,
}
}
@@ -0,0 +1,128 @@
import { workspaceForkPromoteRun } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { desc, eq } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
/**
* A target workflow's pre-promote deployed-version reference. Rollback reactivates
* `priorVersion` (and loads it into the draft); `null` means the target was not
* deployed before the promote, so rollback undeploys it instead.
*/
export interface PromoteRunWorkflowSnapshot {
workflowId: string
priorVersion: number | null
}
export interface PromoteRunSnapshot {
/** Replaced targets: reactivate their prior deployed version on rollback. */
updated: PromoteRunWorkflowSnapshot[]
/** Targets the promote created: undeploy + archive on rollback. */
created: string[]
/** Orphan targets the promote archived: un-archive + reactivate on rollback. */
archived: PromoteRunWorkflowSnapshot[]
}
export interface PromoteRunRow {
id: string
childWorkspaceId: string
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
snapshot: PromoteRunSnapshot
createdAt: Date
}
/** Replace the edge's undo point with a new run (single-level history). */
export async function upsertPromoteRun(
tx: DbOrTx,
params: {
childWorkspaceId: string
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
snapshot: PromoteRunSnapshot
userId: string
}
): Promise<string> {
const now = new Date()
const id = generateId()
await tx
.insert(workspaceForkPromoteRun)
.values({
id,
childWorkspaceId: params.childWorkspaceId,
sourceWorkspaceId: params.sourceWorkspaceId,
targetWorkspaceId: params.targetWorkspaceId,
direction: params.direction,
snapshot: params.snapshot,
createdBy: params.userId,
createdAt: now,
})
.onConflictDoUpdate({
target: [workspaceForkPromoteRun.childWorkspaceId, workspaceForkPromoteRun.targetWorkspaceId],
set: {
id,
sourceWorkspaceId: params.sourceWorkspaceId,
direction: params.direction,
snapshot: params.snapshot,
createdBy: params.userId,
createdAt: now,
},
})
return id
}
/**
* Remove EVERY undo point targeting this workspace. Called after a rollback so the
* undo is single-level: only the latest sync into a target is ever undoable, and
* once it is undone there is no stack of older syncs to walk back into.
*/
export async function deleteAllPromoteRunsForTarget(
tx: DbOrTx,
targetWorkspaceId: string
): Promise<void> {
await tx
.delete(workspaceForkPromoteRun)
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
}
/**
* The newest undo point targeting this workspace. A workspace can be the target of
* several edges (pushes from its children, a pull from its parent), so order by
* recency: this is the ONLY undoable sync - older ones are stale the moment a newer
* sync lands, and rollback refuses them.
*/
export async function getLatestPromoteRunForTarget(
executor: DbOrTx,
targetWorkspaceId: string
): Promise<PromoteRunRow | null> {
const [row] = await executor
.select({
id: workspaceForkPromoteRun.id,
childWorkspaceId: workspaceForkPromoteRun.childWorkspaceId,
sourceWorkspaceId: workspaceForkPromoteRun.sourceWorkspaceId,
targetWorkspaceId: workspaceForkPromoteRun.targetWorkspaceId,
direction: workspaceForkPromoteRun.direction,
snapshot: workspaceForkPromoteRun.snapshot,
createdAt: workspaceForkPromoteRun.createdAt,
})
.from(workspaceForkPromoteRun)
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
.orderBy(desc(workspaceForkPromoteRun.createdAt))
.limit(1)
if (!row) return null
return { ...row, snapshot: row.snapshot as PromoteRunSnapshot }
}
/**
* The "other" workspace and direction of the latest sync into this target, for the
* UI's undo affordance. `sourceWorkspaceId` is the workspace the sync came from
* (rollback resolves the edge from target + other).
*/
export async function getUndoableRunForTarget(
executor: DbOrTx,
targetWorkspaceId: string
): Promise<{ sourceWorkspaceId: string; direction: 'push' | 'pull' } | null> {
const run = await getLatestPromoteRunForTarget(executor, targetWorkspaceId)
return run ? { sourceWorkspaceId: run.sourceWorkspaceId, direction: run.direction } : null
}
@@ -0,0 +1,622 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForkSyncBlocker } from '@/lib/api/contracts/workspace-fork'
const {
mockComputePlan,
mockBuildCopySelection,
mockHasCopySelection,
mockCopyUnmapped,
mockCollectBlockers,
mockLoadBlockMap,
mockBuildBlockIdResolver,
mockResolveFolderMapping,
mockUpsertPromoteRun,
mockLoadSourceDeployedStates,
mockGetUsersWithPermissions,
mockGetMcpServerMeta,
mockCreateTransform,
mockSumForkCopyBytes,
mockAssertForkStorageHeadroom,
} = vi.hoisted(() => ({
mockComputePlan: vi.fn(),
mockBuildCopySelection: vi.fn(),
mockHasCopySelection: vi.fn(),
mockCopyUnmapped: vi.fn(),
mockCollectBlockers: vi.fn(),
mockLoadBlockMap: vi.fn(),
mockBuildBlockIdResolver: vi.fn(),
mockResolveFolderMapping: vi.fn(),
mockUpsertPromoteRun: vi.fn(),
mockLoadSourceDeployedStates: vi.fn(),
mockGetUsersWithPermissions: vi.fn(),
mockGetMcpServerMeta: vi.fn(),
mockCreateTransform: vi.fn(),
mockSumForkCopyBytes: vi.fn(),
mockAssertForkStorageHeadroom: vi.fn(),
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowUndeploySideEffects: vi.fn(),
processWorkflowDeploymentOutboxEvent: vi.fn(),
}))
vi.mock('@/lib/workflows/orchestration/deploy', () => ({
performFullDeploy: vi.fn(async () => ({ success: true })),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
undeployWorkflow: vi.fn(async () => ({ success: true })),
}))
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
startBackgroundWork: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({
hasForkContentToCopy: vi.fn(() => false),
scheduleForkContentCopy: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({
copyWorkflowStateIntoTarget: vi.fn(),
loadTargetDraftSubBlocks: vi.fn(async () => new Map()),
loadWorkflowNameRegistry: vi.fn(async () => new Map()),
resolveForkFolderMapping: mockResolveFolderMapping,
}))
vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
sumForkCopyBytes: mockSumForkCopyBytes,
assertForkStorageHeadroom: mockAssertForkStorageHeadroom,
}))
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
getActiveDeploymentVersionNumbers: vi.fn(async () => new Map()),
loadSourceDeployedStates: mockLoadSourceDeployedStates,
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
acquireForkEdgeLock: vi.fn(),
acquireForkTargetLock: vi.fn(),
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
loadForkBlockMap: mockLoadBlockMap,
reconcileForkBlockPairs: vi.fn(),
toForkBlockPairs: vi.fn(() => []),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/dependent-value-store', () => ({
loadForkDependentValues: vi.fn(async () => []),
reconcileForkDependentValues: vi.fn(),
// Faithful mirror of the real pure translation (unit-tested in dependent-value-store.test.ts),
// so promote's apply/reconcile paths exercise the actual source-doc-id rewrite.
translateForkDependentValues: vi.fn(
(
values: Array<{ value: string }>,
resolve: (kind: string, sourceId: string) => string | null | undefined
) =>
values.map((entry) => {
if (entry.value === '') return entry
const translated = resolve('knowledge-document', entry.value)
return translated != null && translated !== entry.value
? { ...entry, value: translated }
: entry
})
),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
deleteWorkflowIdentityByIds: vi.fn(),
upsertEdgeMappings: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/cleared-refs', () => ({
collectForkSyncBlockers: mockCollectBlockers,
}))
vi.mock('@/ee/workspace-forking/lib/promote/copy-unmapped', () => ({
// Faithful mirror of the real overlay so a copy's id maps resolve through the augmented
// resolver (the dependent-value translation and MCP meta read depend on it).
augmentForkResolver: vi.fn(
(
base: (kind: string, sourceId: string) => string | null | undefined,
extra: Map<string, Map<string, string>>
) =>
(kind: string, sourceId: string) =>
extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
),
buildPromoteCopySelection: mockBuildCopySelection,
copyPromoteUnmappedResources: mockCopyUnmapped,
hasPromoteCopySelection: mockHasCopySelection,
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({
computeForkPromotePlan: mockComputePlan,
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-chats', () => ({
copyForkChatDeployments: vi.fn(async () => ({ created: 0 })),
}))
vi.mock('@/ee/workspace-forking/lib/copy/workflow-mcp-attachments', () => ({
reconcileForkWorkflowMcpAttachments: vi.fn(async () => ({ affectedServerIds: [] })),
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
notifyMcpToolServers: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
upsertPromoteRun: mockUpsertPromoteRun,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({
getMcpServerMetaByIds: mockGetMcpServerMeta,
}))
vi.mock('@/ee/workspace-forking/lib/remap/block-identity', () => ({
buildForkBlockIdResolver: mockBuildBlockIdResolver,
}))
vi.mock('@/ee/workspace-forking/lib/remap/remap-references', () => ({
createForkSubBlockTransform: mockCreateTransform,
}))
vi.mock('@/ee/workspace-forking/lib/socket', () => ({
notifyForkWorkflowChanged: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUsersWithPermissions: mockGetUsersWithPermissions,
}))
import { db } from '@sim/db'
import { copyWorkflowStateIntoTarget } from '@/ee/workspace-forking/lib/copy/copy-workflows'
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
import type { ForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
const EMPTY_SELECTION = {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
}
function makePlan(overrides: Partial<ForkPromotePlan> = {}): ForkPromotePlan {
return {
childWorkspaceId: EDGE.childWorkspaceId,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'tgt-ws',
direction: 'push',
resolver: () => null,
items: [],
workflowIdMap: new Map(),
archivedTargetIds: [],
archivedTargets: [],
references: [],
unmappedRequired: [],
unmappedOptional: [],
mcpReauthServerIds: [],
inlineSecretSources: [],
copyableUnmapped: [],
willUpdate: 0,
willCreate: 0,
willArchive: 0,
...overrides,
}
}
const BLOCKER: ForkSyncBlocker = {
workflowName: 'Caller',
blockLabel: 'Table Block',
fieldLabel: 'Table',
kind: 'table',
sourceId: 'tbl-1',
sourceLabel: 'Orders',
reason: 'unmapped-copyable',
}
function promoteParams() {
return {
edge: EDGE as never,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'tgt-ws',
direction: 'push' as const,
userId: 'user-1',
}
}
/** A copy result carrying no content/id maps, for tests that only need the copy to run. */
function emptyCopyResult() {
return {
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'tgt-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
copyIdMapByKind: new Map(),
contentRefMaps: {},
blobTasks: [],
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(db.transaction).mockImplementation(
async (cb: (tx: unknown) => unknown) => cb({}) as never
)
mockGetUsersWithPermissions.mockResolvedValue([])
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map(),
})
mockComputePlan.mockResolvedValue(makePlan())
mockBuildCopySelection.mockReturnValue({
selection: EMPTY_SELECTION,
willResolve: new Set<string>(),
})
mockHasCopySelection.mockReturnValue(false)
mockCollectBlockers.mockResolvedValue([])
mockLoadBlockMap.mockResolvedValue(new Map())
mockBuildBlockIdResolver.mockReturnValue((_wf: string, blockId: string) => blockId)
mockResolveFolderMapping.mockResolvedValue(new Map())
mockUpsertPromoteRun.mockResolvedValue('run-1')
mockGetMcpServerMeta.mockResolvedValue(new Map())
mockCreateTransform.mockReturnValue((subBlocks: unknown) => subBlocks)
mockSumForkCopyBytes.mockResolvedValue(0)
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
})
describe('promoteFork gates', () => {
it('blocks an over-quota copy selection before any lock, read, or write', async () => {
mockSumForkCopyBytes.mockResolvedValue(999_999)
mockAssertForkStorageHeadroom.mockRejectedValue(
new Error(
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB'
)
)
await expect(
promoteFork({
...promoteParams(),
copyResources: { files: ['workspace/src-ws/key-1'], knowledgeBases: ['kb-1'] },
})
).rejects.toThrow('Not enough storage to copy the selected resources')
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 })
// Fails fast: no source-state loads, no locked transaction, no writes of any kind.
expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled()
expect(db.transaction).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('sums the requested copy selection bytes against the SOURCE workspace (files by key, KBs by id)', async () => {
await promoteFork({
...promoteParams(),
copyResources: {
files: ['workspace/src-ws/key-1'],
knowledgeBases: ['kb-1'],
tables: ['tbl-1'],
},
})
expect(mockSumForkCopyBytes).toHaveBeenCalledTimes(1)
expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', {
fileKeys: ['workspace/src-ws/key-1'],
knowledgeBaseIds: ['kb-1'],
})
})
it('blocks on unmapped required credentials/secrets BEFORE the cleared-refs gate runs', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
unmappedRequired: [
{ kind: 'credential', sourceId: 'c1', subBlockKey: 'credential', required: true },
],
})
)
const result = await promoteFork(promoteParams())
expect(result.blocked).toBe('unmapped')
expect(result.unmappedRequired).toEqual([
{ kind: 'credential', sourceId: 'c1', required: true, blockName: undefined },
])
expect(result.blockers).toEqual([])
expect(mockCollectBlockers).not.toHaveBeenCalled()
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('blocks with the structured blocker list when references would clear, writing NOTHING', async () => {
mockCollectBlockers.mockResolvedValue([BLOCKER])
const result = await promoteFork(promoteParams())
expect(result.blocked).toBe('cleared-refs')
expect(result.blockers).toEqual([BLOCKER])
expect(result.promoteRunId).toBe('')
expect(result.updated).toBe(0)
expect(result.created).toBe(0)
expect(result.archived).toBe(0)
// Blocked before the first write: no folder creation, no resource copy, no undo point.
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
expect(mockCopyUnmapped).not.toHaveBeenCalled()
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
it('evaluates the gate against the plan resolver overlaid with the copy selection', async () => {
const planResolver = vi.fn(() => 'plan-resolved')
mockComputePlan.mockResolvedValue(makePlan({ resolver: planResolver }))
mockBuildCopySelection.mockReturnValue({
selection: EMPTY_SELECTION,
willResolve: new Set(['table:t1']),
})
await promoteFork(promoteParams())
expect(mockCollectBlockers).toHaveBeenCalledTimes(1)
const gateParams = mockCollectBlockers.mock.calls[0][0]
// A copy-selected reference resolves through the overlay (never hits the plan resolver);
// everything else falls through to the plan's persisted-mapping resolver.
expect(gateParams.resolver('table', 't1')).toBe('t1')
expect(planResolver).not.toHaveBeenCalled()
expect(gateParams.resolver('table', 't2')).toBe('plan-resolved')
expect(planResolver).toHaveBeenCalledWith('table', 't2')
})
it('threads the SAME block-id resolver into the gate and the resource copy as the workflow writes', async () => {
// Copied tables' workflow-group outputs must land on the block ids the sync actually writes
// (persisted pairs preferred over derive), so the copy receives the resolver built from the
// loaded block map - the identical instance the cleared-refs gate uses.
const resolver = (_workflowId: string, blockId: string) => `pair-${blockId}`
mockBuildBlockIdResolver.mockReturnValue(resolver)
mockHasCopySelection.mockReturnValue(true)
mockCopyUnmapped.mockResolvedValue({
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'tgt-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
copyIdMapByKind: new Map(),
contentRefMaps: {},
blobTasks: [],
})
await promoteFork(promoteParams())
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
expect(mockCopyUnmapped.mock.calls[0][0].resolveBlockId).toBe(resolver)
expect(mockCollectBlockers.mock.calls[0][0].resolveBlockId).toBe(resolver)
})
it('proceeds when zero references would clear (empty blocker list)', async () => {
const plan = makePlan()
mockComputePlan.mockResolvedValue(plan)
const result = await promoteFork(promoteParams())
expect(result.blocked).toBeNull()
expect(result.blockers).toEqual([])
expect(result.promoteRunId).toBe('run-1')
expect(mockCollectBlockers).toHaveBeenCalledWith(
expect.objectContaining({
sourceWorkspaceId: 'src-ws',
items: plan.items,
workflowIdMap: plan.workflowIdMap,
})
)
expect(mockUpsertPromoteRun).toHaveBeenCalledTimes(1)
})
it("threads the plan's unmapped references into the gate so it can reuse the plan's scan", async () => {
const unmappedOptional = [
{ kind: 'table' as const, sourceId: 'tbl-1', subBlockKey: 'tbl', required: false },
]
mockComputePlan.mockResolvedValue(makePlan({ unmappedOptional }))
await promoteFork(promoteParams())
expect(mockCollectBlockers).toHaveBeenCalledWith(
expect.objectContaining({ planUnmapped: unmappedOptional })
)
})
it('batch-loads the mapped TARGET MCP server rows and threads them into the subblock transform', async () => {
// Two references resolving to the SAME target and one unmapped: the read must cover the
// distinct mapped target ids only (one bounded query, unmapped ids dropped).
const resolver = (kind: string, id: string) => {
if (kind !== 'mcp-server') return null
if (id === 'srv-a' || id === 'srv-b') return 'srv-tgt'
return null
}
mockComputePlan.mockResolvedValue(
makePlan({
resolver,
references: [
{ kind: 'mcp-server', sourceId: 'srv-a', subBlockKey: 'tools', required: false },
{ kind: 'mcp-server', sourceId: 'srv-b', subBlockKey: 'server', required: false },
{ kind: 'mcp-server', sourceId: 'srv-unmapped', subBlockKey: 'tools', required: false },
],
})
)
mockGetMcpServerMeta.mockResolvedValue(
new Map([['srv-tgt', { name: 'Target Server', url: 'https://target.example/mcp' }]])
)
await promoteFork(promoteParams())
expect(mockGetMcpServerMeta).toHaveBeenCalledTimes(1)
expect(mockGetMcpServerMeta).toHaveBeenCalledWith(expect.anything(), 'tgt-ws', ['srv-tgt'])
// The transform receives a lookup resolving the TARGET id to its row metadata, so remapped
// tool-input entries rewrite their embedded serverUrl/serverName from the target server.
expect(mockCreateTransform).toHaveBeenCalledTimes(1)
const [, transformOptions] = mockCreateTransform.mock.calls[0]
expect(transformOptions.resolveMcpServerMeta('srv-tgt')).toEqual({
name: 'Target Server',
url: 'https://target.example/mcp',
})
expect(transformOptions.resolveMcpServerMeta('srv-unknown')).toBeUndefined()
})
})
describe('promoteFork dependent values', () => {
it('unions the dependent-value picks into the copy discovery set (a re-picked document must be copied)', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
references: [
{
kind: 'knowledge-document',
sourceId: 'doc-a',
subBlockKey: 'documentSelector',
required: false,
},
],
})
)
// No container selection: the document candidates alone must trigger the copy pass.
mockHasCopySelection.mockReturnValue(false)
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
await promoteFork({
...promoteParams(),
dependentValues: [
// Duplicates the plan's own scan -> deduped.
{ workflowId: 'wf-t', blockId: 'b1', subBlockKey: 'documentSelector', value: 'doc-a' },
// A fresh pick the source state does not reference -> must join the discovery set.
{ workflowId: 'wf-t', blockId: 'b2', subBlockKey: 'documentSelector', value: 'doc-b' },
// Cleared values are skipped; non-document values ride along (DB-filtered downstream).
{ workflowId: 'wf-t', blockId: 'b3', subBlockKey: 'folder', value: '' },
{ workflowId: 'wf-t', blockId: 'b4', subBlockKey: 'folder', value: 'INBOX' },
],
})
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
expect(mockCopyUnmapped.mock.calls[0][0].referencedDocumentIds).toEqual([
'doc-a',
'doc-b',
'INBOX',
])
})
it('translates a source document id under a copy-resolved KB for BOTH the written state and the store', async () => {
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Flow',
mode: 'replace' as const,
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
}
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map([
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
]),
})
// The KB is copy-selected; the copy assigns the picked source document its copied id.
mockHasCopySelection.mockReturnValue(true)
mockCopyUnmapped.mockResolvedValue({
...emptyCopyResult(),
copyIdMapByKind: new Map([['knowledge-document', new Map([['doc-src', 'doc-copy']])]]),
})
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
targetWorkflowId: 'wf-tgt',
mode: 'replace',
name: 'Flow',
blocksCount: 0,
edgesCount: 0,
subflowsCount: 0,
clearedDependents: [],
blockIdMapping: new Map(),
})
const result = await promoteFork({
...promoteParams(),
copyResources: { knowledgeBases: ['kb-src'] },
dependentValues: [
{
workflowId: 'wf-tgt',
blockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-src',
},
],
})
expect(result.blocked).toBeNull()
// The apply map the workflow write receives carries the COPIED id: the dependent-value
// apply runs AFTER the reference remap and wins for its subblock, so a raw source id
// would clobber the remapped value in the written state.
expect(vi.mocked(copyWorkflowStateIntoTarget)).toHaveBeenCalledTimes(1)
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe('doc-copy')
// The store persists the translated value too, so the next sync (whose parent is then
// MAPPED via the persisted copy mapping) pre-fills a document id that resolves in the target.
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
expect.anything(),
'child-ws',
['wf-tgt'],
[
{
targetWorkflowId: 'wf-tgt',
targetBlockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-copy',
},
]
)
})
it('keeps a mapped parent dependent value verbatim (a target-space value never re-translates)', async () => {
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Flow',
mode: 'replace' as const,
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
}
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map([
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
]),
})
// The value joins the discovery candidates, so the copy pass runs - and resolves nothing.
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
targetWorkflowId: 'wf-tgt',
mode: 'replace',
name: 'Flow',
blocksCount: 0,
edgesCount: 0,
subflowsCount: 0,
clearedDependents: [],
blockIdMapping: new Map(),
})
await promoteFork({
...promoteParams(),
dependentValues: [
{
workflowId: 'wf-tgt',
blockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-tgt-existing',
},
],
})
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe(
'doc-tgt-existing'
)
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
expect.anything(),
'child-ws',
['wf-tgt'],
[
{
targetWorkflowId: 'wf-tgt',
targetBlockId: 'blk-1',
subBlockKey: 'documentSelector',
value: 'doc-tgt-existing',
},
]
)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { enqueueWorkflowDeploymentSideEffects } from '@/lib/workflows/deployment-outbox'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface ReactivateDeployedVersionParams {
tx: DbOrTx
workflowId: string
version: number
userId: string
requestId: string
}
export interface ReactivateDeployedVersionResult {
deploymentVersionId: string
/**
* Outbox event id enqueued inside the transaction. Process it AFTER the tx commits
* (or rely on the outbox cron/reaper if the process dies first).
*/
outboxEventId: string
}
/**
* Reactivate a prior deployment version AND restore the workflow's draft to it using
* ONLY DB writes against the provided transaction, enqueuing the deployment
* side-effect (webhook / schedule / MCP re-subscription) to the outbox for processing
* AFTER the tx commits. This composes the DB halves of {@link activateWorkflowVersion}
* and `performRevertToVersion` so a fork rollback can run atomically under its fork
* advisory lock - the heavy side-effects never run inside the locked tx.
*
* Deliberately does NOT call `assertWorkflowMutable`: a rollback is an admin force-undo
* and must not be blocked by a workflow/folder lock (that check is also not tx-safe).
* Idempotent: deactivate-all + activate-target + overwrite-draft yield the same state
* on retry.
*
* Returns null when the target version row no longer exists, so the caller can mark the
* workflow skipped rather than failing the whole rollback.
*/
export async function reactivateDeployedVersionInTx(
params: ReactivateDeployedVersionParams
): Promise<ReactivateDeployedVersionResult | null> {
const { tx, workflowId, version, userId, requestId } = params
const now = new Date()
// Lock the workflow row so this serializes with a concurrent (unlocked) promote
// deploy loop, which locks the same row in deployWorkflow - guaranteeing the final
// (active version, draft) pair is always coherent regardless of commit order.
await tx
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
.for('update')
const [versionRow] = await tx
.select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
if (!versionRow) return null
const deployedState = versionRow.state as {
blocks?: Record<string, unknown>
edges?: unknown[]
loops?: Record<string, unknown>
parallels?: Record<string, unknown>
variables?: WorkflowState['variables']
}
if (!deployedState.blocks || !deployedState.edges) {
throw new Error(
`Deployment version ${version} for workflow ${workflowId} has an invalid state structure`
)
}
// Activate the target version (deactivate every other), mark the workflow deployed.
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
await tx
.update(workflowDeploymentVersion)
.set({ isActive: true })
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
await tx
.update(workflow)
.set({ isDeployed: true, deployedAt: now })
.where(eq(workflow.id, workflowId))
// Restore the draft to the deployed version's state.
const hasVariables = Object.hasOwn(deployedState, 'variables')
const restoredState: WorkflowState = {
blocks: deployedState.blocks,
edges: deployedState.edges,
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved: now.getTime(),
} as WorkflowState
if (hasVariables) {
restoredState.variables = deployedState.variables || {}
}
const saveResult = await saveWorkflowToNormalizedTables(workflowId, restoredState, tx)
if (!saveResult.success) {
throw new Error(saveResult.error || `Failed to restore draft for workflow ${workflowId}`)
}
await tx
.update(workflow)
.set({
...(hasVariables ? { variables: deployedState.variables || {} } : {}),
lastSynced: now,
updatedAt: now,
})
.where(eq(workflow.id, workflowId))
const outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
workflowId,
deploymentVersionId: versionRow.id,
userId,
requestId,
forceRecreateSubscriptions: true,
})
return { deploymentVersionId: versionRow.id, outboxEventId }
}
@@ -0,0 +1,279 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockResolveForkEdge,
mockAcquireTargetLock,
mockAcquireEdgeLock,
mockGetLatestRun,
mockDeleteAllRuns,
mockReactivate,
mockUndeploy,
mockDeleteIdentity,
mockEnqueueUndeploy,
mockProcessOutbox,
mockNotify,
} = vi.hoisted(() => ({
mockResolveForkEdge: vi.fn(),
mockAcquireTargetLock: vi.fn(),
mockAcquireEdgeLock: vi.fn(),
mockGetLatestRun: vi.fn(),
mockDeleteAllRuns: vi.fn(),
mockReactivate: vi.fn(),
mockUndeploy: vi.fn(),
mockDeleteIdentity: vi.fn(),
mockEnqueueUndeploy: vi.fn(),
mockProcessOutbox: vi.fn(),
mockNotify: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
resolveForkEdge: mockResolveForkEdge,
acquireForkTargetLock: mockAcquireTargetLock,
acquireForkEdgeLock: mockAcquireEdgeLock,
setForkLockTimeout: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
getLatestPromoteRunForTarget: mockGetLatestRun,
deleteAllPromoteRunsForTarget: mockDeleteAllRuns,
}))
vi.mock('@/ee/workspace-forking/lib/promote/reactivate-in-tx', () => ({
reactivateDeployedVersionInTx: mockReactivate,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
undeployWorkflow: mockUndeploy,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
deleteWorkflowIdentityByIds: mockDeleteIdentity,
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowUndeploySideEffects: mockEnqueueUndeploy,
processWorkflowDeploymentOutboxEvent: mockProcessOutbox,
}))
vi.mock('@/ee/workspace-forking/lib/socket', () => ({
notifyForkWorkflowChanged: mockNotify,
}))
import { db } from '@sim/db'
import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback'
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
/** A fake transaction whose existence query returns the given undeploy ids. */
function makeTx(existingUndeployIds: string[] = []) {
return {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve(existingUndeployIds.map((id) => ({ id })))),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
})),
}
}
function setTx(existingUndeployIds: string[] = []) {
vi.mocked(db.transaction).mockImplementation(
async (cb: (tx: unknown) => unknown) => cb(makeTx(existingUndeployIds)) as never
)
}
function makeRun(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'run-1',
childWorkspaceId: EDGE.childWorkspaceId,
direction: 'push' as const,
snapshot: { updated: [], created: [], archived: [] },
...overrides,
}
}
describe('rollbackFork', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveForkEdge.mockResolvedValue(EDGE)
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv', outboxEventId: 'evt' })
mockUndeploy.mockResolvedValue({ success: true })
mockProcessOutbox.mockResolvedValue('completed')
setTx([])
})
it('reactivates updated workflows and processes side-effects after commit', async () => {
const run = makeRun({
snapshot: {
updated: [
{ workflowId: 'wf-b', priorVersion: 5 },
{ workflowId: 'wf-a', priorVersion: 3 },
],
created: [],
archived: [],
},
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({
deploymentVersionId: `dv-${workflowId}`,
outboxEventId: `evt-${workflowId}`,
}))
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result).toMatchObject({
restored: 2,
archived: 0,
unarchived: 0,
skipped: 0,
skippedIds: [],
})
// Deterministic (sorted) order: wf-a before wf-b.
expect(mockReactivate.mock.calls.map((c) => c[0].workflowId)).toEqual(['wf-a', 'wf-b'])
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-a')
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-b')
expect(mockDeleteAllRuns).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith('wf-a')
expect(mockNotify).toHaveBeenCalledWith('wf-b')
})
it('un-archives and reactivates an archived orphan (prior version restored)', async () => {
const run = makeRun({
snapshot: { updated: [], created: [], archived: [{ workflowId: 'wf-x', priorVersion: 2 }] },
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv-x', outboxEventId: 'evt-x' })
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.unarchived).toBe(1)
expect(result.restored).toBe(0)
expect(result.archived).toBe(0)
expect(result.skipped).toBe(0)
expect(mockReactivate).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: 'wf-x', version: 2 })
)
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-x')
expect(mockNotify).toHaveBeenCalledWith('wf-x')
})
it('aborts with 409 and writes nothing when a newer sync supersedes it mid-flight', async () => {
const run = makeRun({
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
})
// Unlocked read returns our run; the in-tx re-check sees a newer run.
mockGetLatestRun.mockResolvedValueOnce(run).mockResolvedValueOnce(makeRun({ id: 'run-2' }))
await expect(
rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
).rejects.toMatchObject({ statusCode: 409 })
// No partial restore: nothing reactivated, no undo point consumed.
expect(mockReactivate).not.toHaveBeenCalled()
expect(mockUndeploy).not.toHaveBeenCalled()
expect(mockDeleteAllRuns).not.toHaveBeenCalled()
expect(mockProcessOutbox).not.toHaveBeenCalled()
})
it('surfaces a skipped reactivation when the version is gone (never silent)', async () => {
const run = makeRun({
snapshot: {
updated: [
{ workflowId: 'wf-a', priorVersion: 3 },
{ workflowId: 'wf-b', priorVersion: 5 },
],
created: [],
archived: [],
},
})
mockGetLatestRun.mockResolvedValue(run)
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) =>
workflowId === 'wf-b' ? null : { deploymentVersionId: 'dv', outboxEventId: 'evt-wf-a' }
)
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.restored).toBe(1)
expect(result.skipped).toBe(1)
expect(result.skippedIds).toEqual(['wf-b'])
expect(mockNotify).not.toHaveBeenCalledWith('wf-b')
})
it('undeploys + archives created workflows and dissolves their identity rows', async () => {
setTx(['wf-c'])
const run = makeRun({
direction: 'push',
snapshot: { updated: [], created: ['wf-c'], archived: [] },
})
mockGetLatestRun.mockResolvedValue(run)
mockUndeploy.mockImplementation(
async ({
onUndeployTransaction,
}: {
onUndeployTransaction?: (
tx: unknown,
r: { deploymentVersionIds: string[] }
) => Promise<void>
}) => {
await onUndeployTransaction?.(makeTx(), { deploymentVersionIds: ['dv-c'] })
return { success: true }
}
)
mockEnqueueUndeploy.mockResolvedValue('undeploy-evt')
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(result.archived).toBe(1)
expect(result.skipped).toBe(0)
expect(mockUndeploy).toHaveBeenCalledTimes(1)
expect(mockDeleteIdentity).toHaveBeenCalledWith(
expect.anything(),
EDGE.childWorkspaceId,
'parent',
['wf-c']
)
expect(mockProcessOutbox).toHaveBeenCalledWith('undeploy-evt')
})
it('skips a created workflow that was hard-deleted (not archived, surfaced)', async () => {
setTx([]) // wf-c no longer exists
const run = makeRun({ snapshot: { updated: [], created: ['wf-c'], archived: [] } })
mockGetLatestRun.mockResolvedValue(run)
const result = await rollbackFork({
targetWorkspaceId: 'target-ws',
otherWorkspaceId: 'other-ws',
userId: 'user-1',
})
expect(mockUndeploy).not.toHaveBeenCalled()
expect(result.skipped).toBe(1)
expect(result.skippedIds).toEqual(['wf-c'])
expect(result.archived).toBe(0)
})
})
@@ -0,0 +1,300 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, inArray, isNull } from 'drizzle-orm'
import {
enqueueWorkflowUndeploySideEffects,
processWorkflowDeploymentOutboxEvent,
} from '@/lib/workflows/deployment-outbox'
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import {
acquireForkEdgeLock,
acquireForkTargetLock,
resolveForkEdge,
setForkLockTimeout,
} from '@/ee/workspace-forking/lib/lineage/lineage'
import { deleteWorkflowIdentityByIds } from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
deleteAllPromoteRunsForTarget,
getLatestPromoteRunForTarget,
} from '@/ee/workspace-forking/lib/promote/promote-run-store'
import { reactivateDeployedVersionInTx } from '@/ee/workspace-forking/lib/promote/reactivate-in-tx'
import { notifyForkWorkflowChanged } from '@/ee/workspace-forking/lib/socket'
const logger = createLogger('WorkspaceForkRollback')
export interface RollbackForkParams {
targetWorkspaceId: string
otherWorkspaceId: string
userId: string
requestId?: string
}
export interface RollbackForkResult {
restored: number
archived: number
unarchived: number
/** Snapshot workflows that no longer exist and so couldn't be restored. */
skipped: number
/** Ids of the skipped workflows (surfaced so the partial restore is never silent). */
skippedIds: string[]
}
/** A single restore action, sorted by workflow id for a deterministic lock order. */
type RollbackOp =
| { workflowId: string; kind: 'reactivate'; version: number }
| { workflowId: string; kind: 'undeploy' }
/**
* Undo the most recent promote into `targetWorkspaceId` in ONE atomic, fork-locked,
* DB-only transaction. Because a concurrent promote takes the same target advisory
* lock for its write transaction, it cannot interleave with the rollback: it runs
* fully before or fully after. If a newer sync superseded our undo point, we abort
* with 409 BEFORE any write, so the operation is strictly all-or-nothing - it never
* leaves a partially reverted target.
*
* The heavy webhook / schedule / MCP re-subscription work is enqueued to the
* deployment outbox INSIDE the transaction and processed AFTER commit (and durably
* retried by the outbox cron/reaper if this process dies first), so the locked
* transaction never holds across a network call. No draft blobs are stored - the
* deployed version is the source of truth.
*/
export async function rollbackFork(params: RollbackForkParams): Promise<RollbackForkResult> {
const { targetWorkspaceId, otherWorkspaceId, userId } = params
const requestId = params.requestId ?? 'unknown'
const edge = await resolveForkEdge(targetWorkspaceId, otherWorkspaceId)
if (!edge) {
throw new ForkError('These workspaces are not a direct fork edge', 400)
}
// Only the most recent sync into the target is undoable. Undoing an older sibling's
// sync while a newer one stands would partially revert the target and strand the
// newer sync's changes.
const run = await getLatestPromoteRunForTarget(db, targetWorkspaceId)
if (!run) {
throw new ForkError('There is no promote to undo for this workspace', 404)
}
if (run.childWorkspaceId !== edge.childWorkspaceId) {
throw new ForkError(
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
409
)
}
const { updated, created, archived } = run.snapshot
// Build the restore ops: reactivate a prior version, or undeploy (created targets +
// updated targets that had no prior deployment). Sort by workflow id so the locked
// transaction acquires workflow row locks in a deterministic order, avoiding
// deadlocks with the (unlocked) promote deploy loop, which locks the same rows.
const undeployIds = [
...created,
...updated.filter((i) => i.priorVersion == null).map((i) => i.workflowId),
]
const toReactivateOps = (
list: Array<{ workflowId: string; priorVersion: number | null }>
): RollbackOp[] =>
list
.filter((item) => item.priorVersion != null)
.map((item) => ({
workflowId: item.workflowId,
kind: 'reactivate' as const,
version: item.priorVersion as number,
}))
const ops: RollbackOp[] = [
...toReactivateOps(updated),
...toReactivateOps(archived),
...undeployIds.map((workflowId) => ({ workflowId, kind: 'undeploy' as const })),
].sort((a, b) => a.workflowId.localeCompare(b.workflowId))
const skipped = new Set<string>()
const outboxEventIds: string[] = []
await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
await acquireForkTargetLock(tx, targetWorkspaceId)
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
// Re-confirm our run is still the newest sync, now under the lock. If a promote
// landed since the unlocked read above, abort with NO writes (tx rolls back).
const current = await getLatestPromoteRunForTarget(tx, targetWorkspaceId)
if (!current || current.id !== run.id) {
throw new ForkError(
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
409
)
}
const now = new Date()
// Un-archive the orphans the promote archived BEFORE reactivating them.
if (archived.length > 0) {
await tx
.update(workflow)
.set({ archivedAt: null, updatedAt: now })
.where(
inArray(
workflow.id,
archived.map((i) => i.workflowId)
)
)
}
// Which undeploy targets still exist (created targets can be hard-deleted after the
// promote; a missing one is already gone, so skip rather than fail the rollback).
const existingUndeploy =
undeployIds.length === 0
? new Set<string>()
: new Set(
(
await tx
.select({ id: workflow.id })
.from(workflow)
.where(inArray(workflow.id, undeployIds))
).map((row) => row.id)
)
for (const op of ops) {
if (op.kind === 'reactivate') {
const result = await reactivateDeployedVersionInTx({
tx,
workflowId: op.workflowId,
version: op.version,
userId,
requestId,
})
// A null result means the workflow / version was hard-deleted since the
// promote - record it so the partial restore is surfaced, never silent.
if (!result) {
skipped.add(op.workflowId)
continue
}
outboxEventIds.push(result.outboxEventId)
continue
}
if (!existingUndeploy.has(op.workflowId)) {
skipped.add(op.workflowId)
continue
}
const undeployResult = await undeployWorkflow({
workflowId: op.workflowId,
tx,
onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => {
if (deploymentVersionIds.length === 0) return
const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, {
workflowId: op.workflowId,
deploymentVersionIds,
userId,
requestId,
})
outboxEventIds.push(eventId)
},
})
if (!undeployResult.success) {
// The workflow exists but couldn't be undeployed - abort so we never leave a
// partial undo. The whole tx rolls back and the undo point is preserved.
throw new ForkError(
`Rollback could not undeploy workflow ${op.workflowId}: ${undeployResult.error ?? 'unknown error'}. The undo point is preserved - retry the rollback.`,
500
)
}
}
// Archive the workflows the promote created and dissolve their identity rows.
if (created.length > 0) {
await tx
.update(workflow)
.set({ archivedAt: now, updatedAt: now })
.where(inArray(workflow.id, created))
// Archive their chat deployments too (matching `archiveWorkflow`): the sync carried a
// chat onto each created target, and leaving it live would keep a working chat URL (with
// the copied auth config) pointing at the archived workflow. The undeploy above already
// cleans webhooks + MCP tools via the outbox; chats have no undeploy hook.
await tx
.update(chat)
.set({ archivedAt: now, isActive: false, updatedAt: now })
.where(and(inArray(chat.workflowId, created), isNull(chat.archivedAt)))
// A created target is the child side on pull and the parent side on push.
await deleteWorkflowIdentityByIds(
tx,
edge.childWorkspaceId,
run.direction === 'pull' ? 'child' : 'parent',
created
)
}
// Single-level undo: drop every undo point for this target so no older sibling
// sync becomes undoable once this one is undone.
await deleteAllPromoteRunsForTarget(tx, targetWorkspaceId)
})
// After commit: process the enqueued side-effects (webhooks / schedules / MCP). These
// are durable outbox rows, so a crash here is recovered by the outbox cron/reaper -
// failures only warn, they never undo the (committed) restore.
for (const eventId of outboxEventIds) {
try {
await processWorkflowDeploymentOutboxEvent(eventId)
} catch (error) {
logger.warn(
`[${requestId}] Deferred rollback side-effect processing failed (will retry via outbox)`,
{ eventId, error }
)
}
}
if (skipped.size > 0) {
logger.warn(
`[${requestId}] Rollback skipped ${skipped.size} workflow(s) no longer in the database`,
{
targetWorkspaceId,
skipped: Array.from(skipped),
}
)
}
// Notify connected canvases to adopt the restored state (reactivated drafts + the
// undeployed/archived created targets). Skipped (gone) workflows have no room.
const notifyIds = new Set<string>()
for (const op of ops) {
if (!skipped.has(op.workflowId)) notifyIds.add(op.workflowId)
}
for (const workflowId of notifyIds) void notifyForkWorkflowChanged(workflowId)
// Attribute each skip to its bucket (a workflow is in exactly one) so the counts
// reflect what was actually restored, not the snapshot size.
const createdSet = new Set(created)
const archivedSet = new Set(archived.map((i) => i.workflowId))
const updatedSet = new Set(updated.map((i) => i.workflowId))
let skippedUpdated = 0
let skippedCreated = 0
let skippedArchived = 0
for (const id of skipped) {
if (updatedSet.has(id)) skippedUpdated += 1
else if (createdSet.has(id)) skippedCreated += 1
else if (archivedSet.has(id)) skippedArchived += 1
}
const restored = updated.length - skippedUpdated
const archivedCount = created.length - skippedCreated
const unarchived = archived.length - skippedArchived
const result: RollbackForkResult = {
restored,
archived: archivedCount,
unarchived,
skipped: skipped.size,
skippedIds: Array.from(skipped),
}
logger.info(`[${requestId}] Rolled back promote into ${targetWorkspaceId}`, {
restored: result.restored,
archived: result.archived,
unarchived: result.unarchived,
skipped: result.skipped,
})
return result
}
@@ -0,0 +1,121 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import {
forkSyncBlockerReasonFor,
selectForkSyncBlockingRefs,
toForkSyncBlockers,
} from '@/ee/workspace-forking/lib/promote/sync-blockers'
type ReferenceRef = Extract<ForkClearedRef, { cause: 'reference' }>
type DependentRef = Extract<ForkClearedRef, { cause: 'dependent' }>
const base = {
targetWorkflowId: 'wf-tgt',
workflowName: 'Workflow',
blockId: 'block-1',
blockLabel: 'Block',
fieldLabel: 'Field',
sourceLabel: 'Source',
}
const referenceRef = (
kind: ReferenceRef['kind'],
sourceId: string,
sourceDeleted = false
): ReferenceRef => ({ ...base, cause: 'reference', kind, sourceId, sourceDeleted })
const workflowRef = (sourceId: string): ForkClearedRef => ({
...base,
cause: 'workflow',
kind: 'workflow',
sourceId,
})
const dependentRef = (parentKind: DependentRef['parentKind']): DependentRef => ({
...base,
cause: 'dependent',
kind: parentKind,
sourceId: 'parent-src',
parentKind,
parentSourceId: 'parent-src',
})
describe('forkSyncBlockerReasonFor', () => {
it('maps a live unmapped copyable-kind reference to unmapped-copyable (map or copy)', () => {
for (const kind of [
'table',
'knowledge-base',
'file',
'custom-tool',
'skill',
// External MCP servers are copyable too (config rows; OAuth tokens never copied).
'mcp-server',
] as const) {
expect(forkSyncBlockerReasonFor(referenceRef(kind, 'src-1'))).toBe('unmapped-copyable')
}
})
it('maps a source-deleted reference of ANY kind to source-deleted (no exemption)', () => {
expect(forkSyncBlockerReasonFor(referenceRef('table', 'tbl-gone', true))).toBe('source-deleted')
expect(forkSyncBlockerReasonFor(referenceRef('mcp-server', 'srv-gone', true))).toBe(
'source-deleted'
)
expect(forkSyncBlockerReasonFor(referenceRef('file', 'workspace/SRC/gone.png', true))).toBe(
'source-deleted'
)
})
it('maps a workflow-cause entry to workflow-missing', () => {
expect(forkSyncBlockerReasonFor(workflowRef('wf-other'))).toBe('workflow-missing')
})
it('never blocks a dependent-cause entry (the reconfigure flow owns dependents)', () => {
expect(forkSyncBlockerReasonFor(dependentRef('credential'))).toBeNull()
expect(forkSyncBlockerReasonFor(dependentRef('knowledge-base'))).toBeNull()
})
it('defensively ignores kinds the collector excludes (credential / env-var / document)', () => {
// These never reach the cleared list (excluded by the collector); if one leaked, the
// kind-level required gate owns credentials/env-vars, so this path must not double-block.
expect(forkSyncBlockerReasonFor(referenceRef('credential', 'c1'))).toBeNull()
expect(forkSyncBlockerReasonFor(referenceRef('env-var', 'KEY'))).toBeNull()
expect(forkSyncBlockerReasonFor(referenceRef('knowledge-document', 'doc-1'))).toBeNull()
})
})
describe('selectForkSyncBlockingRefs / toForkSyncBlockers', () => {
it('keeps reference + workflow causes with their reasons and drops dependents', () => {
const refs: ForkClearedRef[] = [
referenceRef('table', 'tbl-1'),
referenceRef('mcp-server', 'srv-1'),
referenceRef('skill', 'sk-gone', true),
workflowRef('wf-other'),
dependentRef('credential'),
]
const blocking = selectForkSyncBlockingRefs(refs)
expect(blocking.map(({ ref, reason }) => [ref.sourceId, reason])).toEqual([
['tbl-1', 'unmapped-copyable'],
['srv-1', 'unmapped-copyable'],
['sk-gone', 'source-deleted'],
['wf-other', 'workflow-missing'],
])
})
it('maps blocking entries to the wire blocker shape', () => {
const blocking = selectForkSyncBlockingRefs([referenceRef('table', 'tbl-1')])
expect(toForkSyncBlockers(blocking)).toEqual([
{
workflowName: 'Workflow',
blockLabel: 'Block',
fieldLabel: 'Field',
kind: 'table',
sourceId: 'tbl-1',
sourceLabel: 'Source',
reason: 'unmapped-copyable',
},
])
})
})
@@ -0,0 +1,63 @@
import {
type ForkClearedRef,
type ForkSyncBlocker,
type ForkSyncBlockerReason,
forkCopyableKindSchema,
} from '@/lib/api/contracts/workspace-fork'
/**
* Pure sync-blocker taxonomy, shared by the server gate (promote) and the modal's blocker
* rendering. A sync is allowed only when ZERO references would clear in any synced target
* workflow; every would-clear entry of cause `reference` or `workflow` is a blocker with an
* actionable reason. `dependent`-cause entries are NOT blockers - the dependent/reconfigure
* flow owns them (its own required gating), and a credential-anchored dependent clears on any
* parent remap, so blocking on it would be unresolvable.
*/
/** Copyable kinds derived from the wire contract, so the reason split can never drift. */
const COPYABLE_BLOCKER_KINDS: ReadonlySet<string> = new Set(forkCopyableKindSchema.options)
/**
* The blocker reason for a would-clear entry, or null when the entry does not block
* (`dependent` cause, and - defensively - any kind the cleared-ref collector excludes):
* - `workflow` cause -> `workflow-missing` (deploy the referenced workflow in the source, or
* remove the reference).
* - `reference` + source deleted -> `source-deleted` (map the dead id to a live target
* resource, or fix/archive the source workflow).
* - `reference` + copyable kind (incl. external MCP servers) -> `unmapped-copyable` (map it
* or select it for copy).
*/
export function forkSyncBlockerReasonFor(ref: ForkClearedRef): ForkSyncBlockerReason | null {
if (ref.cause === 'workflow') return 'workflow-missing'
if (ref.cause !== 'reference') return null
if (ref.sourceDeleted) return 'source-deleted'
if (COPYABLE_BLOCKER_KINDS.has(ref.kind)) return 'unmapped-copyable'
// Credential / env-var / knowledge-document never reach the cleared list (excluded by the
// collector; the first two gate via the kind-level required gate, documents follow their KB).
return null
}
/** The would-clear entries that BLOCK the sync, paired with their reason. */
export function selectForkSyncBlockingRefs(
clearedRefs: ForkClearedRef[]
): Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }> {
return clearedRefs.flatMap((ref) => {
const reason = forkSyncBlockerReasonFor(ref)
return reason ? [{ ref, reason }] : []
})
}
/** Map blocking entries to the wire {@link ForkSyncBlocker} shape of the promote gate error. */
export function toForkSyncBlockers(
blocking: Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }>
): ForkSyncBlocker[] {
return blocking.map(({ ref, reason }) => ({
workflowName: ref.workflowName,
blockLabel: ref.blockLabel,
fieldLabel: ref.fieldLabel,
kind: ref.kind,
sourceId: ref.sourceId,
sourceLabel: ref.sourceLabel,
reason,
}))
}
@@ -0,0 +1,88 @@
/**
* @vitest-environment node
*/
import { isValidUuid } from '@sim/utils/id'
import { describe, expect, it } from 'vitest'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
EMPTY_FORK_BLOCK_MAP,
type ForkBlockMap,
} from '@/ee/workspace-forking/lib/remap/block-identity'
describe('deriveForkBlockId', () => {
const targetA = 'wf-target-a'
const targetB = 'wf-target-b'
const block1 = 'block-1'
const block2 = 'block-2'
it('is deterministic for the same (targetWorkflowId, sourceBlockId)', () => {
expect(deriveForkBlockId(targetA, block1)).toBe(deriveForkBlockId(targetA, block1))
})
it('yields different ids for the same source block in different target workflows', () => {
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetB, block1))
})
it('yields different ids for different source blocks in the same target workflow', () => {
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetA, block2))
})
it('produces a valid UUID string (v5)', () => {
const id = deriveForkBlockId(targetA, block1)
expect(isValidUuid(id)).toBe(true)
expect(id[14]).toBe('5')
})
it('does not collide the colon separator (a:bc vs ab:c)', () => {
expect(deriveForkBlockId('a', 'bc')).not.toBe(deriveForkBlockId('ab', 'c'))
})
})
describe('buildForkBlockIdResolver', () => {
const parentWf = 'wf-parent'
const childWf = 'wf-child'
const parentBlock = 'block-parent'
// The pair the fork created: child block derived from the parent block.
const childBlock = deriveForkBlockId(childWf, parentBlock)
const seededMap: ForkBlockMap = {
parentToChild: new Map([
[parentBlock, { targetBlockId: childBlock, targetWorkflowId: childWf }],
]),
childToParent: new Map([
[childBlock, { targetBlockId: parentBlock, targetWorkflowId: parentWf }],
]),
}
it('push maps a child block back to the parent ORIGINAL id (keeps the webhook URL stable)', () => {
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve(parentWf, childBlock)).toBe(parentBlock)
// The bug this fixes: without the map, push would re-derive and re-key the parent block.
expect(pushResolve(parentWf, childBlock)).not.toBe(deriveForkBlockId(parentWf, childBlock))
})
it('pull maps a parent block to its existing child id', () => {
const pullResolve = buildForkBlockIdResolver(true, seededMap)
expect(pullResolve(childWf, parentBlock)).toBe(childBlock)
})
it('derives (does NOT reuse) when the target workflow was re-created (different id)', () => {
// Parent workflow archived + re-created as wf-parent-2: the pair points at the old
// workflow, so reusing parentBlock there would collide on the global block PK. Derive.
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve('wf-parent-2', childBlock)).toBe(
deriveForkBlockId('wf-parent-2', childBlock)
)
expect(pushResolve('wf-parent-2', childBlock)).not.toBe(parentBlock)
})
it('derives a fresh id for a source block with no recorded pair (added since last sync)', () => {
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve(parentWf, 'block-new')).toBe(deriveForkBlockId(parentWf, 'block-new'))
})
it('derives everything when the map is empty (fork creation)', () => {
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
expect(resolve(childWf, parentBlock)).toBe(deriveForkBlockId(childWf, parentBlock))
})
})
@@ -0,0 +1,100 @@
import { createHash } from 'node:crypto'
/**
* Fixed namespace UUID for fork block-identity derivation. Changing this value
* would re-key every forked workflow's block ids, breaking webhook URLs and
* external block references (table workflow groups, chat output configs) across
* promotes - so it must never change.
*/
const FORK_BLOCK_NAMESPACE = '6f1c0e2a-9b3d-5e47-8a1c-2d4f6b8e0c13'
function uuidToBytes(uuid: string): Buffer {
return Buffer.from(uuid.replace(/-/g, ''), 'hex')
}
/**
* Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs
* always yield the same UUID, which is how fork block identity stays stable.
*
* SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for deterministic id derivation,
* never for secrecy or integrity — not a security use of the algorithm. Swapping it would change
* every derived id, breaking webhook URLs and stored block-id references across existing forks
* (see {@link FORK_BLOCK_NAMESPACE}).
*/
function uuidV5(name: string, namespace: string): string {
const hash = createHash('sha1')
hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm]
hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm]
const bytes = hash.digest().subarray(0, 16)
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = bytes.toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/**
* Derive the target block id for a source block copied into a target workflow.
*
* Identity is deterministic in `(targetWorkflowId, sourceBlockId)`, so a logical
* block keeps the same target id across every promote. This is what keeps trigger
* webhook URLs consistent and keeps external block-id references (table workflow
* groups, chat output configs, sim_trigger_state) valid across promotes. A source
* block that no longer exists simply has no derived target, so the target block
* disappears on the next force-replace.
*/
export function deriveForkBlockId(targetWorkflowId: string, sourceBlockId: string): string {
return uuidV5(`${targetWorkflowId}:${sourceBlockId}`, FORK_BLOCK_NAMESPACE)
}
/** A persisted counterpart: the target block id plus the workflow it belongs to. */
export interface ForkBlockMapEntry {
targetBlockId: string
/** The target-side workflow the pair belongs to (childWorkflowId for parentToChild). */
targetWorkflowId: string
}
/** Persisted block-identity pairs for an edge, indexed for both promote directions. */
export interface ForkBlockMap {
/** parent block id -> { child block, child workflow } (pull/create resolve source=parent). */
parentToChild: ReadonlyMap<string, ForkBlockMapEntry>
/** child block id -> { parent block, parent workflow } (push resolves source=child). */
childToParent: ReadonlyMap<string, ForkBlockMapEntry>
}
/** An empty map - fork creation has no prior pairs, so every block id is derived fresh. */
export const EMPTY_FORK_BLOCK_MAP: ForkBlockMap = {
parentToChild: new Map(),
childToParent: new Map(),
}
/** Resolve a source block to its target block id for a promote (map-or-derive). */
export type ForkBlockIdResolver = (targetWorkflowId: string, sourceBlockId: string) => string
/**
* Build the block-id resolver a promote uses to assign target block ids. It reuses the
* persisted counterpart when one exists AND that pair belongs to the workflow being written,
* else falls back to {@link deriveForkBlockId} (blocks added since the last sync; fork
* creation, which has no map). `sourceIsParent` is true on pull/create (source = parent) and
* false on push (source = child); it selects the lookup direction.
*
* The workflow guard is what makes a re-created target safe: if the original target workflow
* was archived and the promote creates a new one, the recorded pair points at the OLD
* workflow, so it no longer matches and we derive a fresh id - never reusing the archived
* workflow's block id (which would collide on the global `workflow_blocks` primary key).
*
* For a stable workflow this still maps each child block back to the parent's ORIGINAL id on
* push, keeping its trigger webhook URL fixed. The SAME resolver must back
* `copyWorkflowStateIntoTarget` (which writes the blocks) and `collectForkDependentReconfigs`
* (which keys the modal's override by target block id), or the two would disagree.
*/
export function buildForkBlockIdResolver(
sourceIsParent: boolean,
map: ForkBlockMap
): ForkBlockIdResolver {
const existing = sourceIsParent ? map.parentToChild : map.childToParent
return (targetWorkflowId, sourceBlockId) => {
const entry = existing.get(sourceBlockId)
if (entry && entry.targetWorkflowId === targetWorkflowId) return entry.targetBlockId
return deriveForkBlockId(targetWorkflowId, sourceBlockId)
}
}
@@ -0,0 +1,43 @@
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
import {
clearDependentsOnRemap,
remapForkSubBlocks,
type SubBlockTransform,
} from '@/ee/workspace-forking/lib/remap/remap-references'
/**
* Resolves a source resource reference to its copied child id, or null when the
* resource was not copied into the fork. Credentials are never copied (always
* null), so credential references are cleared.
*/
export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string | null
/**
* A `copyWorkflowStateIntoTarget` transform for the initial fork. Runs the shared
* fork remapper in `create` mode: copyable resources the user selected are
* rewritten to their child ids; references to resources that were not copied (and
* all credential references) are cleared so the child workflow's subblocks start
* empty; env-var `{{KEY}}` references are preserved (name-based, they resolve once
* the child defines the key).
*/
export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): SubBlockTransform {
return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged) => {
// Every resolution at fork-create IS a copy (the resolver is the copy id map), so all
// remapped keys carry copy provenance - copy-faithful dependents (column picks) survive.
// `blockType`/`canonicalModes` activate the mode policy: active basic remaps, active
// advanced (manual) passes through with its dependents, dormant members clear.
const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create', {
blockType,
canonicalModes,
isCopiedTarget: (kind, sourceId) => resolveCopied(kind, sourceId) != null,
})
if (result.canonicalModes) onCanonicalModesChanged?.(result.canonicalModes)
return clearDependentsOnRemap(
result.subBlocks,
blockType,
result.remappedKeys,
result.canonicalModes ?? canonicalModes,
result.copyRemappedKeys
)
}
}
@@ -0,0 +1,67 @@
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
type ForkRemapKind,
scanWorkflowReferences,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/** A block reduced to what the reference scanner reads (incl. canonical context for detection). */
interface ScannerBlock {
id: string
name: string
type: string
subBlocks: unknown
canonicalModes?: CanonicalModeOverrides
}
/**
* Unique source ids of one remap kind referenced across a set of blocks - both top-level
* selectors and nested tool params. Used at fork/promote time to decide which KB documents
* a copy must create placeholders for (so their references remap to the copied doc instead
* of being cleared). The resolver is irrelevant here (every reference is detected regardless
* of mapping), so a null resolver is passed.
*/
export function collectReferencedResourceIds(
blocks: ScannerBlock[],
kind: ForkRemapKind
): Set<string> {
const ids = new Set<string>()
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
if (reference.kind === kind) ids.add(reference.sourceId)
}
return ids
}
/**
* Map a workflow state's blocks to the `{ id, name, subBlocks }` shape the reference scanner
* consumes. Confines the `subBlocks as unknown` widening (the stored subblock record is opaque
* to the scanner, which re-narrows per subblock type) to one spot shared by every fork caller
* that scans a source workflow's references.
*/
export function toScannerBlocks(state: WorkflowState): ScannerBlock[] {
return Object.values(state.blocks).map((block) => ({
id: block.id,
name: block.name,
type: block.type,
subBlocks: block.subBlocks as unknown,
canonicalModes: block.data?.canonicalModes,
}))
}
/**
* Unique knowledge-document ids referenced across a set of source workflow states. Fork and
* promote use this to decide which KB documents a copy must pre-create placeholders for, so a
* `document-selector` reference remaps to the copied doc instead of being cleared.
*/
export function collectReferencedDocumentIds(states: Iterable<WorkflowState>): Set<string> {
const ids = new Set<string>()
for (const state of states) {
for (const docId of collectReferencedResourceIds(
toScannerBlocks(state),
'knowledge-document'
)) {
ids.add(docId)
}
}
return ids
}
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
type ForkContentRefMaps,
rewriteForkContentRefs,
rewriteForkResourceUrls,
} from '@/ee/workspace-forking/lib/remap/remap-content-refs'
const SRC_KEY = 'workspace/SRC/1700000000000-deadbeef-photo.png'
const DST_KEY = 'workspace/DST/1700000000001-cafebabe-photo.png'
const maps = (): ForkContentRefMaps => ({
workspaceId: { from: 'SRC', to: 'DST' },
fileKeys: new Map([[SRC_KEY, DST_KEY]]),
fileIds: new Map([['file-src', 'file-dst']]),
workflows: new Map([['wf-src', 'wf-dst']]),
knowledgeBases: new Map([['kb-src', 'kb-dst']]),
tables: new Map([['tbl-src', 'tbl-dst']]),
skills: new Map([['skill-src', 'skill-dst']]),
folders: new Map([['fld-src', 'fld-dst']]),
})
describe('rewriteForkContentRefs - sim: links', () => {
it('remaps each mapped sim: link kind by its id map', () => {
const input = [
'see [F](sim:file/file-src)',
'[W](sim:workflow/wf-src)',
'[K](sim:knowledge/kb-src)',
'[T](sim:table/tbl-src)',
'[S](sim:skill/skill-src)',
'[D](sim:folder/fld-src)',
].join(' ')
expect(rewriteForkContentRefs(input, maps())).toBe(
[
'see [F](sim:file/file-dst)',
'[W](sim:workflow/wf-dst)',
'[K](sim:knowledge/kb-dst)',
'[T](sim:table/tbl-dst)',
'[S](sim:skill/skill-dst)',
'[D](sim:folder/fld-dst)',
].join(' ')
)
})
it('leaves an unmapped id unchanged (graceful broken link)', () => {
const input = '[F](sim:file/unknown-file) and [I](sim:integration/gmail_v2)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves a kind with no supplied map unchanged', () => {
const input = '[W](sim:workflow/wf-src)'
expect(rewriteForkContentRefs(input, { fileIds: new Map() })).toBe(input)
})
})
describe('rewriteForkContentRefs - embedded urls', () => {
it('remaps a serve-url storage key (encoded form output)', () => {
const input = `![a](/api/files/serve/${encodeURIComponent(SRC_KEY)}?context=workspace)`
expect(rewriteForkContentRefs(input, maps())).toBe(
`![a](/api/files/serve/${encodeURIComponent(DST_KEY)}?context=workspace)`
)
})
it('remaps a serve-url key given in raw (unencoded) form', () => {
const input = `![a](/api/files/serve/${SRC_KEY})`
expect(rewriteForkContentRefs(input, maps())).toBe(
`![a](/api/files/serve/${encodeURIComponent(DST_KEY)})`
)
})
it('remaps an s3/blob-prefixed serve url, preserving the prefix', () => {
const input = `![a](/api/files/serve/s3/${encodeURIComponent(SRC_KEY)})`
expect(rewriteForkContentRefs(input, maps())).toBe(
`![a](/api/files/serve/s3/${encodeURIComponent(DST_KEY)})`
)
})
it('remaps a view-url file id', () => {
const input = '![a](/api/files/view/file-src)'
expect(rewriteForkContentRefs(input, maps())).toBe('![a](/api/files/view/file-dst)')
})
it('remaps both the workspace id and file id in an in-app files path', () => {
const input = '![a](/workspace/SRC/files/file-src)'
expect(rewriteForkContentRefs(input, maps())).toBe('![a](/workspace/DST/files/file-dst)')
})
it('leaves a foreign-workspace in-app file path unchanged (both-or-nothing)', () => {
// The ws id is not the mapped source, so emitting the child file id under OTHER would 404.
const input = '![a](/workspace/OTHER/files/file-src)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves an in-app file path unchanged when the file id is unmapped (both-or-nothing)', () => {
const input = '![a](/workspace/SRC/files/unknown-file)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves an unmapped storage key / file id unchanged', () => {
const input =
'![a](/api/files/serve/workspace%2FSRC%2Funknown.png) ![b](/api/files/view/unknown-id)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves an external / data url unchanged', () => {
const input = '![a](https://cdn.example.com/x.png) ![b](data:image/png;base64,AAAA)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
})
describe('rewriteForkContentRefs - mixed and edge cases', () => {
it('rewrites multiple references of different shapes in one string', () => {
const input = [
'intro [S](sim:skill/skill-src)',
`![img](/api/files/serve/${encodeURIComponent(SRC_KEY)})`,
'link [W](sim:workflow/wf-src)',
'![v](/api/files/view/file-src)',
].join('\n')
const output = rewriteForkContentRefs(input, maps())
expect(output).toContain('sim:skill/skill-dst')
expect(output).toContain('sim:workflow/wf-dst')
expect(output).toContain(encodeURIComponent(DST_KEY))
expect(output).toContain('/api/files/view/file-dst')
})
it('returns the input unchanged when there are no references', () => {
const input = '# Heading\n\nNo references here, just text.'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('returns the input unchanged for an empty string', () => {
expect(rewriteForkContentRefs('', maps())).toBe('')
})
it('leaves a malformed (un-decodable) serve key unchanged', () => {
const input = '![a](/api/files/serve/%E0%A4%A)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('does nothing when no maps are supplied', () => {
const input = `[S](sim:skill/skill-src) ![a](/api/files/serve/${SRC_KEY})`
expect(rewriteForkContentRefs(input, {})).toBe(input)
})
})
describe('rewriteForkResourceUrls - table cell resource chip urls', () => {
it('rewrites the workspace id + resource id for each section when the resource is mapped', () => {
expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-src', maps())).toBe(
'/workspace/DST/w/wf-dst'
)
expect(rewriteForkResourceUrls('/workspace/SRC/tables/tbl-src', maps())).toBe(
'/workspace/DST/tables/tbl-dst'
)
expect(rewriteForkResourceUrls('/workspace/SRC/knowledge/kb-src', maps())).toBe(
'/workspace/DST/knowledge/kb-dst'
)
expect(rewriteForkResourceUrls('/workspace/SRC/files/file-src', maps())).toBe(
'/workspace/DST/files/file-dst'
)
})
it('leaves an unmapped resource id unchanged (both-or-nothing -> graceful plain link)', () => {
expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-unknown', maps())).toBe(
'/workspace/SRC/w/wf-unknown'
)
})
it('leaves a foreign / unknown workspace id unchanged', () => {
expect(rewriteForkResourceUrls('/workspace/OTHER/w/wf-src', maps())).toBe(
'/workspace/OTHER/w/wf-src'
)
})
it('leaves a non-matching string (unknown section / no resource path) unchanged', () => {
const input = 'text /workspace/ and /workspace/SRC/settings/x and /workspace/SRC/w/'
expect(rewriteForkResourceUrls(input, maps())).toBe(input)
})
it('does nothing without a workspaceId map', () => {
const input = '/workspace/SRC/w/wf-src'
expect(rewriteForkResourceUrls(input, { workflows: new Map([['wf-src', 'wf-dst']]) })).toBe(
input
)
})
it('rewrites a URL embedded mid-text', () => {
const input = 'See [chip](/workspace/SRC/knowledge/kb-src) here'
expect(rewriteForkResourceUrls(input, maps())).toBe(
'See [chip](/workspace/DST/knowledge/kb-dst) here'
)
})
})
@@ -0,0 +1,127 @@
/**
* Pure rewriter for the in-content references embedded in copied free-text (skill bodies,
* markdown file blobs) at fork/sync time. It rewrites the two reference shapes a copy must
* keep pointing at the right workspace:
*
* - `sim:<kind>/<id>` deep links (the `@`-mention / chip scheme) - the id is remapped through
* the matching fork id map by kind (file, folder, table, knowledge, workflow, skill).
* - Embedded file/image URLs - `/api/files/serve/<key>` (workspace storage key), `/api/files/view/<id>`
* (workspace file id), and the in-app `/workspace/<id>/files/<id>` path - remapped through the file
* key / file id / workspace id maps.
*
* A reference whose target has no mapping is LEFT UNCHANGED (a graceful broken link), never deleted,
* so a copied document is never silently corrupted. Pure and isomorphic (no DOM/Node/DB).
*/
/** Per-kind source->target id maps a content copy threads in (any subset may be supplied). */
export interface ForkContentRefMaps {
/** Workspace id rewrite for the in-app `/workspace/<id>/files/...` path. */
workspaceId?: { from: string; to: string }
/** source workspace-file storage key -> child storage key (serve-url embeds). */
fileKeys?: ReadonlyMap<string, string>
/** source workspace-file id -> child id (view-url + in-app-path embeds). */
fileIds?: ReadonlyMap<string, string>
/** source workflow id -> child id (`sim:workflow/<id>`). */
workflows?: ReadonlyMap<string, string>
/** source knowledge-base id -> child id (`sim:knowledge/<id>`). */
knowledgeBases?: ReadonlyMap<string, string>
/** source table id -> child id (`sim:table/<id>`). */
tables?: ReadonlyMap<string, string>
/** source skill id -> child id (`sim:skill/<id>`). */
skills?: ReadonlyMap<string, string>
/** source folder id -> child id (`sim:folder/<id>`). */
folders?: ReadonlyMap<string, string>
}
/** `sim:<kind>/<id>` token; the id charset matches generateId/generateShortId so it stops at delimiters. */
const SIM_LINK_RE = /sim:([a-z_]+)\/([A-Za-z0-9_-]+)/g
/** `/api/files/serve/[s3/|blob/]<key>` (key may be raw or percent-encoded, ends at a delimiter). */
const SERVE_URL_RE = /\/api\/files\/serve\/(s3\/|blob\/)?([^\s)"'<>?]+)/g
/** `/api/files/view/<workspaceFileId>`. */
const VIEW_URL_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g
/** In-app `/workspace/<workspaceId>/files/<workspaceFileId>` embed path. */
const INAPP_FILE_RE = /\/workspace\/([A-Za-z0-9-]+)\/files\/([A-Za-z0-9_-]+)/g
export function rewriteForkContentRefs(content: string, maps: ForkContentRefMaps): string {
if (!content) return content
const idMapForSimKind: Record<string, ReadonlyMap<string, string> | undefined> = {
file: maps.fileIds,
folder: maps.folders,
table: maps.tables,
knowledge: maps.knowledgeBases,
workflow: maps.workflows,
skill: maps.skills,
}
let result = content.replace(SIM_LINK_RE, (full, kind: string, id: string) => {
const target = idMapForSimKind[kind]?.get(id)
return target ? `sim:${kind}/${target}` : full
})
result = result.replace(SERVE_URL_RE, (full, prefix: string | undefined, encodedKey: string) => {
if (!maps.fileKeys) return full
let key: string
try {
key = decodeURIComponent(encodedKey)
} catch {
return full
}
const target = maps.fileKeys.get(key)
return target ? `/api/files/serve/${prefix ?? ''}${encodeURIComponent(target)}` : full
})
result = result.replace(VIEW_URL_RE, (full, id: string) => {
const target = maps.fileIds?.get(id)
return target ? `/api/files/view/${target}` : full
})
// Both-or-nothing: rewrite only when the workspace id is the mapped source AND the file id
// resolves, so we never emit a child-workspace path with an unmapped (guaranteed-404) id -
// matching the serve/view branches and rewriteForkResourceUrls. A foreign workspace id or an
// unmapped file id leaves the original path untouched (graceful).
result = result.replace(INAPP_FILE_RE, (full, wsId: string, fileId: string) => {
if (maps.workspaceId?.from !== wsId) return full
const mappedFile = maps.fileIds?.get(fileId)
return mappedFile ? `/workspace/${maps.workspaceId.to}/files/${mappedFile}` : full
})
return result
}
/**
* In-app `/workspace/<wsId>/(w|tables|knowledge|files)/<resourceId>` deep link - the form a TABLE
* CELL renders as a resource chip (`resolveSimResourceKind`), but ONLY when `wsId` matches the
* current workspace. The id charset stops at delimiters.
*/
const RESOURCE_URL_RE =
/\/workspace\/([A-Za-z0-9-]+)\/(w|tables|knowledge|files)\/([A-Za-z0-9_-]+)/g
/**
* Rewrite the in-workspace resource deep links a copied table cell renders as a resource chip, so
* the chip keeps resolving after a cross-workspace copy (a cell chip renders only when the URL's
* workspace id is the current workspace, so a stale source id silently degrades to a plain link).
* Repoints both the workspace id and the resource id at the child copy, per section: `w` ->
* workflows, `tables` -> tables, `knowledge` -> knowledge bases, `files` -> file ids.
*
* Both-or-nothing: a match is rewritten ONLY when its workspace id is the mapped source AND its
* resource id is in that section's map - otherwise it is left UNCHANGED. Emitting a child-workspace
* URL with an unmapped id would render a "Not found" chip (worse than the graceful plain link an
* unchanged URL degrades to). Distinct from {@link rewriteForkContentRefs}: table cells do NOT
* specially render the `sim:` / serve / view forms that skill + markdown bodies do.
*/
export function rewriteForkResourceUrls(content: string, maps: ForkContentRefMaps): string {
if (!content || !maps.workspaceId) return content
const { from, to } = maps.workspaceId
const idMapForSection: Record<string, ReadonlyMap<string, string> | undefined> = {
w: maps.workflows,
tables: maps.tables,
knowledge: maps.knowledgeBases,
files: maps.fileIds,
}
return content.replace(RESOURCE_URL_RE, (full, wsId: string, section: string, id: string) => {
if (wsId !== from) return full
const mappedId = idMapForSection[section]?.get(id)
return mappedId ? `/workspace/${to}/${section}/${mappedId}` : full
})
}
@@ -0,0 +1,52 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { remapForkFileUploadValue } from '@/ee/workspace-forking/lib/remap/remap-files'
const map = (entries: Record<string, string>) => (key: string) => entries[key] ?? null
describe('remapForkFileUploadValue', () => {
it('rewrites a copied single object key, preserving other fields', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 }
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual({ key: 'child/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 })
})
it('clears a single object whose file was not copied', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf' }
expect(remapForkFileUploadValue(value, map({}))).toBe('')
})
it('remaps copied items and drops uncopied ones in an array', () => {
const value = [
{ key: 'src/a.pdf', name: 'a.pdf' },
{ key: 'src/b.pdf', name: 'b.pdf' },
]
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual([{ key: 'child/a.pdf', name: 'a.pdf' }])
})
it('handles a JSON-stringified value and re-serializes', () => {
const value = JSON.stringify({ key: 'src/a.pdf', name: 'a.pdf' })
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toBe(JSON.stringify({ key: 'child/a.pdf', name: 'a.pdf' }))
})
it('falls back to the path field when there is no key', () => {
const value = { path: 'src/a.pdf', name: 'a.pdf' }
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual({ path: 'child/a.pdf', name: 'a.pdf' })
})
it('returns the value unchanged when no items match', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf' }
const sameKey = map({ 'src/a.pdf': 'src/a.pdf' })
expect(remapForkFileUploadValue(value, sameKey)).toBe(value)
})
it('returns empty/unparseable values untouched', () => {
expect(remapForkFileUploadValue('', map({}))).toBe('')
expect(remapForkFileUploadValue(null, map({}))).toBe(null)
})
})
@@ -0,0 +1,101 @@
/**
* `file-upload` subblock remapping for fork/promote.
*
* A `file-upload` value is a workspace-file reference (or array of them) stored as
* objects `{ key, name, ... }` where `key` is the object-storage key (NOT the
* `workspace_files.id`). Forking copies the blob to a new key; this rewrites each
* reference's key to the copied key, preserving the rest of the object. References
* whose file was not copied are dropped (the field is emptied) rather than left
* pointing at another workspace's blob. External `file-selector` references
* (provider file ids, credential-scoped) are NOT handled here - they carry over
* unchanged.
*/
function parseMaybeJson(value: unknown): { value: unknown; serialized: boolean } {
if (typeof value !== 'string') return { value, serialized: false }
const trimmed = value.trim()
const looksJson =
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
if (!looksJson) return { value, serialized: false }
try {
return { value: JSON.parse(trimmed), serialized: true }
} catch {
return { value, serialized: false }
}
}
/** The field a file-upload item uses as its storage key, and that key's value. */
function fileItemKeyField(item: unknown): { field: 'key' | 'path' | 'name'; key: string } | null {
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
const record = item as Record<string, unknown>
for (const field of ['key', 'path', 'name'] as const) {
const value = record[field]
if (typeof value === 'string' && value.trim().length > 0) return { field, key: value }
}
return null
}
/**
* Enumerate the workspace-file storage keys referenced by a `file-upload` subblock
* value (single object, array, or JSON-string form). Used at promote time to emit each
* workspace file as a `file` reference (keyed by storage key) so it surfaces in the
* scan / unmapped set and can be copied into the target. Deduplicated, order-preserving.
*/
export function collectForkFileUploadKeys(value: unknown): string[] {
const parsed = parseMaybeJson(value)
const items = Array.isArray(parsed.value)
? (parsed.value as unknown[])
: parsed.value
? [parsed.value]
: []
const keys: string[] = []
const seen = new Set<string>()
for (const item of items) {
const keyInfo = fileItemKeyField(item)
if (!keyInfo || seen.has(keyInfo.key)) continue
seen.add(keyInfo.key)
keys.push(keyInfo.key)
}
return keys
}
/**
* Remap a `file-upload` subblock value. `resolveFileKey(sourceKey)` returns the
* copied target storage key, or null when the file was not copied (drop the ref).
*/
export function remapForkFileUploadValue(
value: unknown,
resolveFileKey: (sourceKey: string) => string | null
): unknown {
const parsed = parseMaybeJson(value)
const isArray = Array.isArray(parsed.value)
const items = isArray ? (parsed.value as unknown[]) : parsed.value ? [parsed.value] : []
if (items.length === 0) return value
const next: unknown[] = []
let changed = false
for (const item of items) {
const keyInfo = fileItemKeyField(item)
if (!keyInfo) {
next.push(item)
continue
}
const targetKey = resolveFileKey(keyInfo.key)
if (targetKey == null) {
changed = true
continue
}
if (targetKey === keyInfo.key) {
next.push(item)
continue
}
changed = true
next.push({ ...(item as Record<string, unknown>), [keyInfo.field]: targetKey })
}
if (!changed) return value
if (next.length === 0) return ''
if (isArray) return parsed.serialized ? JSON.stringify(next) : next
return parsed.serialized ? JSON.stringify(next[0]) : next[0]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { TableSchema } from '@/lib/table/types'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
} from '@/ee/workspace-forking/lib/remap/block-identity'
import { remapForkTableWorkflowGroups } from '@/ee/workspace-forking/lib/remap/remap-table-groups'
describe('remapForkTableWorkflowGroups', () => {
it('remaps a manual group workflowId and outputs[].blockId to child ids', () => {
const map = new Map([['src-wf', 'child-wf']])
const schema: TableSchema = {
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
workflowGroups: [
{
id: 'g1',
workflowId: 'src-wf',
outputs: [{ blockId: 'src-block', path: 'out', columnName: 'col_1' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, map)
const group = result.workflowGroups?.[0]
expect(group?.workflowId).toBe('child-wf')
expect(group?.outputs[0].blockId).toBe(deriveForkBlockId('child-wf', 'src-block'))
expect(group?.outputs[0].columnName).toBe('col_1')
expect(result.columns[0].id).toBe('col_1')
expect(result.columns[0].workflowGroupId).toBe('g1')
})
// Promote threads its persisted-pair resolver: a paired block resolves to the pair's target id
// (on push, the parent's ORIGINAL id - never the derive); an unpaired block falls back to the
// derive, matching the workflow write path.
it('prefers a provided block-id resolver (persisted pair) over the derive, deriving unpaired blocks', () => {
const map = new Map([['src-wf', 'child-wf']])
const schema: TableSchema = {
columns: [],
workflowGroups: [
{
id: 'g1',
workflowId: 'src-wf',
outputs: [
{ blockId: 'src-block', path: 'out', columnName: 'col_1' },
{ blockId: 'src-unpaired', path: 'out2', columnName: 'col_2' },
],
},
],
}
const resolver = buildForkBlockIdResolver(true, {
parentToChild: new Map([
['src-block', { targetBlockId: 'original-parent-block', targetWorkflowId: 'child-wf' }],
]),
childToParent: new Map(),
})
const result = remapForkTableWorkflowGroups(schema, map, resolver)
const outputs = result.workflowGroups?.[0].outputs
expect(outputs?.[0].blockId).toBe('original-parent-block')
expect(outputs?.[1].blockId).toBe(deriveForkBlockId('child-wf', 'src-unpaired'))
})
it('drops a group whose backing workflow was not copied and clears its column wiring', () => {
const schema: TableSchema = {
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
workflowGroups: [
{
id: 'g1',
workflowId: 'missing-wf',
outputs: [{ blockId: 'b', path: 'p', columnName: 'col_1' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, new Map())
expect(result.workflowGroups).toHaveLength(0)
expect(result.columns[0].workflowGroupId).toBeUndefined()
expect(result.columns[0].id).toBe('col_1')
})
it('leaves enrichment groups (empty workflowId) untouched', () => {
const schema: TableSchema = {
columns: [],
workflowGroups: [
{
id: 'g1',
workflowId: '',
enrichmentId: 'enr',
outputs: [{ blockId: '', path: '', columnName: 'col_1', outputId: 'o' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, new Map([['src-wf', 'child-wf']]))
expect(result.workflowGroups?.[0]).toEqual(schema.workflowGroups?.[0])
})
it('returns the schema unchanged when there are no groups', () => {
const schema: TableSchema = { columns: [{ id: 'col_1', name: 'A', type: 'string' }] }
expect(remapForkTableWorkflowGroups(schema, new Map())).toBe(schema)
})
})
@@ -0,0 +1,62 @@
import type { TableSchema } from '@/lib/table/types'
import {
deriveForkBlockId,
type ForkBlockIdResolver,
} from '@/ee/workspace-forking/lib/remap/block-identity'
/**
* Remap the workflow/block references embedded in a copied table's schema so its
* workflow groups keep working in the child workspace. `workflowGroups[].workflowId`
* is rewritten through the source→child workflow identity map, and each
* `outputs[].blockId` is rewritten through `resolveBlockId` - which MUST be the
* same resolver that assigns the target workflows' block ids, or the outputs
* point at nonexistent blocks. Fork-create omits it and defaults to the
* deterministic {@link deriveForkBlockId} (a fresh child has no persisted
* pairs, matching `copyWorkflowStateIntoTarget`); promote passes its
* persisted-pair resolver (a push keeps the parent's ORIGINAL block ids, which
* never equal the derive). Manual groups whose backing workflow was not
* copied are dropped, and any columns wired to a dropped group have their
* `workflowGroupId` cleared. Enrichment groups (empty `workflowId`) and column
* ids are left untouched.
*/
export function remapForkTableWorkflowGroups(
schema: TableSchema,
workflowIdMap: Map<string, string>,
resolveBlockId: ForkBlockIdResolver = deriveForkBlockId
): TableSchema {
const groups = schema.workflowGroups ?? []
if (groups.length === 0) return schema
const droppedGroupIds = new Set<string>()
const remappedGroups = groups.flatMap((group) => {
if (!group.workflowId) return [group]
const childWorkflowId = workflowIdMap.get(group.workflowId)
if (!childWorkflowId) {
droppedGroupIds.add(group.id)
return []
}
return [
{
...group,
workflowId: childWorkflowId,
outputs: group.outputs.map((output) => ({
...output,
blockId: output.blockId
? resolveBlockId(childWorkflowId, output.blockId)
: output.blockId,
})),
},
]
})
const columns =
droppedGroupIds.size === 0
? schema.columns
: schema.columns.map((column) =>
column.workflowGroupId && droppedGroupIds.has(column.workflowGroupId)
? { ...column, workflowGroupId: undefined }
: column
)
return { ...schema, columns, workflowGroups: remappedGroups }
}
@@ -0,0 +1,43 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkspaceForkSocket')
async function postToRealtime(path: string, workflowId: string): Promise<void> {
const response = await fetch(`${getSocketServerUrl()}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET },
body: JSON.stringify({ workflowId }),
})
if (!response.ok) {
throw new Error(`${path} responded ${response.status}`)
}
}
/**
* Notify connected canvas clients that a fork promote/rollback force-replaced a
* workflow's state. This mirrors a mothership edit rather than a passive ping:
*
* - `workflow-updated` makes each client reload the workflow from the API the same
* way it reacts to an external full-state edit (copilot / state route), deferring
* while a local diff or unsaved operations are pending so it never clobbers
* in-flight work. Without this the canvas keeps the stale state and a later local
* edit would overwrite the freshly-synced state.
* - `workflow-deployed` refreshes the deployment indicator (the promote/rollback also
* changed which version is deployed).
*
* Best-effort and independent: each notification is attempted regardless of the
* other, and a failure only warns - it never blocks the promote/rollback.
*/
export async function notifyForkWorkflowChanged(workflowId: string): Promise<void> {
const results = await Promise.allSettled([
postToRealtime('/api/workflow-updated', workflowId),
postToRealtime('/api/workflow-deployed', workflowId),
])
for (const result of results) {
if (result.status === 'rejected') {
logger.warn('Fork sync socket notification failed', { workflowId, error: result.reason })
}
}
}