feat(forking): resource copying UX to help with setup speed (#5294)

* feat(forking): resource copying UX to help with setup speed

* update UX nits

* address comments

* fix canonical modes and dependson behaviour in tool input

* fix skill memory bounding;
This commit is contained in:
Vikhyath Mondreti
2026-06-30 17:55:50 -07:00
committed by GitHub
parent ed6e8c2d12
commit 7662ecc117
80 changed files with 24972 additions and 623 deletions
@@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -18,13 +18,9 @@ export const GET = withRouteHandler(
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const access = await checkWorkspaceAccess(id, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.canAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// The fork Activity feed is a fork feature: gate it behind the same forking-enabled +
// workspace-admin check the other fork routes use, instead of a bare access check.
await assertWorkspaceAdminAccess(id, session.user.id)
const rows = await listSurfacedBackgroundWork(db, id)
return NextResponse.json({
@@ -1,4 +1,6 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
@@ -16,6 +18,8 @@ import {
forkDependentValueKey,
loadForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs'
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
@@ -63,10 +67,17 @@ export const GET = withRouteHandler(
const replaceTargetIds = plan.items
.filter((item) => item.mode === 'replace')
.map((item) => item.targetWorkflowId)
const [storedValues, targetDraftByWorkflow] = await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
])
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
listForkResourceCandidates(db, auth.sourceWorkspaceId),
db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
])
const storedByKey = new Map(
storedValues.map((entry) => [
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
@@ -108,6 +119,24 @@ export const GET = withRouteHandler(
),
}))
// References this sync will blank in the target (per block/field), for the pre-sync cleared-ref
// list. Labels resolve from the source candidate lists + workflow names loaded above.
const sourceLabels = new Map<string, string>()
for (const [kind, candidates] of Object.entries(sourceCandidates)) {
for (const candidate of candidates)
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
}
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
const clearedRefs = collectForkClearedRefCandidates({
items: plan.items,
sourceStates,
resolver: plan.resolver,
workflowIdMap: plan.workflowIdMap,
resolveBlockId,
sourceLabels,
sourceWorkflowNames,
})
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
sourceId: reference.sourceId,
@@ -155,6 +184,8 @@ export const GET = withRouteHandler(
inlineSecretSources: plan.inlineSecretSources,
dependentReconfigs,
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
copyableUnmapped: plan.copyableUnmapped,
clearedRefs,
})
}
)
@@ -25,7 +25,7 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(promoteForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, dependentValues } = parsed.data.body
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
@@ -36,6 +36,7 @@ export const POST = withRouteHandler(
direction,
userId: session.user.id,
dependentValues,
copyResources,
requestId,
})
@@ -39,7 +39,7 @@ export const POST = withRouteHandler(
knowledgeBases: copy?.knowledgeBases ?? [],
customTools: copy?.customTools ?? [],
skills: copy?.skills ?? [],
mcpServers: copy?.mcpServers ?? [],
workflowMcpServers: copy?.workflowMcpServers ?? [],
},
requestId,
})
@@ -5,6 +5,8 @@ import { useMarkdownMentions } from './use-markdown-mentions'
interface UseEditorMentionsOptions {
/** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */
navigable?: boolean
/** Force the `@` insertion menu off even with a workspace; existing tags still render. */
disableTagging?: boolean
}
/**
@@ -20,17 +22,18 @@ export function useEditorMentions(
const [active, setActive] = useState(false)
const items = useMarkdownMentions(workspaceId, { enabled: active })
const navigable = options?.navigable ?? false
const disableTagging = options?.disableTagging ?? false
useEffect(() => {
if (!editor) return
const hasWorkspace = Boolean(workspaceId)
editor.storage.mention.enabled = hasWorkspace
const taggingOn = Boolean(workspaceId) && !disableTagging
editor.storage.mention.enabled = taggingOn
editor.storage.mention.navigable = navigable
editor.storage.mention.onOpen = hasWorkspace ? () => setActive(true) : null
editor.storage.mention.onOpen = taggingOn ? () => setActive(true) : null
return () => {
editor.storage.mention.onOpen = null
}
}, [editor, workspaceId, navigable])
}, [editor, workspaceId, navigable, disableTagging])
useEffect(() => {
editor?.storage.mention.store.set(items)
@@ -56,6 +56,8 @@ interface RichMarkdownEditorProps {
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
/** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */
disableTagging?: boolean
}
/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
@@ -72,6 +74,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
streamIsIncremental,
disableStreamingAutoScroll = false,
previewContextKey,
disableTagging,
}: RichMarkdownEditorProps) {
const {
content,
@@ -113,6 +116,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
autoFocus={autoFocus}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
disableTagging={disableTagging}
onChange={setDraftContent}
onSaveShortcut={saveImmediately}
/>
@@ -131,6 +135,7 @@ interface LoadedRichMarkdownEditorProps {
/** See {@link RichMarkdownEditorProps.streamIsIncremental}. */
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
disableTagging?: boolean
onChange: (markdown: string) => void
onSaveShortcut: () => Promise<void>
}
@@ -155,6 +160,7 @@ export function LoadedRichMarkdownEditor({
autoFocus,
streamIsIncremental,
disableStreamingAutoScroll,
disableTagging,
onChange,
onSaveShortcut,
}: LoadedRichMarkdownEditorProps) {
@@ -339,7 +345,7 @@ export function LoadedRichMarkdownEditor({
}
}, [editor])
useEditorMentions(editor, workspaceId, { navigable: true })
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging })
const wasStreamingRef = useRef(streamingAtMountRef.current)
@@ -38,6 +38,8 @@ interface RichMarkdownFieldProps {
error?: boolean
/** Enables the `@` mention menu scoped to this workspace. Omit to disable mentions. */
workspaceId?: string
/** Force the `@` tag-insertion menu off even with a workspace set (existing tags still render). */
disableTagging?: boolean
/**
* Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste
* (e.g. a full document the host destructures elsewhere); `false` to fall through to normal
@@ -62,6 +64,7 @@ function LoadedRichMarkdownField({
maxHeight = 360,
error = false,
workspaceId,
disableTagging,
onPasteText,
}: RichMarkdownFieldProps) {
const containerRef = useRef<HTMLDivElement>(null)
@@ -166,7 +169,7 @@ function LoadedRichMarkdownField({
if (editor.isEditable !== !disabled) editor.setEditable(!disabled)
}, [editor, value, isStreaming, disabled])
useEditorMentions(editor, workspaceId)
useEditorMentions(editor, workspaceId, { disableTagging })
return (
<div
@@ -147,6 +147,7 @@ export function VersionDescriptionModal({
isStreaming={isGenerating}
error={description.length > MAX_DESCRIPTION_LENGTH}
workspaceId={workspaceId}
disableTagging
/>
</ChipModalField>
<ChipModalError>
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Tool', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
describe('clearDependentToolParams', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('clears a non-empty dependent when its parent changes', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
])
)
const result = clearDependentToolParams(
'gmail',
{ credential: 'cred-2', folder: 'INBOX' },
'credential'
)
expect(result.folder).toBe('')
// The changed param itself is untouched.
expect(result.credential).toBe('cred-2')
})
it('clears transitively (a grandchild dependent is also cleared)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
{ id: 'thread', title: 'Thread', type: 'short-input', dependsOn: ['folder'] },
])
)
const result = clearDependentToolParams(
'gmail',
{ credential: 'cred-2', folder: 'INBOX', thread: 't-1' },
'credential'
)
expect(result.folder).toBe('')
expect(result.thread).toBe('')
})
it('clears a dependent when a canonical-pair member changes (advanced member, dependent on the canonical id)', () => {
vi.mocked(getBlock).mockReturnValue(
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'] },
])
)
const result = clearDependentToolParams(
'gmail',
{ manualCredential: 'mc-2', folder: 'INBOX' },
'manualCredential'
)
// The shared walk expands the canonical group, so an advanced-member change clears the dependent.
expect(result.folder).toBe('')
})
it('leaves an already-empty dependent and a non-dependent param untouched (same reference)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
{ id: 'subject', title: 'Subject', type: 'short-input' },
])
)
const params = { credential: 'cred-2', folder: '', subject: 'keep' }
const result = clearDependentToolParams('gmail', params, 'credential')
// The only dependent is already empty, so nothing changes - the same reference is returned.
expect(result).toBe(params)
expect(result.subject).toBe('keep')
})
it('returns equivalent params when the changed param has no dependents', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'subject', title: 'Subject', type: 'short-input' },
])
)
const params = { credential: 'cred-2', subject: 'hello' }
const result = clearDependentToolParams('gmail', params, 'subject')
expect(result).toBe(params)
})
})
@@ -0,0 +1,25 @@
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import { getBlock } from '@/blocks/registry'
/**
* Clear every TRANSITIVE `dependsOn` descendant of `changedParamId` in a nested tool's params,
* mirroring the top-level block clear (`use-collaborative-workflow`). Reuses the shared
* {@link getWorkflowSearchDependentClears} walk - transitive BFS plus canonical-pair expansion, so a
* basic OR advanced member change clears the dependent - so both surfaces clear identically. Only
* descendants that currently hold a non-empty value are reset to `''`; the changed param itself and
* non-descendants are untouched. Returns the same reference when nothing changed.
*/
export function clearDependentToolParams(
toolType: string,
params: Record<string, string>,
changedParamId: string
): Record<string, string> {
const subBlocks = getBlock(toolType)?.subBlocks ?? []
let next: Record<string, string> | null = null
for (const { subBlockId } of getWorkflowSearchDependentClears(subBlocks, changedParamId)) {
if (!params[subBlockId]) continue
next ??= { ...params }
next[subBlockId] = ''
}
return next ?? params
}
@@ -42,6 +42,7 @@ import {
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector'
import { ParameterWithLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter'
import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer'
import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents'
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
import {
isCustomToolAlreadySelected,
@@ -106,31 +107,11 @@ import {
resolveCanonicalMode,
resolveDependencyValue,
type SubBlockCondition,
scopeCanonicalModesForTool,
} from '@/tools/params-resolver'
const logger = createLogger('ToolInput')
/**
* Extracts canonical mode overrides scoped to a specific tool type.
* Canonical modes are stored with `{blockType}:{canonicalId}` keys to prevent
* cross-tool collisions when multiple tools share the same canonicalParamId.
*/
function scopeCanonicalOverrides(
overrides: CanonicalModeOverrides | undefined,
blockType: string | undefined
): CanonicalModeOverrides | undefined {
if (!overrides || !blockType) return undefined
const prefix = `${blockType}:`
let scoped: CanonicalModeOverrides | undefined
for (const [key, val] of Object.entries(overrides)) {
if (key.startsWith(prefix) && val) {
if (!scoped) scoped = {}
scoped[key.slice(prefix.length)] = val
}
}
return scoped
}
/**
* Renders the input for workflow_executor's inputMapping parameter.
* This is a special case that doesn't map to any SubBlockConfig, so it's kept here.
@@ -535,15 +516,7 @@ export const ToolInput = memo(function ToolInput({
const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type)
if (!blockConfig?.subBlocks) continue
const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks)
const scopedOverrides: CanonicalModeOverrides = {}
if (canonicalModeOverrides) {
for (const [key, val] of Object.entries(canonicalModeOverrides)) {
const prefix = `${tool.type}:`
if (key.startsWith(prefix) && val) {
scopedOverrides[key.slice(prefix.length)] = val as 'basic' | 'advanced'
}
}
}
const scopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type)
const reactiveSubBlock = blockConfig.subBlocks.find(
(sb: { reactiveCondition?: unknown }) => sb.reactiveCondition
)
@@ -977,17 +950,17 @@ export const ToolInput = memo(function ToolInput({
if (isPreview || disabled) return
setStoreValue(
selectedTools.map((tool, index) =>
index === toolIndex
? {
...tool,
params: {
...tool.params,
[paramId]: paramValue,
},
}
: tool
)
selectedTools.map((tool, index) => {
if (index !== toolIndex) return tool
// Clear the changed param's transitive `dependsOn` descendants (mirrors the top-level
// block clear), so a child scoped to the old parent isn't left stale.
const params = clearDependentToolParams(
tool.type,
{ ...tool.params, [paramId]: paramValue },
paramId
)
return { ...tool, params }
})
)
},
[isPreview, disabled, selectedTools, setStoreValue]
@@ -1707,7 +1680,7 @@ export const ToolInput = memo(function ToolInput({
})
: null
const toolScopedOverrides = scopeCanonicalOverrides(canonicalModeOverrides, tool.type)
const toolScopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type)
const subBlocksResult: SubBlocksForToolInput | null =
!isCustomTool && !isMcpTool && currentToolId
@@ -1732,6 +1705,7 @@ export const ToolInput = memo(function ToolInput({
subBlocks: toolBlock!.subBlocks,
canonicalIndex: toolCanonicalIndex,
values: { operation: tool.operation, ...tool.params },
overrides: toolScopedOverrides,
})
: tool.params || {}
@@ -122,7 +122,7 @@ function jobReport(job: BackgroundWorkItem): JobReport {
addGroup('Files', m.fileNames)
addGroup('Custom tools', m.customToolNames)
addGroup('Skills', m.skillNames)
addGroup('MCP servers', m.mcpServerNames)
addGroup('Workflow MCP servers', m.workflowMcpServerNames)
// Pre-names entries fall back to the per-kind counts.
if (groups.length === 0) {
const counts = [
@@ -139,6 +139,9 @@ function jobReport(job: BackgroundWorkItem): JobReport {
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 }
}
@@ -247,9 +250,9 @@ function ForkJobRow({ job }: { job: BackgroundWorkItem }) {
{report.groups.map((group) => (
<ReportGroupRow key={group.label} group={group} />
))}
{report.notes.map((note) => (
{report.notes.map((note, index) => (
<span
key={note.value}
key={`${index}:${note.value}`}
className={cn(
'text-caption',
note.warning ? 'text-[var(--text-error)]' : 'text-[var(--text-tertiary)]'
@@ -0,0 +1,28 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { groupForkFilesIntoFolders } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/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,215 @@
'use client'
import { useId, useMemo, useState } from 'react'
import { Checkbox, ChevronDown, ChipInput, cn } from '@sim/emcn'
import { Search } from 'lucide-react'
import {
ForkFileTree,
type ForkFlatFile,
groupForkFilesIntoFolders,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/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
}
/** Show the inline search once a kind has more entries than fit comfortably. */
const SEARCH_THRESHOLD = 8
interface ResourceKindRowProps {
label: string
items: ForkResourcePickerItem[]
selected: Set<string>
/** Toggle the given ids on/off. Used for select-all over the currently-VISIBLE (filtered) subset. */
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 searchable 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}.
*/
export function ResourceKindRow({
label,
items,
selected,
onToggleMany,
onToggleItem,
disabled = false,
}: ResourceKindRowProps) {
const [expanded, setExpanded] = useState(false)
const [query, setQuery] = useState('')
const fieldId = useId()
const filtered = useMemo(() => {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return items
return items.filter((item) => item.label.toLowerCase().includes(trimmed))
}, [items, query])
// Count + header state + select-all are scoped to the VISIBLE (filtered) items so a search never
// selects or counts hidden ones. With no filter, `filtered === items`, so behavior is unchanged.
const total = filtered.length
const selectedCount = filtered.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 gap-1'>
<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(
filtered.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='ml-6 flex flex-col gap-1'>
{total > SEARCH_THRESHOLD ? (
<ChipInput
icon={Search}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Search ${label.toLowerCase()}`}
disabled={disabled}
/>
) : null}
<div className='flex max-h-44 flex-col gap-0.5 overflow-y-auto'>
{filtered.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>
)
})}
{filtered.length === 0 ? (
<p className='py-1 text-[var(--text-secondary)] text-xs'>No matches</p>
) : null}
</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 gap-1'>
<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='ml-6'>
<ForkFileTree
folders={folders}
rootFiles={rootFiles}
isSelected={(id) => selected.has(id)}
onToggleFile={onToggleItem}
onToggleMany={onToggleMany}
disabled={disabled}
/>
</div>
) : null}
</div>
)
}
@@ -1,9 +1,7 @@
'use client'
import { useEffect, useId, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
Checkbox,
ChevronDown,
Chip,
ChipConfirmModal,
ChipCopyInput,
@@ -15,19 +13,19 @@ import {
type ChipModalFooterSlotAction,
ChipModalHeader,
ChipModalTabs,
cn,
Tooltip,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { Search } from 'lucide-react'
import { AlertTriangle } from 'lucide-react'
import { useRouter } from 'next/navigation'
import type {
ForkCopyableResource,
GetForkResourcesResponse,
} from '@/lib/api/contracts/workspace-fork'
import type { GetForkResourcesResponse } from '@/lib/api/contracts/workspace-fork'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { ForkActivityPanel } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel'
import {
FileKindRow,
ResourceKindRow,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker'
import {
type ForkDirection,
useForkResources,
@@ -57,127 +55,24 @@ const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [
{ 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' },
]
/** Show the inline search once a kind has more entries than fit comfortably. */
const SEARCH_THRESHOLD = 8
const emptySelection = (): ResourceSelection => ({
files: new Set(),
tables: new Set(),
knowledgeBases: new Set(),
customTools: new Set(),
skills: new Set(),
mcpServers: new Set(),
workflowMcpServers: new Set(),
})
/**
* One expandable resource kind in the fork picker: a tri-state "select all" header
* (count of selected / total) plus, when expanded, a searchable scrollable list of
* individual resources so the user can copy a specific subset.
*/
function ResourceKindRow({
label,
items,
selected,
onToggleAll,
onToggleItem,
disabled,
}: {
label: string
items: ForkCopyableResource[]
selected: Set<string>
onToggleAll: (selectAll: boolean) => void
onToggleItem: (id: string, checked: boolean) => void
disabled: boolean
}) {
const [expanded, setExpanded] = useState(false)
const [query, setQuery] = useState('')
const fieldId = useId()
const total = items.length
const selectedCount = selected.size
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
const filtered = useMemo(() => {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return items
return items.filter((item) => item.label.toLowerCase().includes(trimmed))
}, [items, query])
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 ${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='ml-6 flex flex-col gap-1'>
{total > SEARCH_THRESHOLD ? (
<ChipInput
icon={Search}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Search ${label.toLowerCase()}`}
disabled={disabled}
/>
) : null}
<div className='flex max-h-44 flex-col gap-0.5 overflow-y-auto'>
{filtered.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>
)
})}
{filtered.length === 0 ? (
<p className='py-1 text-[var(--text-secondary)] text-xs'>No matches</p>
) : null}
</div>
</div>
) : null}
</div>
)
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
}
/**
@@ -200,6 +95,7 @@ export function ForkWorkspaceModal({
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)
const [activeTab, setActiveTab] = useState<'config' | 'activity'>('config')
@@ -210,6 +106,7 @@ export function ForkWorkspaceModal({
if (open) {
setName(`${sourceWorkspaceName} (fork)`)
setSelected(emptySelection())
setDefaulted(false)
setError(null)
setActiveTab('config')
setForkedWorkspace(null)
@@ -217,6 +114,12 @@ export function ForkWorkspaceModal({
}
}, [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(
@@ -224,6 +127,15 @@ export function ForkWorkspaceModal({
[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
@@ -239,11 +151,14 @@ export function ForkWorkspaceModal({
return
}
const trimmed = name.trim()
if (!trimmed || isForking) return
// 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 = resources.data
? Object.fromEntries(RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])]))
: undefined
const copy = Object.fromEntries(
RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])])
)
forkWorkspace.mutate(
{ workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } },
{
@@ -360,34 +275,83 @@ export function ForkWorkspaceModal({
{availableKinds.length > 0 ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
{availableKinds.map((kind) => (
<ResourceKindRow
key={kind.key}
label={kind.label}
items={resources.data?.[kind.key] ?? []}
selected={selected[kind.key]}
onToggleAll={(selectAll) =>
setSelected((prev) => ({
...prev,
[kind.key]: selectAll
? new Set((resources.data?.[kind.key] ?? []).map((item) => item.id))
: new Set<string>(),
}))
}
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}
/>
))}
<p className='text-[var(--text-muted)] text-caption'>
Unselected resources leave their workflow fields empty in the fork.
</p>
{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>
) : (
<p className='text-[var(--text-muted)] text-caption'>
Everything referenced by your workflows is copied. Deselect a resource to
skip it — its references will be cleared.
</p>
)}
</div>
</SettingsSection>
) : null}
@@ -414,8 +378,14 @@ export function ForkWorkspaceModal({
: {
label: isForking ? 'Forking...' : 'Fork',
onClick: handleSubmit,
// At the cap the button stays clickable (no name needed) so it can route to upgrade.
disabled: isForking || (canFork && !name.trim()),
// 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,
}
}
/>
@@ -437,7 +407,15 @@ export function ForkWorkspaceModal({
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,120 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/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'
): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId })
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 modal'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 in the modal)', () => {
const workflowReference = workflowRef('wf-other')
expect(
selectVisibleClearedRefs([workflowReference], resolvedKeys('workflow:wf-other'))
).toEqual([workflowReference])
})
})
@@ -0,0 +1,39 @@
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
/** 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 in the modal.
*
* Pure so the reactive list is unit-testable independent of the modal'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
})
}
@@ -0,0 +1,124 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import {
effectiveForkTarget,
forkCopyingKeys,
forkMappedCopyableKeys,
forkRefKey,
forkRequiredPending,
forkVisibleCopyables,
isForkRequiredComplete,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/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,
...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('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('optional refs never block', () => {
const entries = [entry({ kind: 'table', sourceId: 't1', required: false })]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
})
})
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,85 @@
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)))
}
/** 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
}
/**
* 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 overview'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))
)
}
@@ -0,0 +1,59 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
import {
dependentKey,
effectiveDependentValue,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/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: {},
...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('')
})
})
@@ -16,6 +16,7 @@ import {
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowRight } from 'lucide-react'
import type {
ForkCopyableUnmapped,
ForkDependentReconfig,
ForkLineageNodeApi,
ForkMappingEntry,
@@ -23,7 +24,21 @@ import type {
ForkWorkflowChange,
} from '@/lib/api/contracts/workspace-fork'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
FileKindRow,
ResourceKindRow,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker'
import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list'
import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure'
import {
effectiveForkTarget,
forkCopyingKeys,
forkMappedCopyableKeys,
forkRefKey,
forkRequiredPending,
forkVisibleCopyables,
isForkRequiredComplete,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation'
import {
dependentKey,
effectiveDependentValue,
@@ -43,7 +58,7 @@ interface PromoteWorkspaceModalProps {
parent: ForkLineageNodeApi | null
}
const entryKey = (entry: ForkMappingEntry) => `${entry.kind}:${entry.sourceId}`
const entryKey = (entry: ForkMappingEntry) => forkRefKey(entry)
/**
* Whether a mapping entry needs an in-place reconfigure: its effective target was changed
@@ -90,19 +105,46 @@ function takenTargetOwners(
return owners
}
/**
* 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.
*/
type MappableMappingKind = Exclude<ForkMappingEntry['kind'], 'knowledge-document'>
/** Section label + display order per mapping kind (one mapping step per kind). */
const MAPPING_SECTION: Record<ForkMappingEntry['kind'], { label: string; order: number }> = {
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 },
'knowledge-document': { label: 'Knowledge documents', order: 4 },
file: { label: 'Files', order: 5 },
'mcp-server': { label: 'MCP servers', order: 6 },
'custom-tool': { label: 'Custom tools', order: 7 },
skill: { label: 'Skills', order: 8 },
file: { label: 'Files', order: 4 },
'mcp-server': { label: 'MCP servers', order: 5 },
'custom-tool': { label: 'Custom tools', order: 6 },
skill: { label: 'Skills', order: 7 },
}
/**
* Copyable kinds as expandable sections in the sync "Copy resources" picker, ordered + labeled to
* match the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other
* kind is a flat searchable 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' },
]
const copyableKey = (candidate: ForkCopyableUnmapped) => forkRefKey(candidate)
/** Sentinel option value for the editor's "Copy instead" entry - handled via onSelect, never sent. */
const COPY_INSTEAD_VALUE = '__copy_instead__'
interface EdgeOption {
value: string
label: string
@@ -157,6 +199,11 @@ export function PromoteWorkspaceModal({
// `dependentKey`. Folded into the full effective set sent on sync, which promote 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)
// Wizard step: 0 is the overview; 1..N edit one resource kind each, entered via
// "Edit mappings". Backing out of step 1 returns to the overview.
const [step, setStep] = useState(0)
@@ -176,6 +223,8 @@ export function PromoteWorkspaceModal({
setStep(0)
setTargets({})
setReconfig({})
setCopySelected(new Set())
setCopyDefaulted(false)
}, [open, selectedKey])
const selected = edgeOptions.find((option) => option.value === selectedKey)
@@ -193,6 +242,64 @@ export function PromoteWorkspaceModal({
[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 editor shows 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(copyableKey)), [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. `copyableKey` shares the `${kind}:${sourceId}` keyspace with `entryKey`, 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 in the editor walk 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. Files nest in a folder ▸
// file tree inside their section; every other kind is a flat searchable list.
const copyablesByKind = useMemo(() => {
const groups = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
for (const candidate of visibleCopyables) {
const list = groups.get(candidate.kind)
if (list) list.push(candidate)
else groups.set(candidate.kind, [candidate])
}
return groups
}, [visibleCopyables])
// Default every copyable referenced 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.
// 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 (!open || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return
setCopyDefaulted(true)
setCopySelected(new Set(copyableUnmapped.map(copyableKey)))
}, [open, diff.isPlaceholderData, copyableUnmapped, copyDefaulted])
// Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat
@@ -211,9 +318,11 @@ export function PromoteWorkspaceModal({
// 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) => targets[entryKey(entry)] ?? entry.targetId ?? ''
const targetFor = (entry: ForkMappingEntry) => effectiveForkTarget(entry, targets)
const requiredComplete = entries.every((entry) => !entry.required || targetFor(entry) !== '')
// 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 workflow a mapping entry's resource is used in, for the always-on reconfigure
// listing rendered beneath that mapping (so the credential/KB stays in context).
@@ -226,10 +335,34 @@ export function PromoteWorkspaceModal({
const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
// 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 applyTargetChange = (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
})
}
// Group mappings by resource type - one step per kind, required types first.
const groupedEntries = useMemo(() => {
const groups = new Map<ForkMappingEntry['kind'], ForkMappingEntry[]>()
const groups = new Map<MappableMappingKind, ForkMappingEntry[]>()
for (const entry of entries) {
// The mapping view never emits a document entry (it rides its KB), so the section is
// unreachable - skip defensively so the narrowed `MAPPING_SECTION` lookup stays sound.
if (entry.kind === 'knowledge-document') continue
const list = groups.get(entry.kind)
if (list) list.push(entry)
else groups.set(entry.kind, [entry])
@@ -277,6 +410,32 @@ export function PromoteWorkspaceModal({
return dependentValueFor(field, parent) !== ''
})
// Kinds with a required DEPENDENT that still has no value (its parent is mapped): these block
// Sync via `reconfigComplete`, so the overview 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 || targetFor(parent) === '') continue
if (dependentValueFor(field, parent) === '') {
reconfigPendingByKind.add(parent.kind as MappableMappingKind)
}
}
// The references this sync will 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. `workflow` refs always clear (not resolvable here).
const clearedRefsToShow = 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 selectVisibleClearedRefs(clearedRefs, isResolved)
}, [clearedRefs, entriesByParent, targets, copyingKeys])
// Per-kind status for the overview listing: "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
@@ -284,8 +443,22 @@ export function PromoteWorkspaceModal({
const kindSummaries = groupedEntries.map((group) => {
const total = group.items.length
const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
const requiredPending = group.items.some((entry) => entry.required && targetFor(entry) === '')
return { kind: group.kind, label: group.label, total, mapped, requiredPending }
// 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,
label: group.label,
total,
mapped,
copied,
requiredPending,
reconfigPending,
}
})
// Step 0 is the overview; each subsequent step edits one resource kind, entered via
@@ -296,16 +469,18 @@ export function PromoteWorkspaceModal({
const safeStep = Math.min(step, Math.max(0, stepCount - 1))
const isLastStep = safeStep >= stepCount - 1
const currentGroup = safeStep >= 1 ? (groupedEntries[safeStep - 1] ?? null) : null
// Gate Sync on the diff being loaded too, not just the mapping: until `diff.data` arrives
// `dependentReconfigs` is empty, so `reconfigComplete` is vacuously true and `runPromote` would
// omit `dependentValues` - i.e. Sync before the diff loads would bypass dependent gating.
const syncDisabled =
submitting ||
!otherWorkspaceId ||
!requiredComplete ||
!reconfigComplete ||
// 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 ||
!diff.data
!mapping.data ||
mapping.isPlaceholderData ||
!diff.data ||
diff.isPlaceholderData
const syncDisabled =
submitting || !otherWorkspaceId || !requiredComplete || !reconfigComplete || dataPending
const headsUp =
(diff.data?.mcpReauthServerIds.length ?? 0) > 0 ||
(diff.data?.inlineSecretSources.length ?? 0) > 0
@@ -344,6 +519,25 @@ export function PromoteWorkspaceModal({
]
})
// 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(copyableKey(candidate))
)
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),
}
const result = await promote.mutateAsync({
workspaceId,
body: {
@@ -354,12 +548,25 @@ export function PromoteWorkspaceModal({
// 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.
...(diff.data ? { dependentValues } : {}),
...(selectedCopyables.length > 0 ? { copyResources } : {}),
},
})
if (!result.promoteRunId) {
if (result.unmappedRequired.length > 0) {
toast.error('Map all required credentials and secrets first')
// 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 button's disabled tooltip).
const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind))
const what =
kinds.has('credential') && kinds.has('env-var')
? 'credentials and secrets'
: kinds.has('credential')
? 'credentials'
: kinds.has('env-var')
? 'secrets'
: 'references'
toast.error(`Map all required ${what} first`)
return
}
toast.error('Sync did not complete')
@@ -387,8 +594,16 @@ export function PromoteWorkspaceModal({
optionalBlocks > 0
? ` (+${optionalBlocks} block${optionalBlocks === 1 ? '' : 's'} with optional fields cleared)`
: ''
// Surfaced alongside a needs-config warning too, so concurrent deploy failures aren't only in
// logs/Activity when both happen (the needs-config branch would otherwise win alone).
const deployFailedSuffix =
result.deployFailed > 0
? ` (+${result.deployFailed} workflow${result.deployFailed === 1 ? '' : 's'} failed to deploy)`
: ''
if (needsConfig.length > 0) {
toast.warning(`${label}. Re-check ${formatWhere(needsConfig)}.${optionalSuffix}`)
toast.warning(
`${label}. Re-check ${formatWhere(needsConfig)}.${deployFailedSuffix}${optionalSuffix}`
)
} else if (result.deployFailed > 0) {
const n = result.deployFailed
toast.warning(
@@ -447,6 +662,20 @@ export function PromoteWorkspaceModal({
/>
</SettingsSection>
{/* Surface a failed/pending fetch so the modal never renders blank below the picker. */}
{mapping.isError || diff.isError ? (
<SettingsSection label='Sync details'>
<div className='text-[var(--text-error)] text-small'>
{getErrorMessage(
mapping.error ?? diff.error,
"Couldn't load sync details. Close and reopen to retry."
)}
</div>
</SettingsSection>
) : !diff.data ? (
<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. */}
@@ -506,23 +735,138 @@ export function PromoteWorkspaceModal({
{kindSummaries.length > 0 ? (
<SettingsSection label='Mappings'>
<div className='flex flex-col gap-2'>
{kindSummaries.map(({ kind, label, total, mapped, requiredPending }) => {
const complete = mapped === total
return (
<div key={kind} className='flex items-center justify-between gap-2'>
<span className='text-[var(--text-body)] text-small'>{label}</span>
<Badge
variant={
complete ? 'green' : requiredPending ? 'amber' : 'gray-secondary'
}
size='sm'
dot
>
{complete ? 'Fully mapped' : `${mapped}/${total} mapped`}
</Badge>
</div>
{kindSummaries.map(
({
kind,
label,
total,
mapped,
copied,
requiredPending,
reconfigPending,
}) => {
const resolved = mapped + copied
const complete = resolved === total && !reconfigPending
const badgeLabel = complete
? mapped === total
? 'Fully mapped'
: copied === total
? 'Copied'
: 'Mapped & copied'
: reconfigPending && resolved === total
? 'Needs setup'
: copied > 0
? `${resolved}/${total} ready`
: `${mapped}/${total} mapped`
return (
<div key={kind} className='flex items-center justify-between gap-2'>
<span className='text-[var(--text-body)] text-small'>{label}</span>
<Badge
variant={
complete
? 'green'
: requiredPending || reconfigPending
? 'amber'
: 'gray-secondary'
}
size='sm'
dot
>
{badgeLabel}
</Badge>
</div>
)
}
)}
</div>
</SettingsSection>
) : null}
{clearedRefsToShow.length > 0 ? (
<SettingsSection label='Will be cleared'>
<div className='flex max-h-40 flex-col gap-1 overflow-y-auto'>
{clearedRefsToShow.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'>
Map or copy a reference to keep it. Fields that reference another workflow, or
that hang off a remapped credential or knowledge base, are cleared regardless.
</p>
</SettingsSection>
) : null}
{visibleCopyables.length > 0 ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
{COPYABLE_KIND_SECTIONS.map((section) => {
const candidates = copyablesByKind.get(section.kind)
if (!candidates || candidates.length === 0) return null
// The picker rows track item ids; copy selection is keyed `${kind}:${id}`
// (matching `copyableKey`), so derive the per-kind selected-id subset and
// re-prefix on toggle.
const selectedIds = new Set(
candidates
.filter((candidate) => copySelected.has(copyableKey(candidate)))
.map((candidate) => candidate.sourceId)
)
const toggleMany = (ids: string[], checked: boolean) =>
setCopySelected((prev) => {
const next = new Set(prev)
for (const id of ids) {
const key = `${section.kind}:${id}`
if (checked) next.add(key)
else next.delete(key)
}
return next
})
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={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={submitting}
/>
)
})}
<p className='text-[var(--text-muted)] text-caption'>
These referenced resources aren't in the target yet. Selected ones are copied
during the sync; deselected ones have their references cleared.
</p>
</div>
</SettingsSection>
) : null}
@@ -551,36 +895,31 @@ export function PromoteWorkspaceModal({
>
<ChipCombobox
className='w-full'
options={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={targetFor(entry) || undefined}
onChange={(value) => {
setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
// Changing the parent invalidates any in-session re-picks of its
// dependents - they were chosen against the old account and won't resolve
// against the new one, so drop them; otherwise a stale re-pick (which
// wins over the parent-changed check) would be sent to the new account.
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
}
options={[
// Let the user revert a name-match suggestion (or any in-session map) of a
// copyable resource back to the copy flow - clears the target via onSelect.
...(copyableKeys.has(entryKey(entry)) && targetFor(entry) !== ''
? [
{
label: 'Copy instead',
value: COPY_INSTEAD_VALUE,
onSelect: () => applyTargetChange(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,
}
return changed ? next : prev
})
}}
}),
]}
value={targetFor(entry) || undefined}
onChange={(value) => applyTargetChange(entry, value)}
placeholder='Select target'
/>
{entry.candidatesTruncated ? (
@@ -589,6 +928,12 @@ export function PromoteWorkspaceModal({
one, narrow it down by name.
</div>
) : null}
{copyingKeys.has(entryKey(entry)) ? (
<div className='mt-1 text-[var(--text-muted)] text-small'>
Selected for copy in the overview — it'll be copied into the target. Pick a
target above to map it to an existing one instead.
</div>
) : null}
{/* Always-on: every workflow this resource is used in, each expandable to
its blocks + dependent selectors (greyed when nothing to configure). */}
<ResourceReconfigure
@@ -621,7 +966,9 @@ export function PromoteWorkspaceModal({
? 'Map all required secrets first'
: !reconfigComplete
? 'Reconfigure all required fields first'
: undefined,
: dataPending
? 'Loading sync details…'
: undefined,
}
}
/>
@@ -12,6 +12,10 @@ describe('forkMappableResourceTypeSchema', () => {
expect(forkMappableResourceTypeSchema.safeParse('workflow').success).toBe(false)
})
it('rejects knowledge_document (a document follows its parent knowledge base)', () => {
expect(forkMappableResourceTypeSchema.safeParse('knowledge_document').success).toBe(false)
})
it('accepts user-mappable resource types', () => {
for (const type of [
'oauth_credential',
@@ -19,7 +23,6 @@ describe('forkMappableResourceTypeSchema', () => {
'env_var',
'table',
'knowledge_base',
'knowledge_document',
'file',
'mcp_server',
'custom_tool',
+135 -9
View File
@@ -32,16 +32,37 @@ export const forkResourceTypeSchema = z.enum([
])
/**
* Resource types a user may map via the mapping editor. Excludes `workflow`:
* workflow identity is system-managed (seeded at fork, maintained by promote,
* dissolved by rollback) and must never be written through the mapping editor, or
* a crafted entry could repoint a promote at the wrong target workflow.
* Resource types a user may map via the mapping editor. Excludes `workflow` (identity is
* system-managed - seeded at fork, maintained by promote, dissolved by rollback - and must never
* be written through the editor, or a crafted entry could repoint a promote at the wrong target
* workflow) AND `knowledge_document` (a document is never a standalone mapping: it follows its
* parent knowledge base, re-picked in that KB's reconfigure flow and auto-remapped when the KB is
* copied - the mapping view never emits one and `listForkResourceCandidates` returns none).
*/
export const forkMappableResourceTypeSchema = forkResourceTypeSchema.exclude(['workflow'])
export const forkMappableResourceTypeSchema = forkResourceTypeSchema.exclude([
'workflow',
'knowledge_document',
])
export type ForkMappableResourceType = z.infer<typeof forkMappableResourceTypeSchema>
export const forkDirectionSchema = z.enum(['push', 'pull'])
/**
* The remappable, copyable resource kinds a sync can copy into the target when they are
* referenced but unmapped (the fork-style copy at promote time). Excludes credentials, env
* vars, and external MCP servers (never copied this way); documents are auto-copied with their
* parent knowledge base, not selected individually. Workspace `file` references are keyed by
* storage key (not `workspace_files.id`) and copied like fork does.
*/
export const forkCopyableKindSchema = z.enum([
'knowledge-base',
'table',
'custom-tool',
'skill',
'file',
])
export type ForkCopyableKind = z.infer<typeof forkCopyableKindSchema>
export const forkLineageNodeSchema = z.object({
id: z.string(),
name: z.string(),
@@ -79,7 +100,9 @@ export const forkResourceSelectionSchema = z.object({
knowledgeBases: forkResourceIdList,
customTools: forkResourceIdList,
skills: forkResourceIdList,
mcpServers: forkResourceIdList,
// External MCP servers are never copied (they carry secrets / require re-auth); only
// workflow-publishing MCP servers are copyable, as config-only shells with no workflows.
workflowMcpServers: forkResourceIdList,
})
export const forkWorkspaceBodySchema = z.object({
@@ -106,6 +129,18 @@ export type ForkWorkspaceResponse = z.output<typeof forkWorkspaceContract.respon
export const forkCopyableResourceSchema = z.object({ id: z.string(), label: z.string() })
export type ForkCopyableResource = z.output<typeof forkCopyableResourceSchema>
/**
* A copyable workspace file plus its folder grouping. `folderId`/`folderName` are null when
* the file sits at the workspace root (or its folder was deleted). Files are the only copyable
* kind that nests in the picker (folder ▸ file); every other kind stays flat at the top level.
*/
export const forkCopyableFileSchema = forkCopyableResourceSchema.extend({
folderId: z.string().nullable(),
folderName: z.string().nullable(),
})
export type ForkCopyableFile = z.output<typeof forkCopyableFileSchema>
export const getForkResourcesContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/resources',
@@ -113,12 +148,12 @@ export const getForkResourcesContract = defineRouteContract({
response: {
mode: 'json',
schema: z.object({
files: z.array(forkCopyableResourceSchema),
files: z.array(forkCopyableFileSchema),
tables: z.array(forkCopyableResourceSchema),
knowledgeBases: z.array(forkCopyableResourceSchema),
customTools: z.array(forkCopyableResourceSchema),
skills: z.array(forkCopyableResourceSchema),
mcpServers: z.array(forkCopyableResourceSchema),
workflowMcpServers: z.array(forkCopyableResourceSchema),
deployedWorkflowCount: z.number().int(),
}),
},
@@ -284,6 +319,52 @@ export const forkResourceUsageSchema = z.object({
})
export type ForkResourceUsage = z.output<typeof forkResourceUsageSchema>
/** Fields shared by every cleared-ref variant: the labels to phrase the "will be cleared" line. */
const forkClearedRefBaseSchema = z.object({
targetWorkflowId: z.string(),
workflowName: z.string(),
blockId: z.string(),
blockLabel: z.string(),
fieldLabel: z.string(),
sourceId: z.string(),
sourceLabel: z.string(),
})
/**
* A reference in a synced source workflow that WILL be blanked in the target by this sync, with the
* labels to phrase it as "{blockLabel} will lose {fieldLabel} in workflow {workflowName}". A
* discriminated union on `cause` so clients narrow exhaustively (only `dependent` carries the parent
* fields):
* - `reference`: an unmapped remappable resource (`kind`) - drops off the list once the user maps
* OR copies it (matched to a mapping entry by `${kind}:${sourceId}`).
* - `workflow`: a `workflow-selector`/`workflow_input` ref to a workflow not in the target -
* always cleared (cannot be fixed in the modal).
* - `dependent`: a create-target dependent selector a remapped parent clears. Carries the parent
* (`parentKind`/`parentSourceId`); when the child follows its parent (a document under a knowledge
* base) the client drops it once that parent is mapped/copied, else it stays (credential label /
* table column).
*/
export const forkClearedRefSchema = z.discriminatedUnion('cause', [
forkClearedRefBaseSchema.extend({
cause: z.literal('reference'),
/** The unmapped remappable resource (never `workflow`). */
kind: forkRemapKindSchema,
}),
forkClearedRefBaseSchema.extend({
cause: z.literal('workflow'),
kind: z.literal('workflow'),
}),
forkClearedRefBaseSchema.extend({
cause: z.literal('dependent'),
/** Mirrors `parentKind` - the parent resource the cleared dependent hangs off. */
kind: forkRemapKindSchema,
/** The dependsOn parent; the entry drops off once this parent is mapped/copied (KB-document case). */
parentKind: forkRemapKindSchema,
parentSourceId: z.string(),
}),
])
export type ForkClearedRef = z.output<typeof forkClearedRefSchema>
export const getForkDiffQuerySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
@@ -313,11 +394,35 @@ export const getForkDiffContract = defineRouteContract({
dependentReconfigs: z.array(forkDependentReconfigSchema),
/** Every workflow each mapped resource is used in, for the always-on reconfigure listing. */
resourceUsages: z.array(forkResourceUsageSchema),
/**
* Referenced resources with no target mapping that the sync can copy into the target
* (fork-style), so the user can copy instead of mapping each one by hand. Default-selected
* in the modal; documents under a selected knowledge base are copied automatically.
* `parentId`/`parentLabel` carry the folder grouping for file entries (id + name); they
* are null for non-file kinds and for files at the workspace root.
*/
copyableUnmapped: z.array(
z.object({
kind: forkCopyableKindSchema,
sourceId: z.string(),
label: z.string(),
parentId: z.string().nullable(),
parentLabel: z.string().nullable(),
})
),
/**
* References this sync will blank in the target, with labels for a pre-sync "what will be
* cleared" list. The client filters this against the current mapping/copy selection so a
* `reference` item disappears once mapped or selected for copy; `workflow`/`dependent` items
* always clear (informational).
*/
clearedRefs: z.array(forkClearedRefSchema),
}),
},
})
export type GetForkDiffResponse = z.output<typeof getForkDiffContract.response.schema>
export type ForkWorkflowChange = z.output<typeof forkWorkflowChangeSchema>
export type ForkCopyableUnmapped = GetForkDiffResponse['copyableUnmapped'][number]
/**
* A workflow whose required dependent fields a sync cleared because their parent
@@ -347,6 +452,21 @@ export const forkDependentValueEntrySchema = z.object({
})
export type ForkDependentValueEntry = z.input<typeof forkDependentValueEntrySchema>
/**
* Source resource ids (by kind) the user chose to copy into the target before the sync gate,
* for referenced-but-unmapped resources. Each kind's documents under a copied knowledge base
* are discovered + copied automatically (the user selects only the parent resources).
*/
export const promoteCopyResourcesSchema = z.object({
knowledgeBases: forkResourceIdList,
tables: forkResourceIdList,
customTools: forkResourceIdList,
skills: forkResourceIdList,
/** Workspace files to copy, identified by storage key (not `workspace_files.id`). */
files: forkResourceIdList,
})
export type PromoteCopyResources = z.input<typeof promoteCopyResourcesSchema>
export const promoteForkBodySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
@@ -357,6 +477,8 @@ export const promoteForkBodySchema = z.object({
* an explicit `[]` clears it for the written replace targets.
*/
dependentValues: z.array(forkDependentValueEntrySchema).max(2000).optional(),
/** Referenced-but-unmapped resources to copy into the target before the sync gate (U17). */
copyResources: promoteCopyResourcesSchema.optional(),
})
export const promoteForkContract = defineRouteContract({
method: 'POST',
@@ -397,6 +519,10 @@ export const backgroundWorkMetadataSchema = z
files: z.number().int().optional(),
copied: z.number().int().optional(),
failed: z.number().int().optional(),
/** Count of failed resources whose dangling references were cleared post-fork (U8). */
clearedReferences: z.number().int().optional(),
/** True when a reference-clear phase threw, so cleanup is incomplete (placeholders not dropped). */
clearingFailed: z.boolean().optional(),
/** Names of the resources a fork copied, by kind, for the report breakdown. */
workflowNames: z.array(z.string()).optional(),
tableNames: z.array(z.string()).optional(),
@@ -404,7 +530,7 @@ export const backgroundWorkMetadataSchema = z
fileNames: z.array(z.string()).optional(),
customToolNames: z.array(z.string()).optional(),
skillNames: z.array(z.string()).optional(),
mcpServerNames: z.array(z.string()).optional(),
workflowMcpServerNames: z.array(z.string()).optional(),
// Sync / rollback
otherWorkspaceName: z.string().optional(),
direction: z.enum(['push', 'pull']).optional(),
+8 -1
View File
@@ -10,6 +10,7 @@ import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
import type { RowData, TableRow, TableSchema } from '@/lib/table/types'
import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical'
const logger = createLogger('TableTrigger')
@@ -70,7 +71,13 @@ export async function fireTableTrigger(
// Filter to webhooks watching this table with a matching event type
const matching = webhooks.filter((entry) => {
const config = entry.webhook.providerConfig as WebhookConfig | null
const configTableId = config?.tableId ?? config?.tableSelector ?? config?.manualTableId
// Canonical key `tableId` first; `tableSelector`/`manualTableId` are a transitional
// basic-first fallback for configs deployed before the canonical key was written.
const configTableId = readCanonicalTriggerValue(
config?.tableId,
config?.tableSelector,
config?.manualTableId
)
if (configTableId !== tableId) return false
const configEventType = config?.eventType ?? 'insert'
+122
View File
@@ -0,0 +1,122 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
// deploy.ts pulls in the trigger/block/provider registries at module load; none are exercised by
// buildProviderConfig (a pure function), so stub them to keep this unit test fast and isolated.
vi.mock('@/blocks', () => ({ getBlock: vi.fn() }))
vi.mock('@/triggers', () => ({ getTrigger: vi.fn(), isTriggerValid: vi.fn(() => true) }))
vi.mock('@/lib/oauth', () => ({ getProviderIdFromServiceId: vi.fn() }))
vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: vi.fn() }))
vi.mock('@/lib/webhooks/provider-subscriptions', () => ({
cleanupExternalWebhook: vi.fn(),
createExternalWebhookSubscription: vi.fn(),
shouldRecreateExternalWebhookSubscription: vi.fn(),
}))
vi.mock('@/lib/webhooks/utils.server', () => ({
findConflictingWebhookPathOwner: vi.fn(),
syncWebhooksForCredentialSet: vi.fn(),
}))
vi.mock('@/lib/webhooks/pending-verification', () => ({
PendingWebhookVerificationTracker: vi.fn(),
}))
import { buildProviderConfig } from '@/lib/webhooks/deploy'
const trigger = (subBlocks: Partial<SubBlockConfig>[]): { subBlocks: SubBlockConfig[] } => ({
subBlocks: subBlocks as SubBlockConfig[],
})
const driveTrigger = trigger([
{ id: 'triggerCredentials', mode: 'trigger', canonicalParamId: 'oauthCredential' },
{ id: 'folderId', mode: 'trigger', canonicalParamId: 'folderId', required: false },
{ id: 'manualFolderId', mode: 'trigger-advanced', canonicalParamId: 'folderId', required: false },
])
const tableTrigger = trigger([
{ id: 'tableSelector', mode: 'trigger', canonicalParamId: 'tableId', required: true },
{ id: 'manualTableId', mode: 'trigger-advanced', canonicalParamId: 'tableId', required: true },
])
function makeBlock(
type: string,
subBlockValues: Record<string, unknown>,
canonicalModes?: Record<string, 'basic' | 'advanced'>
): BlockState {
const subBlocks: Record<string, { value: unknown }> = {}
for (const [key, value] of Object.entries(subBlockValues)) subBlocks[key] = { value }
return {
id: 'block-1',
type,
subBlocks,
...(canonicalModes ? { data: { canonicalModes } } : {}),
} as unknown as BlockState
}
describe('buildProviderConfig canonical collapse', () => {
beforeEach(() => vi.clearAllMocks())
it('writes the basic value under the canonical key in basic mode', () => {
const block = makeBlock('google_drive_poller', { folderId: 'BASIC' })
const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger)
expect(providerConfig.folderId).toBe('BASIC')
})
it('writes the active (advanced) value under the canonical key when only advanced is set', () => {
const block = makeBlock('google_drive_poller', { manualFolderId: 'ADVANCED' })
const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger)
// Heuristic: empty basic + populated advanced => advanced is active.
expect(providerConfig.folderId).toBe('ADVANCED')
// Raw advanced key kept for transitional readers.
expect(providerConfig.manualFolderId).toBe('ADVANCED')
})
it('collapses a drift block (stale basic + active advanced via override) to the active value', () => {
const block = makeBlock(
'google_drive_poller',
{ folderId: 'STALE', manualFolderId: 'ACTIVE' },
{ folderId: 'advanced' }
)
const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger)
// The canonical key collapses to the active (advanced) value, not the stale basic value.
expect(providerConfig.folderId).toBe('ACTIVE')
expect(providerConfig.manualFolderId).toBe('ACTIVE')
})
it('honors a basic-mode override even when advanced is populated', () => {
const block = makeBlock(
'google_drive_poller',
{ folderId: 'BASIC', manualFolderId: 'ADVANCED' },
{ folderId: 'basic' }
)
const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger)
expect(providerConfig.folderId).toBe('BASIC')
})
it('omits the canonical key when the active value is empty (optional field)', () => {
const block = makeBlock('google_drive_poller', {})
const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger)
expect(providerConfig.folderId).toBeUndefined()
})
it('writes a distinct canonical key (tableId) for the table trigger', () => {
const block = makeBlock('table_new_row', { tableSelector: 'TBL' })
const { providerConfig } = buildProviderConfig(block, 'table_new_row', tableTrigger)
expect(providerConfig.tableId).toBe('TBL')
// Raw basic key kept for transitional readers.
expect(providerConfig.tableSelector).toBe('TBL')
})
it('collapses a drift table block to the active value under tableId', () => {
const block = makeBlock(
'table_new_row',
{ tableSelector: 'STALE', manualTableId: 'ACTIVE' },
{ tableId: 'advanced' }
)
const { providerConfig } = buildProviderConfig(block, 'table_new_row', tableTrigger)
expect(providerConfig.tableId).toBe('ACTIVE')
})
})
+32 -2
View File
@@ -17,7 +17,12 @@ import {
findConflictingWebhookPathOwner,
syncWebhooksForCredentialSet,
} from '@/lib/webhooks/utils.server'
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
import {
buildCanonicalIndex,
buildSubBlockValues,
isCanonicalPair,
resolveActiveCanonicalValue,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -250,7 +255,13 @@ function getConfigValue(block: BlockState, subBlock: SubBlockConfig): unknown {
return fieldValue
}
function buildProviderConfig(
/**
* Build the persisted `webhook.providerConfig` for a trigger block at deploy time.
*
* Exported for unit testing the canonical-collapse pass; not part of the public
* deploy API.
*/
export function buildProviderConfig(
block: BlockState,
triggerId: string,
triggerDef: { subBlocks: SubBlockConfig[] }
@@ -308,6 +319,25 @@ function buildProviderConfig(
}
}
// Collapse each canonical pair (basic + advanced swap) to its ACTIVE value under the
// canonical key, so pollers read one authoritative key instead of guessing basic-first.
// resolveActiveCanonicalValue is the shared SOT: an explicit block.data.canonicalModes
// override, else the value heuristic. The raw subblock keys written in the first pass are
// kept for transitional readers (removable in a follow-up contract phase). This only runs on
// a (re)deploy, so any drift collapse is scoped to the new deployment version — already
// deployed rows are migrated separately and keep their current resource.
const canonicalModes = block.data?.canonicalModes
const flatSubBlockValues = buildSubBlockValues(block.subBlocks || {})
for (const group of Object.values(canonicalIndex.groupsById)) {
if (!isCanonicalPair(group)) continue
const activeValue = resolveActiveCanonicalValue(group, flatSubBlockValues, canonicalModes)
if (activeValue !== null && activeValue !== undefined && activeValue !== '') {
providerConfig[group.canonicalId] = activeValue
} else {
delete providerConfig[group.canonicalId]
}
}
const credentialConfig = triggerDef.subBlocks.find(
(subBlock) => subBlock.id === 'triggerCredentials'
)
@@ -0,0 +1,111 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical'
describe('readCanonicalTriggerValue', () => {
it('returns the canonical value when present', () => {
expect(readCanonicalTriggerValue('canon', 'basic', 'advanced')).toBe('canon')
})
it('falls back basic-first when the canonical key is absent (transitional)', () => {
expect(readCanonicalTriggerValue(undefined, 'basic', 'advanced')).toBe('basic')
})
it('falls back to the advanced value when canonical and basic are absent', () => {
expect(readCanonicalTriggerValue(undefined, undefined, 'advanced')).toBe('advanced')
})
it('treats empty strings as unset', () => {
expect(readCanonicalTriggerValue('', '', 'advanced')).toBe('advanced')
expect(readCanonicalTriggerValue('', '')).toBeUndefined()
})
it('ignores non-string members', () => {
expect(readCanonicalTriggerValue(null, 42 as unknown, 'advanced')).toBe('advanced')
expect(readCanonicalTriggerValue(undefined, undefined)).toBeUndefined()
})
/**
* No-repoint invariant: for every config shape the previous app version could
* have stored, the new canonical-first read must resolve to the SAME resource
* the old basic-first read returned — whether the row has been backfilled
* (canonical key present) or not (transitional fallback). The legacy
* basic-first read is reproduced inline as the source of truth.
*/
describe('no-repoint across legacy and backfilled shapes', () => {
interface GoogleDriveConfig {
folderId?: string
manualFolderId?: string
}
// The exact pre-change Google Drive poller read (basic key === canonical key).
const legacyDriveRead = (c: GoogleDriveConfig) => c.folderId || c.manualFolderId
// Mirror of the migration 0253 backfill: fill the canonical key basic-first
// ONLY when absent. For Drive the canonical key IS the basic key (folderId).
const backfillDrive = (c: GoogleDriveConfig): GoogleDriveConfig =>
c.folderId ? c : { ...c, folderId: c.folderId || c.manualFolderId }
const driveShapes: GoogleDriveConfig[] = [
{ folderId: 'basic-only' },
{ manualFolderId: 'advanced-only' },
// Drift: stale basic + active advanced both stored. Current poller reads basic.
{ folderId: 'STALE', manualFolderId: 'ACTIVE' },
{},
]
it.each(driveShapes)('Drive legacy config %o reads same resource as before', (config) => {
const before = legacyDriveRead(config)
const after = readCanonicalTriggerValue(config.folderId, config.manualFolderId)
expect(after).toBe(before || undefined)
})
it.each(driveShapes)('Drive backfilled config %o reads same resource as before', (config) => {
const before = legacyDriveRead(config)
const backfilled = backfillDrive(config)
const after = readCanonicalTriggerValue(backfilled.folderId, backfilled.manualFolderId)
expect(after).toBe(before || undefined)
})
interface TableConfig {
tableId?: string
tableSelector?: string
manualTableId?: string
}
// The exact pre-change table reader (already canonical-first, but tableId was
// never written, so it fell through to the raw keys).
const legacyTableRead = (c: TableConfig) => c.tableId ?? c.tableSelector ?? c.manualTableId
// Mirror of the migration 0253 backfill for table (canonical key is distinct).
const backfillTable = (c: TableConfig): TableConfig =>
c.tableId ? c : { ...c, tableId: c.tableSelector ?? c.manualTableId }
const tableShapes: TableConfig[] = [
{ tableSelector: 'basic-only' },
{ manualTableId: 'advanced-only' },
{ tableSelector: 'STALE', manualTableId: 'ACTIVE' },
{ tableId: 'collapsed', tableSelector: 'STALE', manualTableId: 'ACTIVE' },
{},
]
it.each(tableShapes)('table legacy config %o reads same resource as before', (config) => {
const before = legacyTableRead(config)
const after = readCanonicalTriggerValue(
config.tableId,
config.tableSelector,
config.manualTableId
)
expect(after).toBe(before ?? undefined)
})
it.each(tableShapes)('table backfilled config %o reads same resource as before', (config) => {
const before = legacyTableRead(config)
const backfilled = backfillTable(config)
const after = readCanonicalTriggerValue(
backfilled.tableId,
backfilled.tableSelector,
backfilled.manualTableId
)
expect(after).toBe(before ?? undefined)
})
})
})
@@ -0,0 +1,31 @@
/**
* Resolve the live resource id for a canonical trigger field from a deployed
* `webhook.providerConfig`.
*
* The canonical key — written at the deploy boundary by `buildProviderConfig`
* and populated on pre-existing rows by migration `0253` — is authoritative and
* is read first. The `transitionalFallback` values are read basic-first ONLY
* when the canonical key is absent, i.e. for configs deployed before the
* canonical key existed and not yet backfilled (including webhooks created by
* the previous app version during a deploy cutover).
*
* The fallback is TRANSITIONAL: once the backfill is confirmed and every
* deployed config carries the canonical key, it can be deleted in a follow-up
* contract phase and callers can read the canonical key alone. It exists solely
* for migration safety, not as permanent precedence logic.
*
* Empty strings are treated as unset (matching the previous basic-first `||`
* reads); returns `undefined` when no member is set.
*
* @param canonicalValue - value stored under the group's `canonicalParamId`
* @param transitionalFallback - the raw subblock values in basic-first order
*/
export function readCanonicalTriggerValue(
canonicalValue: unknown,
...transitionalFallback: unknown[]
): string | undefined {
for (const value of [canonicalValue, ...transitionalFallback]) {
if (typeof value === 'string' && value.length > 0) return value
}
return undefined
}
@@ -1,6 +1,7 @@
import type { Logger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical'
import {
getProviderConfig,
type PollingProviderHandler,
@@ -106,7 +107,10 @@ export const googleCalendarPollingHandler: PollingProviderHandler = {
)
const config = getProviderConfig<GoogleCalendarWebhookConfig>(webhookData.providerConfig)
const calendarId = config.calendarId || config.manualCalendarId || 'primary'
// Canonical key `calendarId` first; `manualCalendarId` is a transitional basic-first
// fallback. Defaults to the user's primary calendar when none is set.
const calendarId =
readCanonicalTriggerValue(config.calendarId, config.manualCalendarId) || 'primary'
// First poll: seed timestamp, emit nothing
if (!config.lastCheckedTimestamp) {
@@ -1,6 +1,7 @@
import type { Logger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical'
import {
getProviderConfig,
type PollingProviderHandler,
@@ -335,7 +336,8 @@ function filterChanges(
if (file.trashed) return false
const folderId = config.folderId || config.manualFolderId
// Canonical key `folderId` first; `manualFolderId` is a transitional basic-first fallback.
const folderId = readCanonicalTriggerValue(config.folderId, config.manualFolderId)
if (folderId) {
if (!file.parents || !file.parents.includes(folderId)) {
return false
@@ -1,6 +1,7 @@
import type { Logger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical'
import {
getProviderConfig,
type PollingProviderHandler,
@@ -63,8 +64,13 @@ export const googleSheetsPollingHandler: PollingProviderHandler = {
)
const config = getProviderConfig<GoogleSheetsWebhookConfig>(webhookData.providerConfig)
const spreadsheetId = config.spreadsheetId || config.manualSpreadsheetId
const sheetName = config.sheetName || config.manualSheetName
// Canonical keys (`spreadsheetId`/`sheetName`) first; the `manual*` keys are a transitional
// basic-first fallback for configs deployed before the canonical key existed.
const spreadsheetId = readCanonicalTriggerValue(
config.spreadsheetId,
config.manualSpreadsheetId
)
const sheetName = readCanonicalTriggerValue(config.sheetName, config.manualSheetName)
const now = new Date()
if (!spreadsheetId || !sheetName) {
@@ -182,7 +182,11 @@ function extractSelectorContext(
): SelectorContext {
const block = currentState.blocks?.[blockId]
if (!block?.subBlocks) return { workflowId, workspaceId }
return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId, workspaceId })
return buildSelectorContextFromBlock(block.type, block.subBlocks, {
workflowId,
workspaceId,
canonicalModes: block.data?.canonicalModes,
})
}
/**
@@ -99,6 +99,91 @@ describe('remapWorkflowReferencesInSubBlocks', () => {
expect(tools[0].params?.workflowId).toBe('sub-dst')
})
it('clears the sibling inputMapping when an unmapped workflow selector is cleared (U10)', () => {
const subBlocks: SubBlockRecord = {
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.workflowId.value).toBe('')
expect(result.inputMapping.value).toBe('')
})
it('keeps inputMapping when the workflow selector is remapped (not cleared)', () => {
const subBlocks: SubBlockRecord = {
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.workflowId.value).toBe('wf-dst')
expect(result.inputMapping.value).toBe('{"a":"b"}')
})
// The `inputMapping` belongs to the ACTIVE canonical mode's workflow only. resolveCanonicalMode
// picks the active mode (block.data.canonicalModes override, else the value heuristic); the wipe
// fires iff the ACTIVE mode's workflowId was removed by the remap. clearUnmapped: true throughout.
it('keeps inputMapping: active basic valid + dormant advanced stale (no override)', () => {
const subBlocks: SubBlockRecord = {
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.workflowId.value).toBe('wf-dst')
expect(result.manualWorkflowId.value).toBe('')
expect(result.inputMapping.value).toBe('{"a":"b"}')
})
it('wipes inputMapping: active advanced stale (canonicalModes override) + dormant basic valid', () => {
const subBlocks: SubBlockRecord = {
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, {
clearUnmapped: true,
canonicalModes: { workflowId: 'advanced' },
})
expect(result.workflowId.value).toBe('wf-dst')
expect(result.manualWorkflowId.value).toBe('')
expect(result.inputMapping.value).toBe('')
})
it('wipes inputMapping: active basic stale (heuristic) + dormant advanced valid', () => {
const subBlocks: SubBlockRecord = {
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' },
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.workflowId.value).toBe('')
expect(result.manualWorkflowId.value).toBe('wf-dst')
expect(result.inputMapping.value).toBe('')
})
it('wipes inputMapping: active advanced stale + basic empty (heuristic)', () => {
const subBlocks: SubBlockRecord = {
workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' },
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.manualWorkflowId.value).toBe('')
expect(result.inputMapping.value).toBe('')
})
it('keeps inputMapping: both modes valid', () => {
const subBlocks: SubBlockRecord = {
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.workflowId.value).toBe('wf-dst')
expect(result.manualWorkflowId.value).toBe('sub-dst')
expect(result.inputMapping.value).toBe('{"a":"b"}')
})
it('remaps the advanced-mode manualWorkflowId override', () => {
const subBlocks: SubBlockRecord = {
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
@@ -134,6 +219,61 @@ describe('remapWorkflowReferencesInSubBlocks', () => {
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
})
// create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state
// loaded). With BOTH the parent (`wf-src`) and child (`sub-src`) workflows copied, every
// reference variety must remap to the child id (NOT clear), even under fork-create's
// clearUnmapped policy - the explicit "both deployed and copied" guard.
it('remaps every reference variety when both referenced workflows are copied (clearUnmapped)', () => {
const subBlocks: SubBlockRecord = {
selector: { id: 'selector', type: 'workflow-selector', value: 'wf-src' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' },
manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' },
workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] },
tools: {
id: 'tools',
type: 'tool-input',
value: [
{ type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } },
{ type: 'custom-tool', customToolId: 'ct-1' },
],
},
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.selector.value).toBe('wf-dst')
expect(result.inputMapping.value).toBe('{"a":"b"}')
expect(result.manualWorkflowId.value).toBe('sub-dst')
expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst')
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }>
expect(tools[0].params?.workflowId).toBe('sub-dst')
expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' })
})
// A deployed source workflow whose state failed to load is excluded from the scoped fork map,
// so a copied workflow's reference to it clears (never dangles at a never-created child id).
it('clears references to a deployed-but-uncopied workflow absent from the scoped map', () => {
const subBlocks: SubBlockRecord = {
selector: { id: 'selector', type: 'workflow-selector', value: 'wf-uncopied' },
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
manualWorkflowIds: {
id: 'manualWorkflowIds',
type: 'short-input',
value: 'wf-src,wf-uncopied',
},
tools: {
id: 'tools',
type: 'tool-input',
value: [{ type: 'workflow_input', params: { workflowId: 'wf-uncopied' } }],
},
}
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
expect(result.selector.value).toBe('')
expect(result.inputMapping.value).toBe('')
expect(result.manualWorkflowIds.value).toBe('wf-dst')
expect(result.tools.value as unknown[]).toHaveLength(0)
})
})
describe('coerceObjectArray', () => {
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { remapConditionBlockIds } from '@/lib/workflows/condition-ids'
import { resolveCanonicalMode } from '@/lib/workflows/subblocks/visibility'
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('WorkflowRemapInternalIds')
@@ -140,7 +141,7 @@ export function remapVariableIdsInSubBlocks(
export function remapWorkflowReferencesInSubBlocks(
subBlocks: SubBlockRecord,
workflowIdMap: Map<string, string> | undefined,
options?: { clearUnmapped?: boolean }
options?: { clearUnmapped?: boolean; canonicalModes?: Record<string, 'basic' | 'advanced'> }
): SubBlockRecord {
if (!workflowIdMap?.size) return subBlocks
const clearUnmapped = options?.clearUnmapped ?? false
@@ -149,10 +150,24 @@ export function remapWorkflowReferencesInSubBlocks(
if (mapped) return mapped
return clearUnmapped ? '' : value
}
// The `workflowId` canonical pair: basic `workflow-selector` + advanced `manualWorkflowId`. Capture
// each key (by type/baseKey, regardless of value) and its ORIGINAL value so the inputMapping wipe
// below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`.
let basicId: string | undefined
let basicValue = ''
let advancedId: string | undefined
let advancedValue = ''
const updated: SubBlockRecord = {}
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (subBlock && typeof subBlock === 'object') {
const baseKey = key.replace(/_\d+$/, '')
if (subBlock.type === 'workflow-selector' && basicId === undefined) {
basicId = key
basicValue = typeof subBlock.value === 'string' ? subBlock.value : ''
} else if (baseKey === 'manualWorkflowId' && advancedId === undefined) {
advancedId = key
advancedValue = typeof subBlock.value === 'string' ? subBlock.value : ''
}
if (
(subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') &&
typeof subBlock.value === 'string' &&
@@ -178,6 +193,29 @@ export function remapWorkflowReferencesInSubBlocks(
}
updated[key] = subBlock
}
if (basicId !== undefined || advancedId !== undefined) {
const isEmptyValue = (value: unknown) => value === '' || value == null
const values: Record<string, unknown> = {}
if (basicId !== undefined) values[basicId] = basicValue
if (advancedId !== undefined) values[advancedId] = advancedValue
const activeMode = resolveCanonicalMode(
{ canonicalId: 'workflowId', basicId, advancedIds: advancedId ? [advancedId] : [] },
values,
options?.canonicalModes
)
const activeKey = activeMode === 'advanced' ? advancedId : basicId
const originalActive = activeKey === basicId ? basicValue : advancedValue
const postActive = activeKey !== undefined ? updated[activeKey]?.value : undefined
if (activeKey !== undefined && !isEmptyValue(originalActive) && isEmptyValue(postActive)) {
for (const [key, subBlock] of Object.entries(updated)) {
if (key.replace(/_\d+$/, '') !== 'inputMapping') continue
if (!subBlock || typeof subBlock !== 'object') continue
if (subBlock.value === '' || subBlock.value == null) continue
updated[key] = { ...subBlock, value: '' }
}
}
}
return updated
}
@@ -35,6 +35,7 @@ import {
normalizeDependencyValue,
parseDependsOn,
resolveDependencyValue,
scopeCanonicalModesForTool,
shouldUseSubBlockForTriggerModeCanonicalIndex,
} from '@/lib/workflows/subblocks/visibility'
import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
@@ -518,22 +519,6 @@ function safeParseJson(value: string): unknown {
}
}
function scopeToolCanonicalModes(
canonicalModes: CanonicalModeOverrides | undefined,
blockType: string | undefined
): CanonicalModeOverrides | undefined {
if (!canonicalModes || !blockType) return undefined
const prefix = `${blockType}:`
let scoped: CanonicalModeOverrides | undefined
for (const [key, value] of Object.entries(canonicalModes)) {
if (!key.startsWith(prefix) || !value) continue
scoped = scoped ?? {}
scoped[key.slice(prefix.length)] = value
}
return scoped
}
function parseToolParamValue(value: unknown, subBlockType: SubBlockType): unknown {
if (value === undefined || value === null) return ''
if (typeof value !== 'string') return value
@@ -738,7 +723,7 @@ export function getToolInputParamConfigs({
if (!toolId) return genericFallback()
const scopedCanonicalModes = scopeToolCanonicalModes(parentCanonicalModes, tool.type)
const scopedCanonicalModes = scopeCanonicalModesForTool(parentCanonicalModes, tool.type)
const blockConfig =
tool.type !== 'custom-tool' && tool.type !== 'mcp'
? (blockConfigs?.[tool.type] ?? getBlock(tool.type))
@@ -36,6 +36,30 @@ describe('buildSelectorContextFromBlock', () => {
expect(ctx.knowledgeBaseId).toBe('manual-kb-id')
})
it('resolves the ACTIVE member when both basic and advanced hold values (no last-write-wins)', () => {
const subBlocks = {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-basic',
},
manualKnowledgeBaseId: {
id: 'manualKnowledgeBaseId',
type: 'short-input',
value: 'kb-advanced',
},
}
// No override: the value heuristic keeps basic (matches a default-basic migrated block).
expect(buildSelectorContextFromBlock('knowledge', subBlocks).knowledgeBaseId).toBe('kb-basic')
// Explicit advanced toggle: the active member wins (the dormant basic value never leaks).
expect(
buildSelectorContextFromBlock('knowledge', subBlocks, {
canonicalModes: { knowledgeBaseId: 'advanced' },
}).knowledgeBaseId
).toBe('kb-advanced')
})
it('should skip null/empty values', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
knowledgeBaseSelector: {
+28 -10
View File
@@ -1,7 +1,12 @@
import { getBlock } from '@/blocks'
import type { SelectorContext } from '@/hooks/selectors/types'
import type { SubBlockState } from '@/stores/workflows/workflow/types'
import { buildCanonicalIndex } from './visibility'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
resolveActiveCanonicalValue,
} from './visibility'
/**
* Canonical param IDs (or raw subblock IDs) that correspond to SelectorContext fields.
@@ -42,7 +47,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
export function buildSelectorContextFromBlock(
blockType: string,
subBlocks: Record<string, SubBlockState | { value?: unknown }>,
opts?: { workflowId?: string; workspaceId?: string }
opts?: { workflowId?: string; workspaceId?: string; canonicalModes?: CanonicalModeOverrides }
): SelectorContext {
const context: SelectorContext = {}
if (opts?.workflowId) context.workflowId = opts.workflowId
@@ -52,17 +57,30 @@ export function buildSelectorContextFromBlock(
if (!blockConfig) return context
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const values = buildSubBlockValues(subBlocks)
const resolvedGroups = new Set<string>()
const setField = (key: string, value: unknown) => {
if (value === null || value === undefined) return
const strValue = typeof value === 'string' ? value : String(value)
if (!strValue) return
if (SELECTOR_CONTEXT_FIELDS.has(key as keyof SelectorContext)) {
context[key as keyof SelectorContext] = strValue
}
}
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
const val = subBlock?.value
if (val === null || val === undefined) continue
const strValue = typeof val === 'string' ? val : String(val)
if (!strValue) continue
const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? subBlockId
if (SELECTOR_CONTEXT_FIELDS.has(canonicalKey as keyof SelectorContext)) {
context[canonicalKey as keyof SelectorContext] = strValue
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId]
if (canonicalId) {
// A canonical group resolves to its ACTIVE member only (no last-write-wins between a
// basic/advanced pair when both hold values), honoring an explicit mode override.
if (resolvedGroups.has(canonicalId)) continue
resolvedGroups.add(canonicalId)
const group = canonicalIndex.groupsById[canonicalId]
setField(canonicalId, resolveActiveCanonicalValue(group, values, opts?.canonicalModes))
continue
}
setField(subBlockId, subBlock?.value)
}
return context
@@ -241,6 +241,47 @@ export function getCanonicalValues(
return { basicValue, advancedValue, advancedSourceId }
}
/**
* Resolve the ACTIVE canonical member's value for a group: the basic value in basic mode, the
* advanced value in advanced mode (per {@link resolveCanonicalMode} - honoring an explicit
* override, then the value heuristic). Strict: returns ONLY the active member's value with no
* cross-mode fallback, so a dormant mode's stale value can never leak. The single source of truth
* for "what value is live for this canonical pair" - use it instead of basic-first `||` /
* `?? 'basic'` reads or last-write-wins scans.
*/
export function resolveActiveCanonicalValue(
group: CanonicalGroup,
values: Record<string, unknown>,
overrides?: CanonicalModeOverrides
): unknown {
const mode = resolveCanonicalMode(group, values, overrides)
const { basicValue, advancedValue } = getCanonicalValues(group, values)
return mode === 'advanced' ? advancedValue : basicValue
}
/**
* Strip the `${toolType}:` prefix from canonical-mode override keys, returning the overrides for a
* nested tool keyed by bare `canonicalId`. An agent block stores its nested tools' modes scoped as
* `${toolType}:${canonicalId}` (to avoid cross-tool collisions when tools share a `canonicalParamId`),
* so this is the canonical un-scoping primitive. Returns `undefined` when there are no overrides, no
* `toolType`, or no matching keys.
*/
export function scopeCanonicalModesForTool(
overrides: CanonicalModeOverrides | undefined,
toolType: string | undefined
): CanonicalModeOverrides | undefined {
if (!overrides || !toolType) return undefined
const prefix = `${toolType}:`
let scoped: CanonicalModeOverrides | undefined
for (const [key, value] of Object.entries(overrides)) {
if (key.startsWith(prefix) && value) {
scoped = scoped ?? {}
scoped[key.slice(prefix.length)] = value
}
}
return scoped
}
/**
* Check if a block has any standalone advanced-only fields (not part of canonical pairs).
* These require the block-level advanced mode toggle to be visible.
@@ -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 {
clearFailedForkResourceReferences,
clearFailedReferencesInDeploymentVersions,
clearFailedReferencesInWorkflows,
rewriteDeploymentVersionState,
} from '@/lib/workspaces/fork/copy/cleanup-failed'
import type { ForkCopyResolver } from '@/lib/workspaces/fork/remap/fork-bootstrap'
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
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,356 @@
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 '@/lib/workspaces/fork/copy/copy-resources'
import type { ForkCopyResolver } from '@/lib/workspaces/fork/remap/fork-bootstrap'
import {
clearDependentsOnRemap,
type ForkRemapKind,
remapForkSubBlocks,
} from '@/lib/workspaces/fork/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.
*/
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 '@/lib/workspaces/fork/copy/content-copy-runner'
import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files'
import type { ForkContentPlan } from '@/lib/workspaces/fork/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)
})
})
@@ -1,10 +1,52 @@
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 '@/lib/workspaces/fork/background-work/store'
import { clearFailedForkResourceReferences } from '@/lib/workspaces/fork/copy/cleanup-failed'
import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files'
import { executeForkFileBlobCopies } from '@/lib/workspaces/fork/copy/copy-files'
import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources'
import type { ForkContentPlan, ForkFailedResource } from '@/lib/workspaces/fork/copy/copy-resources'
import { copyForkResourceContent } from '@/lib/workspaces/fork/copy/copy-resources'
import type { ForkContentRefMaps } from '@/lib/workspaces/fork/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
@@ -14,15 +56,66 @@ import { copyForkResourceContent } from '@/lib/workspaces/fork/copy/copy-resourc
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
@@ -33,8 +126,23 @@ export interface ForkContentCopyPayload {
export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise<void> {
const { contentPlan, blobTasks, statusId, requestId } = payload
try {
const resourceCounts = await copyForkResourceContent({ contentPlan, requestId })
const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId)
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) {
@@ -44,7 +152,12 @@ export async function runForkContentCopy(payload: ForkContentCopyPayload): Promi
failed > 0
? `Copied ${copied} item${copied === 1 ? '' : 's'}; ${failed} could not be copied`
: `Copied ${copied} item${copied === 1 ? '' : 's'}`,
metadata: { copied, failed },
metadata: {
copied,
failed,
clearedReferences: cleared,
...(clearingFailed ? { clearingFailed: true } : {}),
},
})
}
} catch (error) {
@@ -57,3 +170,42 @@ export async function runForkContentCopy(payload: ForkContentCopyPayload): Promi
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(() => {})
}
}
}
+74 -21
View File
@@ -2,15 +2,28 @@ 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 } from 'drizzle-orm'
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { downloadFile, 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 '@/lib/workspaces/fork/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
@@ -25,10 +38,17 @@ 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. File identity is not persisted in the fork resource map - files
* are a fork-copy-only resource (not remapped on promote).
* 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[]
}
@@ -40,31 +60,44 @@ export interface PlanForkFileCopiesResult {
* (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[]
fileIds?: string[]
fileKeys?: string[]
now: Date
}): Promise<PlanForkFileCopiesResult> {
const { tx, sourceWorkspaceId, childWorkspaceId, userId, fileIds, now } = params
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) return { keyMap, blobTasks }
if (fileIds.length === 0 && fileKeys.length === 0) return { keyMap, idMap, blobTasks }
// Batch the metadata read (one query for all selected files) instead of a per-file
// lookup: 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 passed here is ignored rather than copied.
// 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(
inArray(workspaceFiles.id, fileIds),
selectors.length === 1 ? selectors[0] : or(...selectors),
eq(workspaceFiles.workspaceId, sourceWorkspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
@@ -87,6 +120,7 @@ export async function planForkFileCopies(params: {
uploadedAt: now,
})
keyMap.set(meta.key, targetKey)
idMap.set(meta.id, childFileId)
blobTasks.push({
sourceKey: meta.key,
targetKey,
@@ -98,21 +132,27 @@ export async function planForkFileCopies(params: {
})
}
return { keyMap, blobTasks }
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. Best-effort: a failed blob leaves the metadata row
* pointing at a missing object, which the user can re-upload.
* 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).
*/
export async function executeForkFileBlobCopies(
blobTasks: BlobCopyTask[],
requestId = 'unknown'
): Promise<{ copied: number; failed: number }> {
requestId = 'unknown',
contentRefMaps?: ForkContentRefMaps
): Promise<{ copied: number; failed: number; failedTargetKeys: string[] }> {
let copied = 0
let failed = 0
const failedTargetKeys: string[] = []
for (const task of blobTasks) {
try {
const buffer = await downloadFile({
@@ -120,8 +160,21 @@ export async function executeForkFileBlobCopies(
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: buffer,
file: body,
fileName: task.fileName,
contentType: task.contentType,
context: task.context,
@@ -135,12 +188,12 @@ export async function executeForkFileBlobCopies(
})
copied += 1
} catch (error) {
failed += 1
failedTargetKeys.push(task.targetKey)
logger.warn(`[${requestId}] Failed to copy file blob during fork`, {
targetKey: task.targetKey,
error: getErrorMessage(error),
})
}
}
return { copied, failed }
return { copied, failed: failedTargetKeys.length, failedTargetKeys }
}
@@ -0,0 +1,462 @@
/**
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
resetDbChainMock,
storageServiceMock,
storageServiceMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
import type { DbOrTx } from '@/lib/db/types'
import {
copyForkResourceContainers,
copyForkResourceContent,
type ForkContentPlan,
planForkMappedKbDocumentCopies,
} from '@/lib/workspaces/fork/copy/copy-resources'
import type { ForkReferenceResolver } from '@/lib/workspaces/fork/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('#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: [],
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 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'],
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('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)
})
})
@@ -4,22 +4,35 @@ import {
document,
embedding,
knowledgeBase,
mcpServers,
skill,
userTableDefinitions,
userTableRows,
workflowMcpServer,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, gt, inArray, isNull, type SQL } from 'drizzle-orm'
import { and, asc, eq, gt, inArray, isNull, type SQL, sql } from 'drizzle-orm'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import type { DbOrTx } from '@/lib/db/types'
import { generateMcpServerId } from '@/lib/mcp/utils'
import type { TableSchema } from '@/lib/table/types'
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import type {
ForkMappingUpsert,
ForkResourceType,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import {
type ForkContentRefMaps,
rewriteForkContentRefs,
rewriteForkResourceUrls,
} from '@/lib/workspaces/fork/remap/remap-content-refs'
import {
type ForkReferenceResolver,
rewriteEnvRefsInText,
} from '@/lib/workspaces/fork/remap/remap-references'
import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/remap/remap-table-groups'
const logger = createLogger('WorkspaceForkCopyResources')
@@ -27,6 +40,20 @@ const logger = createLogger('WorkspaceForkCopyResources')
/** Page size for the post-transaction bulk content copy (keyset-paginated). */
const CONTENT_PAGE = 500
/**
* Max documents copied concurrently within one KB page. Bounds fan-out (blob copy + per-doc
* embedding paging) so a large page doesn't issue every request at once; the keyset loop still
* processes one page at a time, so peak concurrency stays at this cap regardless of KB size.
*/
const KB_DOCUMENT_COPY_CONCURRENCY = 5
/**
* Max copied skill bodies rewritten concurrently within one keyset page. Bounds the per-skill
* re-read + UPDATE fan-out so a page of copied skills doesn't issue every write at once; the keyset
* loop still processes one page at a time, so peak concurrency stays at this cap.
*/
const SKILL_REWRITE_CONCURRENCY = 5
export interface CopyResourcesParams {
tx: DbOrTx
sourceWorkspaceId: string
@@ -37,12 +64,25 @@ export interface CopyResourcesParams {
selection: {
customTools: string[]
skills: string[]
mcpServers: string[]
workflowMcpServers: string[]
tables: string[]
knowledgeBases: string[]
}
/** source workflow id -> child workflow id, for table workflow-group remap. */
workflowIdMap: Map<string, string>
/**
* Source KB-document ids referenced by the copied workflows (document-selector values +
* nested `documentId` tool params). Documents in this set whose parent KB is being copied
* get a placeholder row + a persisted `knowledge_document` id map inside the transaction, so
* the reference remaps to the copied document instead of being cleared. Defaults to none.
*/
referencedDocumentIds?: string[]
/**
* Resolve a source env-var name to its target name, so a copied custom tool's `code` (which
* embeds `{{ENV}}` refs) is rewritten when a sync renames an env var. Provided by promote (the
* plan resolver); omitted by fork-create, which preserves env names verbatim (no rewrite).
*/
resolveEnvName?: (key: string) => string | null | undefined
}
export interface ForkContentPlanEntry {
@@ -50,21 +90,83 @@ export interface ForkContentPlanEntry {
childId: string
}
/**
* A KB to copy post-commit, plus the source-document -> child-document id map for the
* documents that were pre-created as placeholders in the transaction (referenced by copied
* workflows). The content phase fills those exact child ids (and copies the rest fresh) so a
* remapped `document-selector` reference can never dangle.
*/
export interface ForkContentKbEntry extends ForkContentPlanEntry {
documentIdMap: Record<string, string>
}
/**
* A copied skill whose body's in-content references (`sim:` links + embedded file URLs) are
* rewritten post-commit. Only the child id is carried: the child row is inserted in the fork tx
* with the SOURCE body copied IN-DB (never materialized in app memory or embedded in the job
* payload), and the content phase RE-READS the body keyset-paginated to rewrite it best-effort so
* it points at the copied child resources (an unmapped target degrades to a graceful broken link,
* never an FK/subblock reference).
*/
export interface ForkContentSkillEntry {
childId: string
}
/**
* A single source document copied into an EXISTING (already-mapped, not copied this sync) target
* KB - the sync-only "document into mapped KB" path (U-docs). The placeholder row is inserted in
* the promote tx at `childDocId` under `childKnowledgeBaseId`; the content phase fills its
* embeddings (and re-keys its blob) best-effort. Distinct from {@link ForkContentKbEntry}, which
* copies a whole KB's documents - here only the referenced documents are placed, since the KB
* itself already exists in the target with its own documents.
*/
export interface ForkContentDocumentEntry {
sourceDocId: string
childDocId: string
childKnowledgeBaseId: string
/** Source blob fields captured at placeholder time, for the post-commit blob re-key. */
storageKey: string | null
filename: string
mimeType: string
}
/** Bulk content to copy AFTER the fork transaction commits (best-effort, batched). */
export interface ForkContentPlan {
sourceWorkspaceId: string
childWorkspaceId: string
/** Initiating user, recorded as the owner of copied KB-document blob bindings in the child. */
userId: string
tables: ForkContentPlanEntry[]
knowledgeBases: ForkContentPlanEntry[]
knowledgeBases: ForkContentKbEntry[]
skills: ForkContentSkillEntry[]
/** Documents copied into an already-existing target KB (sync-only; empty at fork create). */
documents: ForkContentDocumentEntry[]
}
/**
* A resource whose post-commit content fill failed, so every reference to it must be cleared.
* A table carries its child definition id; a KB carries its child id (dropping it cascade-removes
* its documents + embeddings) and the child ids of the document placeholders pre-created in the
* fork tx, so `document-selector` references clear too. A standalone `knowledge-document` (a doc
* copied into an existing target KB) carries just its child id - dropping that one row (its
* embeddings cascade) without touching the existing KB. A `file` carries the COPIED child storage
* key whose blob duplication failed: its `file-upload` references are cleared so no block points at
* a missing object. Unlike the others, a failed file drops no row - the metadata row is left so the
* user can re-upload the blob.
*/
export type ForkFailedResource =
| { kind: 'table'; childId: string }
| { kind: 'knowledge-base'; childId: string; documentChildIds: string[] }
| { kind: 'knowledge-document'; childId: string }
| { kind: 'file'; childKey: string }
/** Display names of the copied resources, by kind, for the fork activity report. */
export interface ForkCopiedResourceNames {
tables: string[]
knowledgeBases: string[]
customTools: string[]
skills: string[]
mcpServers: string[]
workflowMcpServers: string[]
}
export interface CopyResourcesResult {
@@ -87,6 +189,13 @@ function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkReso
return map
}
/**
* Child `skill` insert whose `content` is a correlated subquery (copied server-side from the source
* row) rather than a materialized string, so the fork tx never pulls skill bodies into app memory -
* see the skeleton skill copy in {@link copyForkResourceContainers}.
*/
type SkillSkeletonInsert = Omit<typeof skill.$inferInsert, 'content'> & { content: SQL }
/**
* Copy the selected resources' **container rows** into the child workspace inside
* the fork transaction: custom tools, skills, and MCP server configs (each a
@@ -102,20 +211,25 @@ export async function copyForkResourceContainers(
params: CopyResourcesParams
): Promise<CopyResourcesResult> {
const { tx, sourceWorkspaceId, childWorkspaceId, userId, now, selection, workflowIdMap } = params
const referencedDocumentIds = params.referencedDocumentIds ?? []
const resolveEnvName = params.resolveEnvName
const idMap = new Map<ForkResourceType, Map<string, string>>()
const mappingEntries: ForkMappingUpsert[] = []
const contentPlan: ForkContentPlan = {
sourceWorkspaceId,
childWorkspaceId,
userId,
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
}
const names: ForkCopiedResourceNames = {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
}
const record = (type: ForkResourceType, sourceId: string, childId: string) => {
@@ -145,6 +259,13 @@ export async function copyForkResourceContainers(
id: childId,
workspaceId: childWorkspaceId,
userId,
// The code column is copied verbatim, so a `{{ENV}}` ref in it would stay stale when a
// sync renames the env var. Rewrite those refs through the env-name resolver (subblock
// values are already remapped by the reference transform). Fork-create passes no resolver,
// preserving env names by default.
...(resolveEnvName && typeof row.code === 'string'
? { code: rewriteEnvRefsInText(row.code, resolveEnvName) }
: {}),
createdAt: now,
updatedAt: now,
})
@@ -155,11 +276,22 @@ export async function copyForkResourceContainers(
}
if (selection.skills.length > 0) {
// Select every skill column EXCEPT `content`: the body (capped at 50 KB each, up to 2000 skills)
// is copied server-side via the correlated subquery below, so it is never materialized in app
// memory while the fork tx holds its locks - nor carried in the background-job payload.
const rows = await tx
.select()
.select({
id: skill.id,
workspaceId: skill.workspaceId,
userId: skill.userId,
name: skill.name,
description: skill.description,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
})
.from(skill)
.where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId)))
const inserts: (typeof skill.$inferInsert)[] = []
const inserts: SkillSkeletonInsert[] = []
for (const row of rows) {
const childId = generateId()
inserts.push({
@@ -167,59 +299,48 @@ export async function copyForkResourceContainers(
id: childId,
workspaceId: childWorkspaceId,
userId,
// Copy the body straight from the source row in-DB (never through app memory). Re-read and
// rewritten post-commit (see copyForkResourceContent), out of the locked fork tx.
content: sql`(SELECT ${skill.content} FROM ${skill} WHERE ${skill.id} = ${row.id})`,
createdAt: now,
updatedAt: now,
})
record('skill', row.id, childId)
contentPlan.skills.push({ childId })
names.skills.push(row.name)
}
if (inserts.length > 0) await tx.insert(skill).values(inserts)
}
if (selection.mcpServers.length > 0) {
if (selection.workflowMcpServers.length > 0) {
const rows = await tx
.select()
.from(mcpServers)
.from(workflowMcpServer)
.where(
and(
inArray(mcpServers.id, selection.mcpServers),
eq(mcpServers.workspaceId, sourceWorkspaceId),
isNull(mcpServers.deletedAt)
inArray(workflowMcpServer.id, selection.workflowMcpServers),
eq(workflowMcpServer.workspaceId, sourceWorkspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
// `generateMcpServerId` is deterministic on (workspace, url), so two selected
// servers with the same normalized URL derive the same child id. Insert once
// and map both source ids to the surviving child rather than aborting the fork.
const insertsByChildId = new Map<string, typeof mcpServers.$inferInsert>()
// Copy workflow-publishing MCP servers as config-only shells: the server definition
// (name/description/visibility) with NO `workflow_mcp_tool` rows attached, so the child
// re-registers its own workflows. These are fork-copy-only (not referenced by subblocks),
// so they are not recorded in the fork resource map.
const inserts: (typeof workflowMcpServer.$inferInsert)[] = []
for (const row of rows) {
const childId = row.url ? generateMcpServerId(childWorkspaceId, row.url) : generateId()
record('mcp_server', row.id, childId)
if (insertsByChildId.has(childId)) continue
names.mcpServers.push(row.name)
insertsByChildId.set(childId, {
inserts.push({
...row,
id: childId,
id: generateId(),
workspaceId: childWorkspaceId,
createdBy: userId,
// Secrets are never copied across workspaces: drop the registered OAuth
// client + any auth headers so the child re-authenticates from scratch.
oauthClientId: null,
oauthClientSecret: null,
headers: {},
connectionStatus: 'disconnected',
lastConnected: null,
lastError: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
names.workflowMcpServers.push(row.name)
}
if (insertsByChildId.size > 0) {
await tx
.insert(mcpServers)
.values([...insertsByChildId.values()])
.onConflictDoNothing()
}
if (inserts.length > 0) await tx.insert(workflowMcpServer).values(inserts)
}
if (selection.tables.length > 0) {
@@ -273,6 +394,7 @@ export async function copyForkResourceContainers(
)
)
const inserts: (typeof knowledgeBase.$inferInsert)[] = []
const kbEntryBySourceId = new Map<string, ForkContentKbEntry>()
for (const base of bases) {
const childKbId = generateId()
inserts.push({
@@ -285,32 +407,220 @@ export async function copyForkResourceContainers(
updatedAt: now,
})
record('knowledge_base', base.id, childKbId)
contentPlan.knowledgeBases.push({ sourceId: base.id, childId: childKbId })
const entry: ForkContentKbEntry = { sourceId: base.id, childId: childKbId, documentIdMap: {} }
contentPlan.knowledgeBases.push(entry)
kbEntryBySourceId.set(base.id, entry)
names.knowledgeBases.push(base.name)
}
if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts)
// Pre-create placeholder document rows for the documents the copied workflows
// reference, at child ids generated inside the transaction, so each
// `document-selector` reference can be remapped to a valid copied document rather
// than cleared. Only documents whose parent KB is in this copy (FK-safe: the KB
// rows were just inserted above) are placed; the heavy content (embeddings + blob)
// is filled at these exact ids by the post-commit content phase.
await createForkDocumentPlaceholders({
tx,
kbIdMap: idMap.get('knowledge_base') ?? new Map(),
kbEntryBySourceId,
referencedDocumentIds,
record,
})
}
return { idMap, mappingEntries, contentPlan, names }
}
/**
* Insert placeholder {@link document} rows in the child KBs for the referenced documents
* whose parent KB is being copied, recording the `knowledge_document` source->child id map.
* Each placeholder is the source document's metadata row at a fresh child id; its embeddings
* and (re-keyed) blob are filled by {@link copyForkResourceContent} after commit. Documents
* whose parent KB is not copied are skipped, leaving their references to be cleared.
*/
async function createForkDocumentPlaceholders(params: {
tx: DbOrTx
kbIdMap: Map<string, string>
kbEntryBySourceId: Map<string, ForkContentKbEntry>
referencedDocumentIds: string[]
record: (type: ForkResourceType, sourceId: string, childId: string) => void
}): Promise<void> {
const { tx, kbIdMap, kbEntryBySourceId, referencedDocumentIds, record } = params
if (referencedDocumentIds.length === 0 || kbIdMap.size === 0) return
const docs = await tx
.select()
.from(document)
.where(
and(
inArray(document.id, referencedDocumentIds),
inArray(document.knowledgeBaseId, Array.from(kbIdMap.keys())),
isNull(document.deletedAt),
isNull(document.archivedAt)
)
)
const inserts: (typeof document.$inferInsert)[] = []
for (const doc of docs) {
const childKbId = kbIdMap.get(doc.knowledgeBaseId)
const kbEntry = kbEntryBySourceId.get(doc.knowledgeBaseId)
if (!childKbId || !kbEntry) continue
const childDocId = generateId()
inserts.push({
...doc,
id: childDocId,
knowledgeBaseId: childKbId,
connectorId: null,
deletedAt: null,
archivedAt: null,
})
record('knowledge_document', doc.id, childDocId)
kbEntry.documentIdMap[doc.id] = childDocId
}
if (inserts.length > 0) await tx.insert(document).values(inserts)
}
/**
* Plan the copy of documents referenced by the synced workflows whose parent knowledge base is
* ALREADY mapped to an existing target KB (not copied this sync) but the document itself is not
* mapped. For each, insert a placeholder document row into that existing target KB and return:
* - `documents`: content entries for the post-commit embeddings/blob fill,
* - `docIdMap`: source->child document id map (to augment the resolver so the `document-selector`
* reference remaps to the copy instead of being cleared),
* - `mappingEntries`: `knowledge_document` rows to persist (so a re-sync resolves the copy).
*
* FK-safe: the target KB is one the resolver returns (existence-checked via `validTargetIdsByKind`),
* and the placeholder insert is a bounded in-tx write with no object-storage I/O. Documents whose
* parent KB is being copied THIS sync are handled by {@link createForkDocumentPlaceholders} under
* that copied KB and are excluded here via `alreadyCopiedSourceDocIds`. A referenced document whose
* parent KB is not mapped at all is left untouched, so its reference is cleared as before.
*/
export async function planForkMappedKbDocumentCopies(params: {
tx: DbOrTx
resolver: ForkReferenceResolver
referencedDocumentIds: string[]
alreadyCopiedSourceDocIds: Set<string>
}): Promise<{
documents: ForkContentDocumentEntry[]
docIdMap: Map<string, string>
mappingEntries: ForkMappingUpsert[]
}> {
const { tx, resolver, referencedDocumentIds, alreadyCopiedSourceDocIds } = params
const documents: ForkContentDocumentEntry[] = []
const docIdMap = new Map<string, string>()
const mappingEntries: ForkMappingUpsert[] = []
// A doc whose parent KB is being copied this sync is placed under that copied KB (skip via
// alreadyCopiedSourceDocIds); a doc that already resolves was mapped by a prior sync (skip via
// the resolver). Everything else is a candidate to copy into its (existing) mapped target KB.
const candidateIds = referencedDocumentIds.filter(
(id) => !alreadyCopiedSourceDocIds.has(id) && resolver('knowledge-document', id) == null
)
if (candidateIds.length === 0) return { documents, docIdMap, mappingEntries }
const docs = await tx
.select()
.from(document)
.where(
and(
inArray(document.id, candidateIds),
isNull(document.deletedAt),
isNull(document.archivedAt)
)
)
const inserts: (typeof document.$inferInsert)[] = []
for (const doc of docs) {
// The parent KB must already exist in the target. The resolver returns a target KB id only
// for a mapped, still-existing KB (validTargetIdsByKind), so this is FK-safe; a doc whose KB
// isn't mapped resolves null here and is left for its reference to be cleared.
const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId)
if (targetKbId == null) continue
const childDocId = generateId()
inserts.push({
...doc,
id: childDocId,
knowledgeBaseId: targetKbId,
connectorId: null,
deletedAt: null,
archivedAt: null,
})
docIdMap.set(doc.id, childDocId)
mappingEntries.push({
resourceType: 'knowledge_document',
parentResourceId: doc.id,
childResourceId: childDocId,
})
documents.push({
sourceDocId: doc.id,
childDocId,
childKnowledgeBaseId: targetKbId,
storageKey: doc.storageKey,
filename: doc.filename,
mimeType: doc.mimeType,
})
}
if (inserts.length > 0) await tx.insert(document).values(inserts)
return { documents, docIdMap, mappingEntries }
}
/**
* Recursively rewrite the in-workspace resource URLs stored in a copied table row's `data` jsonb
* (column -> value), so resource-chip cells keep resolving after a cross-workspace copy. Fast-rejects
* a string without `/workspace/` before any regex, and returns the same reference when nothing
* changed (so only rows with a rewritten cell allocate a new `data` object).
*/
function remapTableRowResourceUrls(value: unknown, maps: ForkContentRefMaps): unknown {
if (typeof value === 'string') {
if (!value.includes('/workspace/')) return value
return rewriteForkResourceUrls(value, maps)
}
if (Array.isArray(value)) {
let changed = false
const next = value.map((item) => {
const remapped = remapTableRowResourceUrls(item, maps)
if (remapped !== item) changed = true
return remapped
})
return changed ? next : value
}
if (isRecord(value)) {
let changed = false
const next: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
const remapped = remapTableRowResourceUrls(item, maps)
if (remapped !== item) changed = true
next[key] = remapped
}
return changed ? next : value
}
return value
}
/**
* Copy the heavy resource content described by a {@link ForkContentPlan} AFTER the
* fork transaction has committed: table rows, and KB documents + embeddings. Reads
* and writes are keyset-paginated so peak memory is bounded to one page, and each
* resource is copied in its own short statements (never one long transaction).
* Best-effort: a failure on one resource is logged and the others continue - the
* fork itself (workflows + container rows) already succeeded.
* fork itself (workflows + container rows) already succeeded. Copied skill bodies are
* RE-READ (keyset-paginated) and rewritten here too (`contentRefMaps`), out of the locked
* fork tx; that rewrite is an in-content link fixup, so a failure degrades to a broken link
* and never fails a resource.
*/
export async function copyForkResourceContent(params: {
contentPlan: ForkContentPlan
/** In-content reference maps for rewriting copied skill bodies post-commit (best-effort). */
contentRefMaps?: ForkContentRefMaps
requestId?: string
}): Promise<{ copied: number; failed: number }> {
const { contentPlan, requestId = 'unknown' } = params
const { childWorkspaceId } = contentPlan
}): Promise<{ copied: number; failed: number; failures: ForkFailedResource[] }> {
const { contentPlan, contentRefMaps, requestId = 'unknown' } = params
const { childWorkspaceId, userId } = contentPlan
let copiedResources = 0
let failedResources = 0
const failures: ForkFailedResource[] = []
for (const table of contentPlan.tables) {
try {
@@ -334,6 +644,8 @@ export async function copyForkResourceContent(params: {
id: generateId(),
tableId: table.childId,
workspaceId: childWorkspaceId,
// Repoint resource-chip URLs in cell data at the child copies (no-op when no maps).
data: contentRefMaps ? remapTableRowResourceUrls(row.data, contentRefMaps) : row.data,
}))
)
copied += rows.length
@@ -347,6 +659,7 @@ export async function copyForkResourceContent(params: {
copiedResources += 1
} catch (error) {
failedResources += 1
failures.push({ kind: 'table', childId: table.childId })
logger.warn(`[${requestId}] Failed to copy table rows during fork`, {
sourceTableId: table.sourceId,
error: getErrorMessage(error),
@@ -375,24 +688,60 @@ export async function copyForkResourceContent(params: {
.orderBy(asc(document.id))
.limit(CONTENT_PAGE)
if (docs.length === 0) break
for (const doc of docs) {
const childDocId = generateId()
await db.insert(document).values({
...doc,
id: childDocId,
knowledgeBaseId: kb.childId,
connectorId: null,
deletedAt: null,
archivedAt: null,
})
await copyDocumentEmbeddings(doc.id, childDocId, kb.childId)
}
// Copy the page's documents with bounded concurrency. The mapper never rejects
// (it captures its error), so all in-flight work settles before this resolves - no
// orphaned writes survive a failure - and a captured error is rethrown after to keep
// the KB ALL-OR-NOTHING (any failed doc fails the whole KB -> cleanup below).
const docErrors = await mapWithConcurrency(
docs,
KB_DOCUMENT_COPY_CONCURRENCY,
async (doc): Promise<unknown> => {
try {
// Referenced documents were pre-created as placeholders in the fork tx at this
// exact child id, so a remapped `document-selector` reference can't dangle; the
// rest get a fresh id here. Copy the blob to a child-scoped KB key so the copy
// never shares the source's object (best-effort - keeps the source key on failure).
const placeholderId = kb.documentIdMap[doc.id]
const childDocId = placeholderId ?? generateId()
const blob = await copyKbDocumentBlob(doc, childWorkspaceId, userId, requestId)
if (placeholderId) {
if (blob) {
await db
.update(document)
.set({ storageKey: blob.storageKey, fileUrl: blob.fileUrl })
.where(eq(document.id, childDocId))
}
} else {
await db.insert(document).values({
...doc,
id: childDocId,
knowledgeBaseId: kb.childId,
connectorId: null,
deletedAt: null,
archivedAt: null,
...(blob ? { storageKey: blob.storageKey, fileUrl: blob.fileUrl } : {}),
})
}
await copyDocumentEmbeddings(doc.id, childDocId, kb.childId)
return null
} catch (error) {
return error
}
}
)
const docError = docErrors.find((error) => error != null)
if (docError) throw docError
afterDocId = docs[docs.length - 1].id
if (docs.length < CONTENT_PAGE) break
}
copiedResources += 1
} catch (error) {
failedResources += 1
failures.push({
kind: 'knowledge-base',
childId: kb.childId,
documentChildIds: Object.values(kb.documentIdMap),
})
logger.warn(`[${requestId}] Failed to copy knowledge base content during fork`, {
sourceKnowledgeBaseId: kb.sourceId,
error: getErrorMessage(error),
@@ -400,7 +749,90 @@ export async function copyForkResourceContent(params: {
}
}
return { copied: copiedResources, failed: failedResources }
// Fill the documents copied into an already-existing target KB (sync-only U-docs path). The
// placeholder rows were inserted in the promote tx; here we re-key each blob and copy its
// embeddings into the existing KB. A per-document failure drops just that placeholder (its
// embeddings cascade) and clears its `document-selector` references - the existing KB and its
// own documents are never touched.
for (const docEntry of contentPlan.documents) {
try {
const blob = await copyKbDocumentBlob(
{
storageKey: docEntry.storageKey,
filename: docEntry.filename,
mimeType: docEntry.mimeType,
},
childWorkspaceId,
userId,
requestId
)
if (blob) {
await db
.update(document)
.set({ storageKey: blob.storageKey, fileUrl: blob.fileUrl })
.where(eq(document.id, docEntry.childDocId))
}
await copyDocumentEmbeddings(
docEntry.sourceDocId,
docEntry.childDocId,
docEntry.childKnowledgeBaseId
)
copiedResources += 1
} catch (error) {
failedResources += 1
failures.push({ kind: 'knowledge-document', childId: docEntry.childDocId })
logger.warn(`[${requestId}] Failed to copy document into mapped KB during sync`, {
sourceDocumentId: docEntry.sourceDocId,
error: getErrorMessage(error),
})
}
}
// Rewrite copied skill bodies out of the locked fork tx (the rows were inserted with the source
// body via an in-DB copy). RE-READ each child body keyset-paginated - it is never carried in the
// job payload - remap its `sim:` links + embedded file URLs to the child resources, and write it
// back. An in-content link fixup: a per-skill failure degrades to a broken link and is never
// counted as a failed resource (unmapped targets are left as graceful broken links).
if (contentRefMaps && contentPlan.skills.length > 0) {
const childSkillIds = contentPlan.skills.map((entry) => entry.childId)
let afterId: string | null = null
for (;;) {
const where: SQL<unknown> | undefined =
afterId === null
? inArray(skill.id, childSkillIds)
: and(inArray(skill.id, childSkillIds), gt(skill.id, afterId))
const rows = await db
.select({ id: skill.id, content: skill.content })
.from(skill)
.where(where)
.orderBy(asc(skill.id))
.limit(CONTENT_PAGE)
if (rows.length === 0) break
// Bounded fan-out: the mapper never rejects (it captures its own error), so mapWithConcurrency
// settles the whole page. A rewrite is a best-effort link fixup, so a per-skill failure is
// logged and the body keeps its source links rather than failing a resource.
await mapWithConcurrency(rows, SKILL_REWRITE_CONCURRENCY, async (row): Promise<void> => {
try {
const rewritten = rewriteForkContentRefs(row.content, contentRefMaps)
if (rewritten !== row.content) {
await db.update(skill).set({ content: rewritten }).where(eq(skill.id, row.id))
}
} catch (error) {
logger.warn(
`[${requestId}] Failed to rewrite copied skill content; keeping source links`,
{
childSkillId: row.id,
error: getErrorMessage(error),
}
)
}
})
afterId = rows[rows.length - 1].id
if (rows.length < CONTENT_PAGE) break
}
}
return { copied: copiedResources, failed: failedResources, failures }
}
async function copyDocumentEmbeddings(
@@ -433,3 +865,49 @@ async function copyDocumentEmbeddings(
if (rows.length < CONTENT_PAGE) break
}
}
/**
* Duplicate a KB document's stored blob to a fresh child-scoped KB storage key so the copied
* document never points at the source's object. The child key is written with a `file_metadata`
* ownership binding owned by the CHILD workspace (mirroring the canonical KB upload), so
* `verifyKBFileAccess` grants a child-workspace member - without it the copied object is
* download-denied (no binding = deny). Returns the new `storageKey` + serve `fileUrl`, or null
* when there is no internal blob to copy (external/`data:` docs have a null `storageKey`) or the
* copy fails - best-effort, so a single blob failure never aborts the KB.
*/
async function copyKbDocumentBlob(
doc: { storageKey: string | null; filename: string; mimeType: string },
childWorkspaceId: string,
userId: string,
requestId: string
): Promise<{ storageKey: string; fileUrl: string } | null> {
if (!doc.storageKey) return null
try {
const buffer = await downloadFile({
key: doc.storageKey,
context: 'knowledge-base',
maxBytes: MAX_FILE_SIZE,
})
const targetKey = generateKnowledgeBaseFileKey(doc.filename)
await uploadFile({
file: buffer,
fileName: doc.filename,
contentType: doc.mimeType,
context: 'knowledge-base',
customKey: targetKey,
preserveKey: true,
metadata: {
userId,
workspaceId: childWorkspaceId,
originalName: doc.filename,
},
})
return { storageKey: targetKey, fileUrl: `/api/files/serve/${encodeURIComponent(targetKey)}` }
} catch (error) {
logger.warn(`[${requestId}] Failed to copy KB document blob during fork; keeping source key`, {
sourceStorageKey: doc.storageKey,
error: getErrorMessage(error),
})
return null
}
}
@@ -33,7 +33,11 @@ import type {
const logger = createLogger('WorkspaceForkCopyWorkflows')
type SubBlockTransform = (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord
type SubBlockTransform = (
subBlocks: SubBlockRecord,
blockType: string,
canonicalModes?: Record<string, 'basic' | 'advanced'>
) => SubBlockRecord
interface ResolveForkFolderMappingParams {
tx: DbOrTx
@@ -390,7 +394,12 @@ export async function copyWorkflowStateIntoTarget(
const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks)
let subBlocks: SubBlockRecord = sanitizedSource
if (transformSubBlocks) {
subBlocks = transformSubBlocks(subBlocks, block.type)
subBlocks = transformSubBlocks(
subBlocks,
block.type,
(block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined)
?.canonicalModes
)
}
if (varIdMapping.size > 0) {
subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping)
@@ -399,6 +408,9 @@ export async function copyWorkflowStateIntoTarget(
// rather than leave them pointing at the source workspace.
subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, {
clearUnmapped: true,
canonicalModes: (
block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined
)?.canonicalModes,
})
subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord
@@ -0,0 +1,41 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildForkWorkflowIdMap } from '@/lib/workspaces/fork/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
}
+67 -49
View File
@@ -6,8 +6,6 @@ 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 { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { runDetached } from '@/lib/core/utils/background'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
@@ -16,7 +14,9 @@ import {
} from '@/lib/workspaces/fork/background-work/store'
import {
type ForkContentCopyPayload,
runForkContentCopy,
hasForkContentToCopy,
scheduleForkContentCopy,
serializeContentRefMaps,
} from '@/lib/workspaces/fork/copy/content-copy-runner'
import { planForkFileCopies } from '@/lib/workspaces/fork/copy/copy-files'
import {
@@ -29,6 +29,7 @@ import {
resolveForkFolderMapping,
} from '@/lib/workspaces/fork/copy/copy-workflows'
import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
import { buildForkWorkflowIdMap } from '@/lib/workspaces/fork/copy/workflow-id-map'
import { setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
import {
type ForkBlockPair,
@@ -41,6 +42,7 @@ import {
seedEdgeMappings,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import { createForkBootstrapTransform } from '@/lib/workspaces/fork/remap/fork-bootstrap'
import { collectReferencedDocumentIds } from '@/lib/workspaces/fork/remap/reference-scan'
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import type { WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
@@ -55,7 +57,8 @@ export interface ForkResourceSelection {
knowledgeBases: string[]
customTools: string[]
skills: string[]
mcpServers: string[]
/** Workflow-publishing MCP servers (copied as config-only shells); external MCP is never copied. */
workflowMcpServers: string[]
}
const EMPTY_SELECTION: ForkResourceSelection = {
@@ -64,7 +67,7 @@ const EMPTY_SELECTION: ForkResourceSelection = {
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
}
export interface CreateForkParams {
@@ -84,12 +87,14 @@ export interface CreateForkResult {
workflowsCopied: number
}
// External MCP servers are intentionally absent: a fork never copies them, so their
// references resolve to null here and are cleared on remap (re-add + re-auth in the child).
const FORK_KIND_TO_RESOURCE_TYPE: Partial<Record<ForkRemapKind, ForkResourceType>> = {
'custom-tool': 'custom_tool',
skill: 'skill',
'mcp-server': 'mcp_server',
table: 'table',
'knowledge-base': 'knowledge_base',
'knowledge-document': 'knowledge_document',
}
/**
@@ -111,15 +116,25 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
// 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 } = await db.transaction(async (tx) => {
const { result, blobTasks, contentPlan, contentRefMaps } = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
const now = new Date()
const childWorkspaceId = generateId()
@@ -167,8 +182,14 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
}))
)
const workflowIdMap = new Map<string, string>()
for (const wf of deployedWorkflows) workflowIdMap.set(wf.id, generateId())
// 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,
@@ -179,6 +200,16 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
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).
const folderIdMap = await resolveForkFolderMapping({
tx,
sourceWorkspaceId: source.id,
targetWorkspaceId: childWorkspaceId,
userId,
now,
})
const resourceResult = await copyForkResourceContainers({
tx,
sourceWorkspaceId: source.id,
@@ -188,11 +219,12 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
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
@@ -204,14 +236,6 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
}
const transform = createForkBootstrapTransform(resolveCopied)
const folderIdMap = await resolveForkFolderMapping({
tx,
sourceWorkspaceId: source.id,
targetWorkspaceId: childWorkspaceId,
userId,
now,
})
// 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)
@@ -294,6 +318,20 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
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: {
@@ -310,6 +348,7 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
},
blobTasks: fileResult.blobTasks,
contentPlan: resourceResult.contentPlan,
contentRefMaps,
}
})
@@ -318,8 +357,7 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
// 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 =
contentPlan.tables.length > 0 || contentPlan.knowledgeBases.length > 0 || blobTasks.length > 0
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
@@ -352,7 +390,7 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
fileNames: blobTasks.map((task) => task.fileName),
customToolNames: forkedResourceNames.customTools,
skillNames: forkedResourceNames.skills,
mcpServerNames: forkedResourceNames.mcpServers,
workflowMcpServerNames: forkedResourceNames.workflowMcpServers,
},
})
} catch (error) {
@@ -373,34 +411,14 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
return result
}
const payload: ForkContentCopyPayload = { contentPlan, blobTasks, statusId, requestId }
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('fork-content-copy', () => runForkContentCopy(payload))
}
} catch (error) {
// The fork itself succeeded; only scheduling the background copy failed. Surface
// it on the status row instead of failing the (committed) fork response.
logger.error(`[${requestId}] Failed to schedule fork content copy`, {
childWorkspaceId: result.workspace.id,
error: getErrorMessage(error),
})
if (statusId) {
await finishBackgroundWork(db, statusId, {
status: 'failed',
error: getErrorMessage(error, 'Could not start the background copy'),
}).catch(() => {})
}
const payload: ForkContentCopyPayload = {
contentPlan,
blobTasks,
contentRefMaps,
statusId,
requestId,
}
await scheduleForkContentCopy(payload, { detachedLabel: 'fork-content-copy', requestId })
return result
}
@@ -14,6 +14,7 @@ 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
@@ -1,4 +1,4 @@
import { customTools, knowledgeConnector, mcpServers } from '@sim/db/schema'
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 {
@@ -148,6 +148,9 @@ export async function detectForkCascadeReferences(params: {
}
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,
@@ -156,9 +159,12 @@ export async function detectForkCascadeReferences(params: {
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)
)
)
@@ -96,6 +96,71 @@ describe('collectForkDependentReconfigs', () => {
])
})
it('anchors a dependent on the ACTIVE advanced parent member (not the dormant basic selector)', () => {
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
)
// Today (raw basic read) this is skipped because the basic selector is empty; the active-member
// resolution anchors the document on the advanced KB id so the re-pick is offered.
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
parentKind: 'knowledge-base',
parentSourceId: 'kb-active',
parentContextKey: 'knowledgeBaseId',
subBlockKey: 'documentSelector',
selectorKey: 'knowledge.documents',
currentValue: 'doc-1',
})
})
it('emits a knowledge-base-dependent document selector', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
@@ -329,6 +394,95 @@ describe('collectForkDependentReconfigs', () => {
])
})
it('honors a nested tool-scoped advanced override (anchors on the active member, not the dormant basic)', () => {
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 `gmail: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 -> anchors on the ACTIVE advanced member (today's heuristic missed it).
const withOverride = collectForkDependentReconfigs(
[replaceItem],
new Map([['wf-src', agentState({ 'gmail:credential': 'advanced' })]]),
resolve
)
expect(withOverride).toHaveLength(1)
expect(withOverride[0]).toMatchObject({
parentKind: 'credential',
parentSourceId: 'cred-active',
subBlockKey: 'tools[0].folder',
})
// 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('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' }])
@@ -8,9 +8,13 @@ import {
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
resolveActiveCanonicalValue,
scopeCanonicalModesForTool,
} from '@/lib/workflows/subblocks/visibility'
import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan'
import {
isSubBlockRequired,
scanWorkflowReferences,
@@ -62,6 +66,8 @@ interface EmitAnchoredParams {
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`). */
@@ -91,6 +97,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
contextSubBlocks,
blockName,
targetWorkflowId,
canonicalModes,
resolveTargetBlockId,
makeSubBlockKey,
makeTitle,
@@ -106,7 +113,14 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
for (const anchor of PARENT_ANCHORS) {
for (const anchorCfg of config.subBlocks) {
if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue
const rawValue = values[anchorCfg.id]
// Resolve the parent's ACTIVE canonical value: an advanced override (or an advanced-only
// value) beats a stale dormant basic selector, so a dependent re-pick is offered when
// advanced mode is active (today's raw basic read skips it).
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[anchorCfg.id]
const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined
const rawValue = group
? resolveActiveCanonicalValue(group, values, canonicalModes)
: values[anchorCfg.id]
const parentSourceId = typeof rawValue === 'string' ? rawValue : ''
// Skip empty and org-scoped credential sets (those carry over unchanged).
if (!parentSourceId || parentSourceId.startsWith('credentialSet:')) continue
@@ -201,11 +215,17 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
export function collectForkDependentReconfigs(
items: ReconfigItem[],
sourceStates: Map<string, WorkflowState>,
resolveTargetBlockId: ForkBlockIdResolver
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 !== 'replace') continue
if (item.mode !== mode) continue
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
@@ -217,7 +237,9 @@ export function collectForkDependentReconfigs(
const resolveBlockId = () =>
(cachedTargetBlockId ??= resolveTargetBlockId(item.targetWorkflowId, sourceBlockId))
// Top-level credential/KB/table-anchored selectors.
// 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,
@@ -225,6 +247,7 @@ export function collectForkDependentReconfigs(
contextSubBlocks: subBlocks,
blockName: block.name,
targetWorkflowId: item.targetWorkflowId,
canonicalModes: block.data?.canonicalModes,
resolveTargetBlockId: resolveBlockId,
makeSubBlockKey: (id) => id,
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
@@ -267,6 +290,7 @@ export function collectForkDependentReconfigs(
contextSubBlocks: toolContextSubBlocks,
blockName: block.name,
targetWorkflowId: item.targetWorkflowId,
canonicalModes: scopeCanonicalModesForTool(block.data?.canonicalModes, tool.type),
resolveTargetBlockId: resolveBlockId,
makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`,
makeTitle: (dependent) => `${toolLabel}: ${dependent.title ?? dependent.id ?? ''}`,
@@ -307,14 +331,9 @@ export function collectForkResourceUsages(
if (item.mode !== 'replace') continue
const state = sourceStates.get(item.sourceWorkflowId)
if (!state) continue
const blocks = Object.values(state.blocks).map((block) => ({
id: block.id,
name: block.name,
subBlocks: block.subBlocks as unknown,
}))
// scanWorkflowReferences already dedups by `${kind}:${sourceId}` across the workflow,
// so each resource appears once per workflow here.
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
const key = `${reference.kind}\u0000${reference.sourceId}`
let usage = byResource.get(key)
if (!usage) {
@@ -22,8 +22,10 @@ vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
import {
findDuplicateTargetEntry,
suggestTarget,
validateForkMappingTargets,
} from '@/lib/workspaces/fork/mapping/mapping-service'
import type { ForkResourceCandidate } from '@/lib/workspaces/fork/mapping/resources'
type ExistingByKind = Partial<Record<ForkRemapKind, Set<string>>>
@@ -91,6 +93,34 @@ describe('validateForkMappingTargets', () => {
).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) =>
@@ -180,3 +210,39 @@ describe('findDuplicateTargetEntry', () => {
).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')
})
})
@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import type { ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import { listDeployedWorkflows, readDeployedState } from '@/lib/workspaces/fork/copy/deploy-bridge'
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
@@ -23,6 +23,7 @@ import {
getWorkspaceEnvKeys,
listForkResourceCandidates,
} from '@/lib/workspaces/fork/mapping/resources'
import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan'
import {
type ForkReference,
type ForkRemapKind,
@@ -35,7 +36,7 @@ interface ForkMappingViewParams {
targetWorkspaceId: string
}
function suggestTarget(
export function suggestTarget(
kind: ForkRemapKind,
sourceLabel: string,
sourceProviderId: string | undefined,
@@ -73,12 +74,12 @@ export async function getForkMappingView(
const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys })
const resourceTypeBySourceId = new Map<string, Exclude<ForkResourceType, 'workflow'>>()
const resourceTypeBySourceId = new Map<string, ForkMappableResourceType>()
for (const row of mappingRows) {
// Workflow identity rows are system-managed, not user-mappable; skip them so a
// scanned reference can never be labeled `workflow` and the view stays within
// the mappable-type contract.
if (row.resourceType === 'workflow') continue
// Workflow identity rows are system-managed and document rows ride their parent KB - neither is
// user-mappable. Skip both 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 === 'knowledge_document') continue
const key = sourceIsParent ? row.parentResourceId : row.childResourceId
if (key) resourceTypeBySourceId.set(key, row.resourceType)
}
@@ -90,12 +91,7 @@ export async function getForkMappingView(
for (const wf of deployedWorkflows) {
const state = await readDeployedState(wf.id, sourceWorkspaceId)
if (!state) continue
const blocks = Object.values(state.blocks).map((block) => ({
id: block.id,
name: block.name,
subBlocks: block.subBlocks as unknown,
}))
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
}
@@ -116,7 +112,7 @@ export async function getForkMappingView(
// valid mapping to a target past the display cap must be RETAINED, not shown unmapped.
interface PendingEntry {
reference: ForkReference
resourceType: Exclude<ForkResourceType, 'workflow'>
resourceType: ForkMappableResourceType
sourceLabel: string
sourceProviderId: string | undefined
candidates: ForkResourceCandidate[]
@@ -129,6 +125,11 @@ export async function getForkMappingView(
// 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 =
@@ -48,10 +48,10 @@ export function resourceTypeToForkKind(resourceType: ForkResourceType): ForkRema
return RESOURCE_TYPE_TO_FORK_KIND[resourceType]
}
const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE: Record<
Exclude<ForkRemapKind, 'credential'>,
Exclude<ForkResourceType, 'workflow'>
> = {
// `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',
@@ -60,16 +60,19 @@ const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE: Record<
'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(
kind: Exclude<ForkRemapKind, 'credential'>
): Exclude<ForkResourceType, 'workflow'> {
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]
}
@@ -0,0 +1,95 @@
/**
* @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 {
listForkResourceCandidates,
loadForkCopyableResourceLabels,
} from '@/lib/workspaces/fork/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('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,
})
})
})
+244 -80
View File
@@ -1,16 +1,20 @@
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 '@/lib/workspaces/fork/mapping/mapping-store'
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
@@ -40,59 +44,145 @@ export async function getWorkspaceEnvKeys(
// 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. Credentials, env vars (mapping-only),
// and files (copy-only) stay inline in their respective functions.
const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
executor
// 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)
isNull(userTableDefinitions.archivedAt),
ids ? inArray(userTableDefinitions.id, ids) : undefined
)
)
.limit(CANDIDATE_LIMIT)
return ids ? query : query.limit(CANDIDATE_LIMIT)
}
const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
executor
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)))
.limit(CANDIDATE_LIMIT)
.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) =>
executor
const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
const query = executor
.select({ id: customTools.id, label: customTools.title })
.from(customTools)
.where(eq(customTools.workspaceId, workspaceId))
.limit(CANDIDATE_LIMIT)
.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) =>
executor
const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
const query = executor
.select({ id: skill.id, label: skill.name })
.from(skill)
.where(eq(skill.workspaceId, workspaceId))
.limit(CANDIDATE_LIMIT)
.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) =>
executor
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)))
.limit(CANDIDATE_LIMIT)
.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` and `file` are intentionally
* left empty for v1 (optional kinds resolved manually).
* 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] = await Promise.all([
const [creds, wsEnvRows, tables, kbs, servers, tools, skills, files] = await Promise.all([
executor
.select({
id: credential.id,
@@ -120,6 +210,7 @@ export async function listForkResourceCandidates(
mcpServerCandidatesQuery(executor, workspaceId),
customToolCandidatesQuery(executor, workspaceId),
skillCandidatesQuery(executor, workspaceId),
fileCandidatesQuery(executor, workspaceId),
])
const envVariables = wsEnvRows[0]?.variables
@@ -141,7 +232,7 @@ export async function listForkResourceCandidates(
'custom-tool': tools,
skill: skills,
'knowledge-document': [],
file: [],
file: files,
}
}
@@ -167,11 +258,15 @@ export async function filterExistingForkTargets(
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, servers, tools, skills] = await Promise.all([
const [creds, tables, kbs, docs, servers, tools, skills, files] = await Promise.all([
credIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
@@ -186,61 +281,51 @@ export async function filterExistingForkTargets(
),
tableIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: userTableDefinitions.id })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt),
inArray(userTableDefinitions.id, tableIds)
)
),
: 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: knowledgeBase.id })
.from(knowledgeBase)
.select({ id: document.id })
.from(document)
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
.where(
and(
eq(knowledgeBase.workspaceId, workspaceId),
isNull(knowledgeBase.deletedAt),
inArray(knowledgeBase.id, kbIds)
isNull(document.deletedAt),
isNull(document.archivedAt),
inArray(document.id, docIds)
)
),
mcpIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: mcpServers.id })
.from(mcpServers)
.where(
and(
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt),
inArray(mcpServers.id, mcpIds)
)
),
: mcpServerCandidatesQuery(executor, workspaceId, mcpIds),
toolIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: customTools.id })
.from(customTools)
.where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, toolIds))),
: customToolCandidatesQuery(executor, workspaceId, toolIds),
skillIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
: executor
.select({ id: skill.id })
.from(skill)
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds))),
: 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
}
@@ -268,13 +353,20 @@ export async function getCredentialProvidersByIds(
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: ForkResourceCandidate[]
files: ForkCopyableFileResource[]
tables: ForkResourceCandidate[]
knowledgeBases: ForkResourceCandidate[]
customTools: ForkResourceCandidate[]
skills: ForkResourceCandidate[]
mcpServers: ForkResourceCandidate[]
/** Workflow-publishing MCP servers, copied as config-only shells (external MCP is not copied). */
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
@@ -293,28 +385,18 @@ export async function listForkCopyableResources(
workspaceId: string
): Promise<ForkCopyableResources> {
const [files, tables, kbs, tools, skills, servers, deployed] = await Promise.all([
executor
.select({
id: workspaceFiles.id,
// displayName is nullable; fall back to the (non-null) original name.
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
})
.from(workspaceFiles)
// Only durable workspace files are forkable - chat/copilot/mothership uploads are
// session-scoped attachments (and their chat-bound unique index can't be copied).
.where(
and(
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
.limit(CANDIDATE_LIMIT),
fileCandidatesWithFolderQuery(executor, workspaceId),
tableCandidatesQuery(executor, workspaceId),
knowledgeBaseCandidatesQuery(executor, workspaceId),
customToolCandidatesQuery(executor, workspaceId),
skillCandidatesQuery(executor, workspaceId),
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)
@@ -341,16 +423,98 @@ export async function listForkCopyableResources(
),
])
return {
files,
// 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: servers,
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
}
/**
* 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')
// Files are keyed by storage key (not `workspace_files.id`), so they label by key.
const fileKeys = ids('file')
const [kbs, tables, tools, skills, 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),
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 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,
@@ -0,0 +1,528 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { SubBlockConfig } from '@/blocks/types'
// The reference indexer resolves a tool's params via the tool registry; stub it so loading the
// remap module never pulls the full registry (these cases use top-level selectors / dependents).
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 { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
EMPTY_FORK_BLOCK_MAP,
} from '@/lib/workspaces/fork/remap/block-identity'
import type { ForkReferenceResolver } from '@/lib/workspaces/fork/remap/remap-references'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
// No persisted block map, so the resolver derives - matching the deriveForkBlockId expectations.
const resolveBlockId = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
const stateWith = (
blockType: string,
blockName: string,
subBlocks: Record<string, { type?: string; value: unknown }>
): WorkflowState =>
({
blocks: { 'block-1': { id: 'block-1', type: blockType, name: blockName, subBlocks } },
edges: [],
loops: {},
parallels: {},
variables: {},
}) as unknown as WorkflowState
interface ParamOverrides {
items?: Array<{
sourceWorkflowId: string
targetWorkflowId: string
mode: 'create' | 'replace'
sourceMeta: { name: string }
}>
sourceStates?: Map<string, WorkflowState>
resolver?: ForkReferenceResolver
workflowIdMap?: Map<string, string>
sourceLabels?: Map<string, string>
sourceWorkflowNames?: Map<string, string>
}
const params = (overrides: ParamOverrides) => ({
items: [],
sourceStates: new Map<string, WorkflowState>(),
resolver: (() => null) as ForkReferenceResolver,
workflowIdMap: new Map<string, string>(),
resolveBlockId,
sourceLabels: new Map<string, string>(),
sourceWorkflowNames: new Map<string, string>(),
...overrides,
})
const targetBlockId = deriveForkBlockId('wf-tgt', 'block-1')
describe('collectForkClearedRefCandidates', () => {
it('emits an unmapped knowledge-base reference (cause reference) with block + field labels', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }])
)
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace',
sourceMeta: { name: 'Search' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('knowledge', 'KB Block', {
kb: { type: 'knowledge-base-selector', value: 'kb-src' },
}),
],
]),
resolver: () => null,
sourceLabels: new Map([['knowledge-base:kb-src', 'Docs KB']]),
})
)
expect(result).toEqual([
{
targetWorkflowId: 'wf-tgt',
workflowName: 'Search',
blockId: targetBlockId,
blockLabel: 'KB Block',
fieldLabel: 'Knowledge Base',
kind: 'knowledge-base',
sourceId: 'kb-src',
sourceLabel: 'Docs KB',
cause: 'reference',
},
])
})
it('drops a reference once the resolver maps it (so a mapped resource is not "cleared")', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }])
)
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace',
sourceMeta: { name: 'Search' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('knowledge', 'KB Block', {
kb: { type: 'knowledge-base-selector', value: 'kb-src' },
}),
],
]),
resolver: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-tgt' : null),
})
)
expect(result).toEqual([])
})
it('excludes a required credential reference (a blocker resolved by mapping, never cleared)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'credential', title: 'Account', type: 'oauth-input' }])
)
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace',
sourceMeta: { name: 'Send Email' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('gmail', 'Gmail 1', {
credential: { type: 'oauth-input', value: 'cred-src' },
}),
],
]),
// An unmapped required credential gates Sync; it must not appear as a "will be cleared" item.
resolver: () => null,
sourceLabels: new Map([['credential:cred-src', 'Work Gmail']]),
})
)
expect(result).toEqual([])
})
it('never emits an env-var reference (env vars are preserved by name, not cleared)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'url', title: 'URL', type: 'short-input' }])
)
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace',
sourceMeta: { name: 'API' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('api', 'API', { url: { type: 'short-input', value: '{{API_KEY}}' } }),
],
]),
resolver: () => null,
})
)
expect(result).toEqual([])
})
it('emits a workflow reference to a workflow not carried into the target (cause workflow)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }])
)
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace',
sourceMeta: { name: 'Caller' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('workflow_caller', 'Run Subflow', {
target: { type: 'workflow-selector', value: 'wf-other' },
}),
],
]),
workflowIdMap: new Map(),
sourceWorkflowNames: new Map([['wf-other', 'Other Workflow']]),
})
)
expect(result).toEqual([
{
targetWorkflowId: 'wf-tgt',
workflowName: 'Caller',
blockId: targetBlockId,
blockLabel: 'Run Subflow',
fieldLabel: 'Workflow',
kind: 'workflow',
sourceId: 'wf-other',
sourceLabel: 'Other Workflow',
cause: 'workflow',
},
])
})
it('does not emit a workflow reference when the workflow is carried into the target', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }])
)
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace',
sourceMeta: { name: 'Caller' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('workflow_caller', 'Run Subflow', {
target: { type: 'workflow-selector', value: 'wf-other' },
}),
],
]),
workflowIdMap: new Map([['wf-other', 'wf-other-child']]),
})
)
expect(result).toEqual([])
})
it('collapses the workflowId pair to the active member: a dormant basic selector is not a false cleared-ref', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'workflowId',
title: 'Workflow',
type: 'workflow-selector',
canonicalParamId: 'workflowId',
mode: 'basic',
},
{
id: 'manualWorkflowId',
title: 'Workflow ID',
type: 'short-input',
canonicalParamId: 'workflowId',
mode: 'advanced',
},
])
)
// Advanced mode active; the dormant basic selector holds a stale, uncopied id.
const advancedState = {
blocks: {
'block-1': {
id: 'block-1',
type: 'workflow',
name: 'Caller',
data: { canonicalModes: { workflowId: 'advanced' } },
subBlocks: {
workflowId: { type: 'workflow-selector', value: 'wf-old' },
manualWorkflowId: { type: 'short-input', value: 'wf-active' },
},
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
} as unknown as WorkflowState
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace' as const,
sourceMeta: { name: 'Caller' },
}
// Active advanced workflow carried into the target: the dormant basic must NOT produce a row.
const carried = collectForkClearedRefCandidates(
params({
items: [item],
sourceStates: new Map([['wf-src', advancedState]]),
workflowIdMap: new Map([['wf-active', 'wf-active-child']]),
})
)
expect(carried.filter((ref) => ref.cause === 'workflow')).toEqual([])
// The ACTIVE member still produces a row when it is not carried (active path unbroken).
const cleared = collectForkClearedRefCandidates(
params({
items: [item],
sourceStates: new Map([['wf-src', advancedState]]),
sourceWorkflowNames: new Map([['wf-active', 'Active Workflow']]),
})
)
const workflowRows = cleared.filter((ref) => ref.cause === 'workflow')
expect(workflowRows).toHaveLength(1)
expect(workflowRows[0].sourceId).toBe('wf-active')
})
it('emits a configured create-target dependent a remapped parent will clear (cause dependent)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{
id: 'folder',
title: 'Label',
type: 'folder-selector',
dependsOn: ['credential'],
selectorKey: 'gmail.labels',
},
])
)
// Entries carry no `type`, so the credential is not a direct (category-A) reference here -
// isolating the create-dependent (category-C) path: the label hangs off the credential.
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'create',
sourceMeta: { name: 'New Workflow' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('gmail', 'Gmail', {
credential: { value: 'cred-src' },
folder: { value: 'INBOX' },
}),
],
]),
sourceLabels: new Map([['credential:cred-src', 'Work Gmail']]),
})
)
expect(result).toEqual([
{
targetWorkflowId: 'wf-tgt',
workflowName: 'New Workflow',
blockId: targetBlockId,
blockLabel: 'Gmail',
fieldLabel: 'Label',
kind: 'credential',
sourceId: 'cred-src',
sourceLabel: 'Work Gmail',
cause: 'dependent',
parentKind: 'credential',
parentSourceId: 'cred-src',
},
])
})
it('carries the knowledge-base parent on a document-selector dependent (so it can drop off)', () => {
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'],
},
])
)
const result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'create',
sourceMeta: { name: 'New Workflow' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('knowledge', 'Knowledge', {
knowledgeBaseSelector: { value: 'kb-src' },
documentSelector: { value: 'doc-src' },
}),
],
]),
sourceLabels: new Map([['knowledge-base:kb-src', 'Docs KB']]),
})
)
expect(result).toEqual([
{
targetWorkflowId: 'wf-tgt',
workflowName: 'New Workflow',
blockId: targetBlockId,
blockLabel: 'Knowledge',
fieldLabel: 'Document',
kind: 'knowledge-base',
sourceId: 'kb-src',
sourceLabel: 'Docs KB',
cause: 'dependent',
parentKind: 'knowledge-base',
parentSourceId: 'kb-src',
},
])
})
it('does not emit a create-target dependent the source left unset (nothing is lost)', () => {
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 result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'create',
sourceMeta: { name: 'New Workflow' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('gmail', 'Gmail', {
credential: { value: 'cred-src' },
folder: { value: '' },
}),
],
]),
})
)
expect(result).toEqual([])
})
it('does not emit create-dependents for a replace target (handled by the reconfigure flow)', () => {
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 result = collectForkClearedRefCandidates(
params({
items: [
{
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
mode: 'replace',
sourceMeta: { name: 'Existing' },
},
],
sourceStates: new Map([
[
'wf-src',
stateWith('gmail', 'Gmail', {
credential: { value: 'cred-src' },
folder: { value: 'INBOX' },
}),
],
]),
})
)
// No direct refs (untyped entries) and no create-dependents (replace mode) -> empty.
expect(result).toEqual([])
})
})
@@ -0,0 +1,233 @@
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import {
coerceObjectArray,
isRecord,
type SubBlockRecord,
} from '@/lib/workflows/persistence/remap-internal-ids'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
isCanonicalPair,
resolveCanonicalMode,
} from '@/lib/workflows/subblocks/visibility'
import { collectForkDependentReconfigs } from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import {
type ForkReferenceResolver,
type ForkRemapKind,
REQUIRED_KINDS,
remapForkSubBlocks,
} from '@/lib/workspaces/fork/remap/remap-references'
import { getBlock } from '@/blocks/registry'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/**
* Remappable kinds excluded from the `reference` cleared-ref list. REQUIRED kinds (credential,
* env-var) are BLOCKERS - they gate Sync and are resolved by mapping, never silently cleared - so
* they must not read as "will be cleared" (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.
*/
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`, advanced `manualWorkflowId(s)`, multi-select
* `workflowSelector`, 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. 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 the `workflowId` canonical pair (basic `workflow-selector` + advanced `manualWorkflowId`)
// to its ACTIVE member: only the active mode is serialized, so a dormant stale member is not a ref
// that would be cleared (mirrors remap-internal-ids.ts). Undefined mode -> emit both (legacy/no-pair).
const workflowGroup = config
? buildCanonicalIndex(config.subBlocks).groupsById.workflowId
: undefined
const workflowMode =
workflowGroup && isCanonicalPair(workflowGroup)
? resolveCanonicalMode(workflowGroup, buildSubBlockValues(subBlocks), canonicalModes)
: undefined
for (const [key, subBlock] of Object.entries(subBlocks)) {
if (!subBlock || typeof subBlock !== 'object') continue
const baseKey = baseSubBlockId(key)
if (
(subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') &&
typeof subBlock.value === 'string' &&
subBlock.value
) {
// Skip the dormant member of the pair (the active mode owns the reference).
const isAdvancedMember = baseKey === 'manualWorkflowId'
if (workflowMode && (workflowMode === 'advanced') !== isAdvancedMember) continue
out.push({ workflowId: subBlock.value, subBlockKey: key })
} else if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') {
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).
const scan = remapForkSubBlocks(subBlocks, resolver, 'promote', {
blockId: targetBlockId,
blockName: blockLabel,
})
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',
})
}
// 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,
fieldLabel: 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
}
@@ -0,0 +1,381 @@
/**
* @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('@/lib/workspaces/fork/mapping/mapping-store', () => ({
upsertEdgeMappings: mockUpsertEdgeMappings,
deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources,
resourceTypeToForkKind: vi.fn(),
}))
vi.mock('@/lib/workspaces/fork/copy/copy-resources', () => ({
copyForkResourceContainers: mockCopyForkResourceContainers,
planForkMappedKbDocumentCopies: mockPlanForkMappedKbDocumentCopies,
copyForkResourceContent: vi.fn(),
}))
vi.mock('@/lib/workspaces/fork/copy/copy-files', () => ({
planForkFileCopies: mockPlanForkFileCopies,
executeForkFileBlobCopies: vi.fn(),
}))
import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
import type { ForkMappingUpsert } from '@/lib/workspaces/fork/mapping/mapping-store'
import {
augmentForkResolver,
buildPromoteCopySelection,
copyPromoteUnmappedResources,
FORK_COPYABLE_KIND_TO_SELECTION_KEY,
hasPromoteCopySelection,
persistPromoteCopiedMappings,
} from '@/lib/workspaces/fork/promote/copy-unmapped'
import { isForkCopyableKind } from '@/lib/workspaces/fork/promote/promote-plan'
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
const candidates: ForkCopyableUnmapped[] = [
{ kind: 'knowledge-base', sourceId: 'kb-1', label: 'KB One', parentId: null, parentLabel: null },
{ kind: 'table', sourceId: 'tbl-1', label: 'Table 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 },
{
kind: 'file',
sourceId: 'workspace/SRC/a.png',
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Images',
},
]
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(selection.workflowMcpServers).toEqual([])
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('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 },
]
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: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
})
).toBe(true)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: [],
files: [],
})
).toBe(false)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/file.png'],
})
).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
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: [],
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: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/a.png'],
},
workflowIdMap: new Map(),
folderIdMap: new Map([['fld-src', 'fld-dst']]),
resolver: () => null,
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('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: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
resolver: () => null,
// 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'] })
)
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',
'mcp-server',
]
for (const kind of nonCopyable) {
expect(isForkCopyableKind(kind)).toBe(false)
}
})
})
@@ -0,0 +1,335 @@
import type {
ForkCopyableKind,
ForkCopyableUnmapped,
PromoteCopyResources,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
type SerializableForkContentRefMaps,
serializeContentRefMaps,
} from '@/lib/workspaces/fork/copy/content-copy-runner'
import { type BlobCopyTask, planForkFileCopies } from '@/lib/workspaces/fork/copy/copy-files'
import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources'
import {
copyForkResourceContainers,
planForkMappedKbDocumentCopies,
} from '@/lib/workspaces/fork/copy/copy-resources'
import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
import {
deleteEdgeMappingsByChildResources,
type ForkMappingUpsert,
resourceTypeToForkKind,
upsertEdgeMappings,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import type {
ForkReferenceResolver,
ForkRemapKind,
} from '@/lib/workspaces/fork/remap/remap-references'
/** The source ids selected for copy at promote, validated against the plan's copyable candidates. */
export interface PromoteCopySelection {
customTools: string[]
skills: string[]
workflowMcpServers: string[]
tables: string[]
knowledgeBases: string[]
/** Workspace files to copy, identified by storage key (not `workspace_files.id`). */
files: 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',
}
/**
* Intersect the user's requested copy with the plan's actual copyable candidates, so a sync can
* only copy resources that are genuinely referenced-but-unmapped + still exist in the source (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.
*/
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: [],
workflowMcpServers: [],
tables: [],
knowledgeBases: [],
files: [],
}
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)
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
)
}
/**
* 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 referenced-but-unmapped resources 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
/**
* 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,
referencedDocumentIds,
} = params
const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId,
childWorkspaceId: targetWorkspaceId,
userId,
now,
selection: {
customTools: selection.customTools,
skills: selection.skills,
workflowMcpServers: selection.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),
})
// 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)
}
@@ -2,7 +2,20 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildPromoteWorkflowIdMap } from '@/lib/workspaces/fork/promote/promote-plan'
import type { ForkCopyableLabel } from '@/lib/workspaces/fork/mapping/resources'
import {
assembleForkCopyableUnmapped,
buildPromoteWorkflowIdMap,
collectForkCopyableIdsByKind,
} from '@/lib/workspaces/fork/promote/promote-plan'
import type { ForkReference } from '@/lib/workspaces/fork/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
@@ -80,3 +93,62 @@ describe('buildPromoteWorkflowIdMap', () => {
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,
},
{ kind: 'file', sourceId: 'fk-1', label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' },
])
})
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([])
})
})
@@ -1,6 +1,7 @@
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 '@/lib/workspaces/fork/copy/deploy-bridge'
import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
@@ -11,9 +12,12 @@ import {
resourceTypeToForkKind,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import {
type ForkCopyableLabel,
filterExistingForkTargets,
getWorkspaceEnvKeys,
loadForkCopyableResourceLabels,
} from '@/lib/workspaces/fork/mapping/resources'
import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan'
import {
type ForkReference,
type ForkReferenceResolver,
@@ -56,11 +60,35 @@ export interface ForkPromotePlan {
mcpReauthServerIds: string[]
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
inlineSecretSources: string[]
/**
* Referenced-but-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). 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
}>
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.
@@ -92,6 +120,48 @@ export function buildPromoteWorkflowIdMap(params: {
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 {@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,
},
]
: []
})
}
/**
* Compute everything a promote needs without mutating. Only the source's
* **deployed** workflows participate; each plan item carries the source's active
@@ -208,12 +278,8 @@ export async function computeForkPromotePlan(params: {
},
})
const blocks = Object.values(sourceState.blocks).map((block) => ({
id: block.id,
name: block.name,
subBlocks: block.subBlocks as unknown,
}))
for (const reference of scanWorkflowReferences(blocks, resolver).references) {
for (const reference of scanWorkflowReferences(toScannerBlocks(sourceState), resolver)
.references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
}
@@ -257,6 +323,15 @@ export async function computeForkPromotePlan(params: {
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 copyableUnmapped = assembleForkCopyableUnmapped(allUnmapped, copyableLabels)
const willUpdate = items.filter((i) => i.mode === 'replace').length
const willCreate = items.filter((i) => i.mode === 'create').length
@@ -275,6 +350,7 @@ export async function computeForkPromotePlan(params: {
unmappedOptional,
mcpReauthServerIds: cascade.mcpReauthServerIds,
inlineSecretSources: cascade.inlineSecretSources,
copyableUnmapped,
willUpdate,
willCreate,
willArchive: archivedTargetIds.length,
+139 -4
View File
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import type { PromoteCopyResources } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
enqueueWorkflowUndeploySideEffects,
@@ -11,6 +12,15 @@ import {
} from '@/lib/workflows/deployment-outbox'
import { performFullDeploy } from '@/lib/workflows/orchestration/deploy'
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
import { startBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
import {
type ForkContentCopyPayload,
hasForkContentToCopy,
type SerializableForkContentRefMaps,
scheduleForkContentCopy,
} from '@/lib/workspaces/fork/copy/content-copy-runner'
import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files'
import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources'
import {
copyWorkflowStateIntoTarget,
loadTargetDraftSubBlocks,
@@ -43,6 +53,12 @@ import {
type ForkMappingUpsert,
upsertEdgeMappings,
} from '@/lib/workspaces/fork/mapping/mapping-store'
import {
augmentForkResolver,
buildPromoteCopySelection,
copyPromoteUnmappedResources,
hasPromoteCopySelection,
} from '@/lib/workspaces/fork/promote/copy-unmapped'
import {
computeForkPromotePlan,
type ForkPromotePlan,
@@ -82,6 +98,12 @@ export interface PromoteForkParams {
subBlockKey: string
value: string
}>
/**
* Referenced-but-unmapped resources (by source id) the caller chose to copy into the target
* before the sync gate. Validated against the plan's copyable candidates, so an arbitrary id is
* ignored. Each copied resource's references then resolve to the new copy instead of blocking.
*/
copyResources?: PromoteCopyResources
requestId?: string
}
@@ -264,6 +286,12 @@ interface PromoteTxApplied {
needsConfiguration: Array<{ workflowId: string; workflowName: string; blocks: string[] }>
/** Per-workflow optional dependents a parent change cleared (surfaced, not gated). */
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
/** Heavy content for resources copied into the target this sync, filled best-effort post-commit. */
copyContentPlan: ForkContentPlan | null
/** Serialized in-content maps for the post-commit skill-body rewrite (paired with the plan). */
copyContentRefMaps: SerializableForkContentRefMaps | null
/** File blob duplications for copied workspace files, run post-commit by the content-copy runner. */
copyContentBlobTasks: BlobCopyTask[]
}
/**
@@ -352,10 +380,25 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
sourceStates,
})
if (plan.unmappedRequired.length > 0) {
const now = new Date()
// Copy the selected referenced-but-unmapped resources into the target BEFORE the gate, so a
// user can copy rather than map each one. The gate is evaluated against the post-copy state
// (the copy resolves the selected refs), so the copy only runs when the sync will actually
// proceed - if required refs remain unmapped, we block without copying anything.
const { selection: copySelection, willResolve } = buildPromoteCopySelection(
params.copyResources,
plan.copyableUnmapped
)
// plan.unmappedRequired is already references.filter(resolver == null).filter(required), so
// subtracting the refs the copy will resolve is equivalent to re-scanning the predicate.
const postCopyUnmappedRequired = plan.unmappedRequired.filter(
(reference) => !willResolve.has(`${reference.kind}:${reference.sourceId}`)
)
if (postCopyUnmappedRequired.length > 0) {
return {
blocked: 'unmapped',
unmappedRequired: plan.unmappedRequired.map((reference) => ({
unmappedRequired: postCopyUnmappedRequired.map((reference) => ({
kind: reference.kind,
sourceId: reference.sourceId,
required: reference.required,
@@ -364,8 +407,10 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
}
}
const now = new Date()
const transform = createForkSubBlockTransform(plan.resolver)
// Resolve the source->target folder map BEFORE the copy so the folders already exist in the
// target and the copy can rewrite `sim:folder/<id>` references inside copied skill / markdown
// bodies (the post-commit content rewrite reads this map). Idempotent: it reuses target
// folders that already match by name within the same mapped parent.
const folderIdMap = await resolveForkFolderMapping({
tx,
sourceWorkspaceId,
@@ -374,6 +419,42 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
now,
})
let resolver = plan.resolver
let copyContentPlan: ForkContentPlan | null = null
let copyContentRefMaps: SerializableForkContentRefMaps | null = null
let copyContentBlobTasks: BlobCopyTask[] = []
// Knowledge-document ids the synced workflows reference, taken from the plan's already-scanned
// references so the copy never re-scans every source state inside this locked tx.
const referencedDocumentIds = plan.references
.filter((reference) => reference.kind === 'knowledge-document')
.map((reference) => reference.sourceId)
// Run the copy when the user selected resources to copy OR any document is referenced (a
// referenced document under an already-mapped KB is auto-copied into that KB so its reference
// remaps instead of clearing). It runs only after the required-reference gate above, so a
// blocked sync copies nothing.
if (hasPromoteCopySelection(copySelection) || referencedDocumentIds.length > 0) {
const copyResult = await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId,
targetWorkspaceId,
direction,
userId,
now,
selection: copySelection,
workflowIdMap: plan.workflowIdMap,
folderIdMap,
resolver: plan.resolver,
referencedDocumentIds,
})
resolver = augmentForkResolver(plan.resolver, copyResult.copyIdMapByKind)
copyContentPlan = copyResult.contentPlan
copyContentRefMaps = copyResult.contentRefMaps
copyContentBlobTasks = copyResult.blobTasks
}
const transform = createForkSubBlockTransform(resolver)
// Batch every prior-version read (replace + archive targets) into one query before any
// write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so
// they still reflect the active version each target had before this sync.
@@ -634,6 +715,9 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
undeployEventIds,
needsConfiguration,
clearedOptional,
copyContentPlan,
copyContentRefMaps,
copyContentBlobTasks,
}
})
@@ -713,6 +797,57 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
}
}
// Fill the heavy content (table rows, KB documents + embeddings) of resources copied into the
// target this sync and rewrite copied skill bodies, off the request path. Scheduled AFTER the
// deploy loop so every deployed version this sync cut already EXISTS: a failed content fill's
// cleanup sweep unions `deployedTargetWorkflowIds`, so it must not race ahead of those versions.
// Mirrors fork: a durable status row + Trigger.dev task surfaces a crash as a failed Activity
// entry rather than a silently-empty placeholder (runDetached is the non-Trigger fallback); the
// runner also clears references + drops the placeholder for any resource whose fill fails.
const copyContentPlan = txResult.copyContentPlan
const copyBlobTasks = txResult.copyContentBlobTasks
const hasCopyContent =
copyContentPlan != null && hasForkContentToCopy(copyContentPlan, copyBlobTasks)
if (copyContentPlan && hasCopyContent) {
// Scope the durable record to the workspace whose Manage Forks -> Activity the user is
// viewing (the one the sync was initiated from), matching where the route records the sync.
const activityWorkspaceId = direction === 'push' ? sourceWorkspaceId : targetWorkspaceId
// The sync already committed; failing to record the tracking row must not turn it into a 500.
// The runner no-ops its status updates when statusId is absent, so the copy still runs.
let statusId: string | undefined
try {
statusId = await startBackgroundWork(db, {
workspaceId: activityWorkspaceId,
kind: 'fork_content_copy',
// Append-only: each sync's content fill is a distinct entry in the Activity history.
supersede: false,
message: 'Copying synced resources',
metadata: {
tables: copyContentPlan.tables.length,
knowledgeBases: copyContentPlan.knowledgeBases.length,
files: copyBlobTasks.length,
},
})
} catch (error) {
logger.error(`[${requestId}] Failed to record sync content-copy status`, {
targetWorkspaceId,
error: getErrorMessage(error),
})
}
const payload: ForkContentCopyPayload = {
contentPlan: copyContentPlan,
blobTasks: copyBlobTasks,
contentRefMaps: txResult.copyContentRefMaps ?? undefined,
statusId,
// The targets this sync wrote and deployed above, so a failed content fill can sweep the
// dropped placeholder from their DEPLOYED version states too, not just drafts.
deployedTargetWorkflowIds: txResult.deployTargetIds,
requestId,
}
await scheduleForkContentCopy(payload, { detachedLabel: 'fork-sync-content-copy', requestId })
}
if (deployFailures.length > 0) {
logger.warn(`[${requestId}] Promote wrote state but some targets failed to deploy`, {
sourceWorkspaceId,
@@ -1,4 +1,5 @@
import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
clearDependentsOnRemap,
type ForkRemapKind,
@@ -22,9 +23,13 @@ export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string
*/
export function createForkBootstrapTransform(
resolveCopied: ForkCopyResolver
): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord {
return (subBlocks, blockType) => {
): (
subBlocks: SubBlockRecord,
blockType: string,
canonicalModes?: CanonicalModeOverrides
) => SubBlockRecord {
return (subBlocks, blockType, canonicalModes) => {
const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create')
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys)
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys, canonicalModes)
}
}
@@ -0,0 +1,67 @@
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
type ForkRemapKind,
scanWorkflowReferences,
} from '@/lib/workspaces/fork/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 '@/lib/workspaces/fork/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
})
}
@@ -36,6 +36,30 @@ function fileItemKeyField(item: unknown): { field: 'key' | 'path' | 'name'; key:
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).
@@ -20,13 +20,16 @@ vi.mock('@/tools/params', () => ({
}))
import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import { createForkBootstrapTransform } from '@/lib/workspaces/fork/remap/fork-bootstrap'
import {
applyDependentOverrides,
clearDependentsOnRemap,
collectClearedDependents,
parseNestedDependentKey,
readTargetDraftDependentValue,
remapForkSubBlocks,
remapToolBlockResources,
scanWorkflowReferences,
} from '@/lib/workspaces/fork/remap/remap-references'
import { getBlock } from '@/blocks/registry'
@@ -201,6 +204,38 @@ describe('remapToolBlockResources', () => {
})
expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: '' })
})
it('remaps a nested documentId through the doc map when its document was copied', () => {
const tool = {
type: 'depblock',
toolId: 'depblock_run',
params: { knowledgeBaseId: 'kb-src', documentId: 'doc-src' },
}
const map: Record<string, string> = {
'knowledge-base:kb-src': 'kb-dst',
'knowledge-document:doc-src': 'doc-dst',
}
const result = remapToolBlockResources(tool, {
resolve: (kind, id) => map[`${kind}:${id}`] ?? null,
resolveFileKey: () => null,
clearUnresolved: true,
blockConfigs: {
depblock: {
subBlocks: [
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
{
id: 'documentId',
title: 'Doc',
type: 'document-selector',
dependsOn: ['knowledgeBaseId'],
},
],
},
},
})
// documentId is remapped (not cleared as a dependent) because its document was copied.
expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: 'doc-dst' })
})
})
describe('remapForkSubBlocks', () => {
@@ -283,6 +318,52 @@ describe('remapForkSubBlocks', () => {
const tools = result.subBlocks.tools.value as Array<{ params: { subject: string } }>
expect(tools[0].params.subject).toBe('Hi {{NEW}}')
})
const fileSubBlock = (): SubBlockRecord => ({
file: {
id: 'file',
type: 'file-upload',
value: { key: 'workspace/SRC/a.png', name: 'a.png' },
},
})
it('promote mode: records an unmapped file-upload key as a file reference and clears it', () => {
const result = remapForkSubBlocks(fileSubBlock(), () => null, 'promote')
const keys = result.references.map((r) => `${r.kind}:${r.sourceId}`)
expect(keys).toContain('file:workspace/SRC/a.png')
// file refs are optional (not required), surfaced for the copy/clear decision.
expect(result.references.find((r) => r.kind === 'file')?.required).toBe(false)
expect(result.unmapped.map((r) => `${r.kind}:${r.sourceId}`)).toContain(
'file:workspace/SRC/a.png'
)
// An uncopied file key is dropped rather than carried cross-workspace.
expect(result.subBlocks.file.value).toBe('')
})
it('promote mode: remaps a file-upload key to the copied target and records it mapped', () => {
const result = remapForkSubBlocks(
fileSubBlock(),
(kind, id) =>
kind === 'file' && id === 'workspace/SRC/a.png' ? 'workspace/DST/a.png' : null,
'promote'
)
expect(result.references.map((r) => `${r.kind}:${r.sourceId}`)).toContain(
'file:workspace/SRC/a.png'
)
expect(result.unmapped).toHaveLength(0)
expect((result.subBlocks.file.value as { key: string }).key).toBe('workspace/DST/a.png')
})
it('create mode: does not record file references but still remaps copied files', () => {
const result = remapForkSubBlocks(
fileSubBlock(),
(kind, id) =>
kind === 'file' && id === 'workspace/SRC/a.png' ? 'workspace/DST/a.png' : null,
'create'
)
expect(result.references).toHaveLength(0)
expect((result.subBlocks.file.value as { key: string }).key).toBe('workspace/DST/a.png')
})
})
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
@@ -290,6 +371,179 @@ const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
const entry = (id: string, type: string, value: unknown) => ({ id, type, value })
describe('createForkBootstrapTransform document-selector remap', () => {
const docBlock = () =>
blockWith([
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
{ id: 'documentId', title: 'Doc', type: 'document-selector', dependsOn: ['knowledgeBaseId'] },
])
const subBlocks = (): SubBlockRecord => ({
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-src' },
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' },
})
it('remaps documentId to the copied document (not cleared as a KB dependent)', () => {
vi.mocked(getBlock).mockReturnValue(docBlock())
const map: Record<string, string> = {
'knowledge-base:kb-src': 'kb-dst',
'knowledge-document:doc-src': 'doc-dst',
}
const transform = createForkBootstrapTransform((kind, id) => map[`${kind}:${id}`] ?? null)
const result = transform(subBlocks(), 'knowledge')
expect(result.knowledgeBaseId.value).toBe('kb-dst')
expect(result.documentId.value).toBe('doc-dst')
})
it('clears documentId when its parent KB was not copied', () => {
vi.mocked(getBlock).mockReturnValue(docBlock())
const transform = createForkBootstrapTransform(() => null)
const result = transform(subBlocks(), 'knowledge')
expect(result.knowledgeBaseId.value).toBe('')
expect(result.documentId.value).toBe('')
})
it('clears documentId when its KB was copied but the document was not', () => {
vi.mocked(getBlock).mockReturnValue(docBlock())
const transform = createForkBootstrapTransform((kind, id) =>
kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null
)
const result = transform(subBlocks(), 'knowledge')
expect(result.knowledgeBaseId.value).toBe('kb-dst')
expect(result.documentId.value).toBe('')
})
})
describe('clearDependentsOnRemap canonical-pair gating', () => {
const kbCanonicalBlock = () =>
blockWith([
{
id: 'knowledgeBaseSelector',
title: 'KB',
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',
dependsOn: ['knowledgeBaseSelector'],
},
])
it('does not clear a dependent when only the DORMANT basic selector was remapped (advanced active)', () => {
vi.mocked(getBlock).mockReturnValue(kbCanonicalBlock())
const subBlocks: SubBlockRecord = {
knowledgeBaseSelector: { type: 'knowledge-base-selector', value: '' },
manualKnowledgeBaseId: { type: 'short-input', value: 'kb-active' },
documentSelector: { type: 'document-selector', value: 'doc-1' },
}
const result = clearDependentsOnRemap(
subBlocks,
'knowledge',
new Set(['knowledgeBaseSelector']),
{
knowledgeBaseId: 'advanced',
}
)
// The active advanced parent is unchanged, so the dependent must be preserved.
expect(result.documentSelector.value).toBe('doc-1')
})
it('clears a dependent when the ACTIVE basic selector was remapped (basic active)', () => {
vi.mocked(getBlock).mockReturnValue(kbCanonicalBlock())
const subBlocks: SubBlockRecord = {
knowledgeBaseSelector: { type: 'knowledge-base-selector', value: 'kb-new' },
manualKnowledgeBaseId: { type: 'short-input', value: '' },
documentSelector: { type: 'document-selector', value: 'doc-1' },
}
const result = clearDependentsOnRemap(
subBlocks,
'knowledge',
new Set(['knowledgeBaseSelector']),
{
knowledgeBaseId: 'basic',
}
)
// Basic is active; its remap clears the dependent (unchanged behavior).
expect(result.documentSelector.value).toBe('')
})
})
describe('scanWorkflowReferences canonical-pair detection', () => {
const credBlock = () =>
blockWith([
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
canonicalParamId: 'credential',
mode: 'basic',
},
{
id: 'manualCredential',
title: 'Account ID',
type: 'short-input',
canonicalParamId: 'credential',
mode: 'advanced',
},
])
// The advanced manualCredential is a short-input escape hatch (never scanned); the basic
// oauth-input is the detectable member, so the "active" assertion targets the basic mode.
const scanBlock = (canonicalModes?: Record<string, 'basic' | 'advanced'>) => ({
id: 'b1',
name: 'Send',
type: 'gmail',
canonicalModes,
subBlocks: {
credential: { id: 'credential', type: 'oauth-input', value: 'cred-stale' },
manualCredential: { id: 'manualCredential', type: 'short-input', value: 'cred-active' },
},
})
it('does not detect a DORMANT basic credential while advanced is active (no required ref / sync gate)', () => {
vi.mocked(getBlock).mockReturnValue(credBlock())
const scan = scanWorkflowReferences([scanBlock({ credential: 'advanced' })], () => null)
expect(scan.references.filter((ref) => ref.kind === 'credential')).toEqual([])
expect(scan.unmapped.filter((ref) => ref.kind === 'credential')).toEqual([])
})
it('detects the ACTIVE basic credential as a required reference (basic active)', () => {
vi.mocked(getBlock).mockReturnValue(credBlock())
const scan = scanWorkflowReferences([scanBlock({ credential: 'basic' })], () => null)
const creds = scan.references.filter((ref) => ref.kind === 'credential')
expect(creds).toHaveLength(1)
expect(creds[0].sourceId).toBe('cred-stale')
expect(creds[0].required).toBe(true)
})
it('skips DETECTION for a dormant member but still REWRITES its value (separation)', () => {
vi.mocked(getBlock).mockReturnValue(credBlock())
const result = remapForkSubBlocks(
{
credential: { id: 'credential', type: 'oauth-input', value: 'cred-stale' },
manualCredential: { id: 'manualCredential', type: 'short-input', value: 'cred-active' },
},
() => null,
'promote',
{ blockType: 'gmail', canonicalModes: { credential: 'advanced' } }
)
// Detection skipped (dormant basic), so it never gates sync...
expect(result.references.filter((ref) => ref.kind === 'credential')).toEqual([])
// ...but the dual-mode rewrite still cleared the unresolved dormant basic credential.
expect(result.subBlocks.credential.value).toBe('')
// The advanced escape-hatch id is preserved verbatim (not auto-remapped).
expect(result.subBlocks.manualCredential.value).toBe('cred-active')
})
})
describe('collectClearedDependents', () => {
it('flags a required dependent the target had set but the merge left empty', () => {
vi.mocked(getBlock).mockReturnValue(
@@ -18,12 +18,19 @@ import {
type StructuredWorkflowSearchResourceKind,
} from '@/lib/workflows/search-replace/resources/registry'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isCanonicalPair,
isNonEmptyValue,
resolveCanonicalMode,
} from '@/lib/workflows/subblocks/visibility'
import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types'
import { remapForkFileUploadValue } from '@/lib/workspaces/fork/remap/remap-files'
import {
collectForkFileUploadKeys,
remapForkFileUploadValue,
} from '@/lib/workspaces/fork/remap/remap-files'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
@@ -38,7 +45,12 @@ export type ForkRemapKind = z.infer<typeof forkRemapKindSchema>
const logger = createLogger('WorkspaceForkRemapReferences')
const REQUIRED_KINDS = new Set<ForkRemapKind>(['credential', 'env-var'])
/**
* Reference kinds whose absence BLOCKS a sync (they gate `requiredComplete` and are resolved by
* mapping), as opposed to optional kinds that silently clear. Exported so the cleared-ref preview
* can exclude them - a required ref is a blocker, never a silent "will be cleared" item.
*/
export const REQUIRED_KINDS = new Set<ForkRemapKind>(['credential', 'env-var'])
/**
* Id-based override kind for a TOOL param's credential, resolved by subblock id so a
@@ -59,20 +71,43 @@ export const REGISTRY_KIND_TO_FORK_KIND: Partial<
> = {
'oauth-credential': 'credential',
'knowledge-base': 'knowledge-base',
'knowledge-document': 'knowledge-document',
table: 'table',
'mcp-server': 'mcp-server',
}
// `file` and `knowledge-document` are intentionally excluded from the generic
// registry path. `file-upload` (workspace files) is remapped by storage key via
// `remapForkFileUploadValue`; `file-selector` (external provider file ids,
// credential-scoped) carries over unchanged; `document-selector` is cleared by the
// `dependsOn` rule (clearDependentsOnRemap) when its parent knowledge base is remapped.
// `mcp-tool-selector` is likewise cleared by `dependsOn` when its `mcp-server-selector`
// parent is remapped - the tool list is server-scoped and may differ in the target.
// `file` is intentionally excluded from the generic registry path: `file-upload`
// (workspace files) is remapped by storage key via `remapForkFileUploadValue`, and
// `file-selector` (external provider file ids, credential-scoped) carries over
// unchanged. `document-selector` (`knowledge-document`) IS remapped through the doc-id
// map when its referenced document was copied into the fork; an unmapped document (its
// parent KB wasn't copied, or the doc wasn't copyable) resolves to null and is cleared,
// and `clearDependentsOnRemap` still clears it as a `knowledgeBaseId` dependent when the
// parent KB itself is unmapped. `mcp-tool-selector` is cleared by `dependsOn` when its
// `mcp-server-selector` parent is remapped - the tool list is server-scoped and may
// differ in the target.
/** Matches `{{ENV_KEY}}` references inside subblock values; shared with cascade detection. */
export const ENV_REF_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g
/**
* Rewrite `{{ENV}}` references in free text (a copied custom tool's `code`, an MCP url/header)
* through an env-name resolver, so a promote that renames an env var (e.g. SLACK_API_KEY ->
* SLACK_API_KEY_TEST) keeps the copied text pointing at the right key. A key the resolver leaves
* unmapped (null/undefined) or maps to the same name is kept verbatim - a graceful no-op so an env
* that exists under the same name in the target still works. Pure; mirrors {@link remapEnvInValue}'s
* preserve-by-name policy for the string case.
*/
export function rewriteEnvRefsInText(
text: string,
resolveEnvName: (key: string) => string | null | undefined
): string {
if (!text) return text
return text.replace(ENV_REF_PATTERN, (full, key: string) => {
const target = resolveEnvName(key)
return target && target !== key ? `{{${target}}}` : full
})
}
/**
* A `credentialSet:<id>` reference points at an ORG-scoped credential set. A fork
* inherits its parent's org, so the set is already valid in the target — these refs
@@ -110,6 +145,16 @@ export interface RemapSubBlocksResult {
remappedKeys: Set<string>
}
/** Per-block context for the fork remap. `blockType`/`canonicalModes` gate DETECTION (not rewrite). */
export interface RemapForkContext {
blockId?: string
blockName?: string
/** Block type, to build the canonical index for active-member DETECTION gating (rewrite unaffected). */
blockType?: string
/** Canonical-mode overrides (`block.data.canonicalModes`), picking the active member per pair. */
canonicalModes?: CanonicalModeOverrides
}
function remapEnvInValue(
value: unknown,
resolve: ForkReferenceResolver,
@@ -232,8 +277,12 @@ export function remapToolBlockResources(
if (definition.kind === 'file') {
// file-upload (workspace file) remaps by storage key; file-selector (external
// provider id) carries over unchanged.
// provider id) carries over unchanged. Each key is recorded as a `file` reference so
// a nested tool's workspace file surfaces in the scan / unmapped set and can be copied.
if (config.type !== 'file-upload') continue
for (const fileKey of collectForkFileUploadKeys(currentValue)) {
opts.record?.('file', fileKey, opts.resolveFileKey(fileKey) != null)
}
const remapped = remapForkFileUploadValue(currentValue, opts.resolveFileKey)
if (remapped !== currentValue) {
setParam(paramId, remapped)
@@ -420,7 +469,7 @@ export function remapForkSubBlocks(
subBlocks: SubBlockRecord,
resolve: ForkReferenceResolver,
mode: 'create' | 'promote',
context?: { blockId?: string; blockName?: string }
context?: RemapForkContext
): RemapSubBlocksResult {
const clearUnresolved = true
const result: SubBlockRecord = {}
@@ -434,6 +483,25 @@ export function remapForkSubBlocks(
if (!mapped) unmapped.set(key, reference)
}
// DETECTION gate: a DORMANT canonical member's stale value must not be recorded as a reference
// (so it is never offered as a required mapping / copyable / usage and can't gate sync). The value
// REWRITE below is untouched - both basic + advanced ids are still remapped. Needs `blockType` to
// build the canonical index; callers that omit it (create-mode transforms) keep today's detection,
// and with `canonicalModes` absent the value heuristic keeps a populated member active (no-op).
const canonicalIndex = context?.blockType
? buildCanonicalIndex(getBlock(context.blockType)?.subBlocks ?? [])
: undefined
const detectionValues = canonicalIndex ? buildSubBlockValues(subBlocks) : {}
const isDormantCanonicalMember = (key: string): boolean => {
if (!canonicalIndex) return false
const baseKey = key.replace(/_\d+$/, '')
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[baseKey]
const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined
if (!group || !isCanonicalPair(group)) return false
const activeMode = resolveCanonicalMode(group, detectionValues, context?.canonicalModes)
return (activeMode === 'advanced') !== group.advancedIds.includes(baseKey)
}
for (const [subBlockKey, subBlock] of Object.entries(subBlocks)) {
if (!subBlock || typeof subBlock !== 'object') {
result[subBlockKey] = subBlock
@@ -450,6 +518,8 @@ export function remapForkSubBlocks(
const forkKind = definition ? REGISTRY_KIND_TO_FORK_KIND[definition.kind] : undefined
if (definition && forkKind && subBlockType) {
// A dormant canonical member is rewritten (below) but NOT detected as a reference.
const isDormant = isDormantCanonicalMember(subBlockKey)
const parsed = parseWorkflowSearchSubBlockResources(value, {
type: subBlockType as SubBlockType,
})
@@ -469,7 +539,7 @@ export function remapForkSubBlocks(
}
const target = resolve(forkKind, ref.rawValue)
const mapped = target != null
recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped)
if (!isDormant) recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped)
if (mapped) {
if (target !== ref.rawValue) {
const replaceResult = definition.codec.replace(value, ref.rawValue, target)
@@ -485,9 +555,25 @@ export function remapForkSubBlocks(
}
if (subBlockType === 'file-upload') {
// Workspace-file refs don't sync on promote (the target lacks the source's
// blob); clear them rather than carry a cross-workspace key. On fork, the
// resolver returns the copied key. `file-selector` (external) is untouched.
// Each workspace-file key is a `file` reference (keyed by storage key). Recording it
// surfaces the file in the scan / unmapped set so a sync can copy it into the target,
// exactly like fork - rather than silently clearing it. The resolver returns the copied
// key once the file has been copied; an unmapped (uncopied) key is dropped by the remap
// below. `file-selector` (external provider ids) is untouched.
for (const fileKey of collectForkFileUploadKeys(value)) {
recordReference(
`file:${fileKey}`,
{
kind: 'file',
sourceId: fileKey,
blockId: context?.blockId,
blockName: context?.blockName,
subBlockKey,
required: false,
},
resolve('file', fileKey) != null
)
}
value = remapForkFileUploadValue(value, (sourceKey) => resolve('file', sourceKey) ?? null)
} else if (subBlockType === 'tool-input' || subBlockType === 'skill-input') {
const record = (kind: ForkRemapKind, sourceId: string, mapped: boolean) =>
@@ -552,14 +638,32 @@ export function remapForkSubBlocks(
export function clearDependentsOnRemap(
subBlocks: SubBlockRecord,
blockType: string,
remappedKeys: ReadonlySet<string>
remappedKeys: ReadonlySet<string>,
canonicalModes?: CanonicalModeOverrides
): SubBlockRecord {
if (remappedKeys.size === 0) return subBlocks
const config = getBlock(blockType)
if (!config) return subBlocks
// Only a remap of the ACTIVE canonical member should clear its dependents: a dormant member's
// stale value being remapped/cleared must not clear a child that hangs off the active parent
// (only the active mode is serialized). With `canonicalModes` absent the value heuristic keeps a
// populated basic member active, so this is a no-op for the normal case; the gate only bites the
// toggle-with-stale-dormant edge (advanced active + a dormant basic that was remapped).
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
const values = buildSubBlockValues(subBlocks)
const isDormantCanonicalMember = (key: string): boolean => {
const baseKey = key.replace(/_\d+$/, '')
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[baseKey]
const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined
if (!group || !isCanonicalPair(group)) return false
const mode = resolveCanonicalMode(group, values, canonicalModes)
return (mode === 'advanced') !== group.advancedIds.includes(baseKey)
}
const toClear = new Set<string>()
for (const key of remappedKeys) {
if (isDormantCanonicalMember(key)) continue
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, key)) {
if (!remappedKeys.has(clear.subBlockId)) toClear.add(clear.subBlockId)
}
@@ -864,7 +968,7 @@ export function applyDependentOverrides(
export function remapSubBlocks(
subBlocks: SubBlockRecord,
resolve: ForkReferenceResolver,
context?: { blockId?: string; blockName?: string }
context?: RemapForkContext
): RemapSubBlocksResult {
return remapForkSubBlocks(subBlocks, resolve, 'promote', context)
}
@@ -872,10 +976,14 @@ export function remapSubBlocks(
/** A `copyWorkflowStateIntoTarget` subBlock transform that rewrites references via the resolver. */
export function createForkSubBlockTransform(
resolve: ForkReferenceResolver
): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord {
return (subBlocks, blockType) => {
): (
subBlocks: SubBlockRecord,
blockType: string,
canonicalModes?: CanonicalModeOverrides
) => SubBlockRecord {
return (subBlocks, blockType, canonicalModes) => {
const result = remapSubBlocks(subBlocks, resolve)
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys)
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys, canonicalModes)
}
}
@@ -890,7 +998,15 @@ export interface WorkflowReferenceScan {
* paths to surface what needs mapping and to block on unmapped required refs.
*/
export function scanWorkflowReferences(
blocks: Array<{ id: string; name: string; subBlocks: unknown }>,
blocks: Array<{
id: string
name: string
/** Block type, so detection can collapse a canonical pair to its active member. */
type?: string
subBlocks: unknown
/** `block.data.canonicalModes`, picking the active member per canonical pair for detection. */
canonicalModes?: CanonicalModeOverrides
}>,
resolve: ForkReferenceResolver
): WorkflowReferenceScan {
const references = new Map<string, ForkReference>()
@@ -903,6 +1019,8 @@ export function scanWorkflowReferences(
const blockResult = remapSubBlocks(block.subBlocks as SubBlockRecord, resolve, {
blockId: block.id,
blockName: block.name,
blockType: block.type,
canonicalModes: block.canonicalModes,
})
for (const reference of blockResult.references) {
const key = `${reference.kind}:${reference.sourceId}`
+7
View File
@@ -1583,6 +1583,13 @@ describe('transformBlockTool multi-instance unique IDs', () => {
expect(result?.id).toBe('table_query_rows_tbl_xyz')
})
it('resolves an advanced-only manual id via the heuristic when basic is empty and no mode is set', async () => {
// No canonicalModes entry: routing through resolveCanonicalMode picks advanced (empty basic),
// where the old `?? 'basic'` fallback dropped the advanced-only value.
const result = await transformTable({ manualTableId: 'tbl_only' })
expect(result?.id).toBe('table_query_rows_tbl_only')
})
it('appends the canonical table id when already present in params', async () => {
const result = await transformTable({ tableId: 'tbl_direct' })
expect(result?.id).toBe('table_query_rows_tbl_direct')
+17 -8
View File
@@ -14,8 +14,8 @@ import {
import {
buildCanonicalIndex,
type CanonicalGroup,
getCanonicalValues,
isCanonicalPair,
resolveActiveCanonicalValue,
} from '@/lib/workflows/subblocks/visibility'
import { isCustomTool } from '@/executor/constants'
import {
@@ -498,9 +498,14 @@ function resolveCanonicalResourceParams(
for (const group of canonicalGroups) {
const existing = resolved[group.canonicalId]
if (existing !== undefined && existing !== null && existing !== '') continue
const { basicValue, advancedValue } = getCanonicalValues(group, params)
const pairMode = canonicalModes?.[`${blockType}:${group.canonicalId}`] ?? 'basic'
const chosen = pairMode === 'advanced' ? advancedValue : basicValue
// Route through the canonical SOT: an explicit scoped override wins, else the value heuristic -
// no `?? 'basic'` (which ignored an advanced-only value when basic was empty).
const explicitMode = canonicalModes?.[`${blockType}:${group.canonicalId}`]
const chosen = resolveActiveCanonicalValue(
group,
params,
explicitMode ? { [group.canonicalId]: explicitMode } : undefined
)
if (chosen !== undefined) resolved[group.canonicalId] = chosen
}
return resolved
@@ -627,10 +632,14 @@ export async function transformBlockTool(
let result = { ...params }
for (const group of canonicalGroups) {
const { basicValue, advancedValue } = getCanonicalValues(group, result)
const scopedKey = `${block.type}:${group.canonicalId}`
const pairMode = canonicalModes?.[scopedKey] ?? 'basic'
const chosen = pairMode === 'advanced' ? advancedValue : basicValue
// Route through the canonical SOT: an explicit scoped override wins, else the value
// heuristic - no `?? 'basic'` (which dropped an advanced-only value when basic was empty).
const explicitMode = canonicalModes?.[`${block.type}:${group.canonicalId}`]
const chosen = resolveActiveCanonicalValue(
group,
result,
explicitMode ? { [group.canonicalId]: explicitMode } : undefined
)
const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[]
sourceIds.forEach((id) => delete result[id])
+45
View File
@@ -0,0 +1,45 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildCanonicalIndex, buildPreviewContextValues } from '@/tools/params-resolver'
const canonicalIndex = buildCanonicalIndex([
{
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
canonicalParamId: 'knowledgeBaseId',
mode: 'basic',
},
{
id: 'manualKnowledgeBaseId',
type: 'short-input',
canonicalParamId: 'knowledgeBaseId',
mode: 'advanced',
},
] as Parameters<typeof buildCanonicalIndex>[0])
const values = { knowledgeBaseSelector: 'kb-basic', manualKnowledgeBaseId: 'kb-advanced' }
describe('buildPreviewContextValues', () => {
it('honors an explicit advanced override so the preview matches execution', () => {
const result = buildPreviewContextValues(values, {
blockType: 'knowledge',
subBlocks: [],
canonicalIndex,
values,
overrides: { knowledgeBaseId: 'advanced' },
})
expect(result.knowledgeBaseId).toBe('kb-advanced')
})
it('falls back to the value heuristic (basic when present) without an override', () => {
const result = buildPreviewContextValues(values, {
blockType: 'knowledge',
subBlocks: [],
canonicalIndex,
values,
})
expect(result.knowledgeBaseId).toBe('kb-basic')
})
})
+8 -1
View File
@@ -8,6 +8,7 @@ import {
resolveCanonicalMode,
resolveDependencyValue,
type SubBlockCondition,
scopeCanonicalModesForTool,
} from '@/lib/workflows/subblocks/visibility'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
@@ -19,6 +20,7 @@ export {
isCanonicalPair,
resolveCanonicalMode,
resolveDependencyValue,
scopeCanonicalModesForTool,
type SubBlockCondition,
}
@@ -27,6 +29,11 @@ export interface ToolParamContext {
subBlocks: BlockSubBlockConfig[]
canonicalIndex: CanonicalIndex
values: Record<string, unknown>
/**
* Canonical-id-keyed mode overrides (the tool-scoped `canonicalModes`) so the preview honors an
* explicit basic/advanced toggle, matching execution. Omitted -> the value heuristic.
*/
overrides?: CanonicalModeOverrides
}
/**
@@ -41,7 +48,7 @@ export function buildPreviewContextValues(
for (const [canonicalId, group] of Object.entries(context.canonicalIndex.groupsById)) {
if (isCanonicalPair(group)) {
const mode = resolveCanonicalMode(group, context.values)
const mode = resolveCanonicalMode(group, context.values, context.overrides)
const { basicValue, advancedValue } = getCanonicalValues(group, context.values)
result[canonicalId] =
mode === 'advanced' ? (advancedValue ?? basicValue) : (basicValue ?? advancedValue)
@@ -0,0 +1,71 @@
-- migration-safe: data-only backfill. Expand-phase companion to the canonical-key collapse in
-- buildProviderConfig + the canonical-first poller reads. Populates each polling trigger's canonical
-- providerConfig key from the value the CURRENT poller already reads (basic-first), so NO deployed
-- trigger changes which resource it polls. Per provider the basic-first effective value is:
-- google-drive folderId <- folderId, else manualFolderId (canonical key == basic key)
-- google-sheets spreadsheetId <- spreadsheetId, else manualSpreadsheetId
-- google-sheets sheetName <- sheetName, else manualSheetName
-- google-calendar calendarId <- calendarId, else manualCalendarId
-- table tableId <- tableSelector, else manualTableId (canonical key is distinct)
-- For the Google triggers the canonical key IS the basic subblock key, so a row whose canonical key
-- is already set is left untouched (it already equals the basic-first read); only rows where it is
-- absent are filled from the advanced key. The table canonical key (tableId) was never written
-- before, so it is filled from tableSelector/manualTableId.
--
-- Idempotent: every statement only writes where the canonical key is still empty, so a replay (a
-- failed migration re-runs unjournaled files from the top) is a no-op. Bounded: each UPDATE is scoped
-- to one provider and the empty-canonical predicate, touching only un-backfilled rows. Safe under
-- concurrent writes: the still-live previous app version never writes these canonical keys (it writes
-- the raw subblock keys), and any row it inserts after this runs is handled by the pollers'
-- transitional basic-first fallback until its next redeploy. providerConfig is a `json` column, so it
-- is cast to jsonb for the `||` merge and back to json for storage.
UPDATE "webhook"
SET "provider_config" = (
COALESCE(("provider_config")::jsonb, '{}'::jsonb)
|| jsonb_build_object('folderId', ("provider_config")::jsonb ->> 'manualFolderId')
)::json
WHERE "provider" = 'google-drive'
AND NULLIF(("provider_config")::jsonb ->> 'folderId', '') IS NULL
AND NULLIF(("provider_config")::jsonb ->> 'manualFolderId', '') IS NOT NULL;
--> statement-breakpoint
UPDATE "webhook"
SET "provider_config" = (
COALESCE(("provider_config")::jsonb, '{}'::jsonb)
|| jsonb_build_object('spreadsheetId', ("provider_config")::jsonb ->> 'manualSpreadsheetId')
)::json
WHERE "provider" = 'google-sheets'
AND NULLIF(("provider_config")::jsonb ->> 'spreadsheetId', '') IS NULL
AND NULLIF(("provider_config")::jsonb ->> 'manualSpreadsheetId', '') IS NOT NULL;
--> statement-breakpoint
UPDATE "webhook"
SET "provider_config" = (
COALESCE(("provider_config")::jsonb, '{}'::jsonb)
|| jsonb_build_object('sheetName', ("provider_config")::jsonb ->> 'manualSheetName')
)::json
WHERE "provider" = 'google-sheets'
AND NULLIF(("provider_config")::jsonb ->> 'sheetName', '') IS NULL
AND NULLIF(("provider_config")::jsonb ->> 'manualSheetName', '') IS NOT NULL;
--> statement-breakpoint
UPDATE "webhook"
SET "provider_config" = (
COALESCE(("provider_config")::jsonb, '{}'::jsonb)
|| jsonb_build_object('calendarId', ("provider_config")::jsonb ->> 'manualCalendarId')
)::json
WHERE "provider" = 'google-calendar'
AND NULLIF(("provider_config")::jsonb ->> 'calendarId', '') IS NULL
AND NULLIF(("provider_config")::jsonb ->> 'manualCalendarId', '') IS NOT NULL;
--> statement-breakpoint
UPDATE "webhook"
SET "provider_config" = (
COALESCE(("provider_config")::jsonb, '{}'::jsonb)
|| jsonb_build_object('tableId', COALESCE(
NULLIF(("provider_config")::jsonb ->> 'tableSelector', ''),
NULLIF(("provider_config")::jsonb ->> 'manualTableId', '')
))
)::json
WHERE "provider" = 'table'
AND NULLIF(("provider_config")::jsonb ->> 'tableId', '') IS NULL
AND COALESCE(
NULLIF(("provider_config")::jsonb ->> 'tableSelector', ''),
NULLIF(("provider_config")::jsonb ->> 'manualTableId', '')
) IS NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -1765,6 +1765,13 @@
"when": 1782685998491,
"tag": "0252_remove_a2a",
"breakpoints": true
},
{
"idx": 253,
"version": "7",
"when": 1782860073503,
"tag": "0253_canonical_trigger_provider_config",
"breakpoints": true
}
]
}